diff --git a/README.md b/README.md index ef73903..0dd5da5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

tg-watchbot

-

Telegram 双向客服机器人 + Web/RSS 监控推送 + 频道媒体下载 + 可视化管理面板

-

双向对话 · 关键词监控 · 频道媒体下载 · 私聊广告拦截 · 多管理员 · 配置导入导出

+

Telegram 双向客服机器人 + Web/RSS 监控推送 + 群组/频道关键词监听 + 可视化管理面板

+

双向对话 · 关键词监控 · 群组/频道监听 · 私聊广告拦截 · 多管理员 · 配置导入导出

AI 一句话安装 · Docker 安装 · @@ -13,12 +13,12 @@

## 简介: -tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器人**、**Web/RSS 监控推送** 和 **频道媒体下载** 合在一起: +tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器人**、**Web/RSS 监控推送** 和 **群组/频道关键词监听** 合在一起: - 普通用户私聊 Bot,消息会转发给管理员; - 管理员可以直接回复、主动发文字/图片、封禁/备注用户; - 后台定时监控 RSS 或网页,命中关键词、新条目、价格/库存变化后推送给管理员; -- 使用 Telethon 用户账号自动下载频道/群组中的视频、文档等媒体文件; +- 使用 Telethon 用户账号监听群组/频道消息,命中关键词后自动推送通知给管理员; - 自带一个 Web 管理面板,可配置监控目标、编辑 YAML、查看收件箱和日志。 项目为单文件应用,适合个人服务器、NAT 小鸡、轻量 VPS 直接用 systemd 跑。 @@ -30,6 +30,16 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器 ``` ## 更新日志 +### 2026-06-02 更新 + +- **群组/频道关键词监听**:使用 Telethon 用户账号监听群组和频道消息,命中关键词后自动推送通知给管理员。 +- 支持 `user_session` 监听模式:Bot 不需要在群里,用你自己的 TG 账号静默监听,更隐蔽。 +- 修复 Telethon `Message` 对象没有 `caption` 属性的错误,统一使用 `msg.message` 获取消息文本。 +- 修复两个 Telethon 客户端使用同一个 session 互相冲突的问题,合并为单个客户端。 +- 删除「频道媒体下载/转发」功能,精简为纯关键词监听。 +- Web 面板导航优化:删除「频道媒体」入口,修复导航标签被遮住的问题。 +- 已发现群聊自动记录:Telethon 收到消息的群组/频道会自动显示在面板,可一键创建监听。 + ### 2026-05-28 更新 - 新增「频道媒体转发」:使用 Telethon 用户账号登录 TG,实时转发群组/频道消息到你的 Telegram。 @@ -115,21 +125,6 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器 ![示例图片](https://pic.gongyichuren.de/file/1779287170665_17b7c8b4040d6334ea62a108d08db644.png) -### 频道媒体下载 - -- 使用 Telethon 用户账号(非 Bot)登录 Telegram,可访问已加入的所有频道和群组。 -- 面板「频道媒体」页面支持搜索已加入的群组/频道,一键添加监控。 -- 支持暂停/恢复监控(保留配置)、删除监控。 -- 支持实时自动下载新消息中的媒体,也支持手动触发下载历史媒体。 -- 支持断点续传:大文件下载中断后自动续传,不重复下载。 -- 支持并发下载控制:可设置同时下载数(1-10,默认 3)。 -- 支持 SOCKS5/HTTP 代理,适合国内服务器使用。 -- 支持按日期范围过滤:只下载指定时间段内的消息。 -- 支持关键词过滤、媒体类型选择(视频/文档/图片/音频)、文件大小限制。 -- 支持实时转发模式:群消息匹配后直接转发到你的 Telegram(含视频/文档原文),无需下载到服务器。 -- 下载完成可自动推送 Telegram 通知给管理员。 -- 需要在设置页填写 `TG_API_ID`、`TG_API_HASH`,然后扫码登录;也兼容手动填写 `TG_API_SESSION`。 - ### Web 管理面板 - 登录页 + HttpOnly session cookie,不使用丑陋的浏览器 Basic Auth。 diff --git a/app.py b/app.py index d77827b..0119d1e 100644 --- a/app.py +++ b/app.py @@ -1014,92 +1014,6 @@ async def channel_media_monitor_loop() -> None: await disconnect_channel_media_client() -async def channel_media_forward_listener() -> None: - """Real-time listener: forward messages from monitored groups to admin Telegram.""" - if TelegramClient is None or StringSession is None: - logger.info("channel media forward listener skipped: telethon not installed") - return - api_id_raw, api_hash, session = user_session_config() - if not api_id_raw or not api_hash or not session: - logger.info("channel media forward listener skipped: user session not configured") - return - try: - api_id = int(api_id_raw) - except Exception: - return - try: - client = TelegramClient(StringSession(session), api_id, api_hash) - - def _get_forward_monitors() -> dict[int, dict[str, Any]]: - monitors = channel_media_monitors_all() - result = {} - for m in monitors: - if m.get("status") != "active" or not m.get("forward_mode"): - continue - try: - cid = int(m["channel_id"]) - result[cid] = m - except (TypeError, ValueError): - continue - return result - - @client.on(events.NewMessage(incoming=True)) - async def on_new_message_for_forward(event: Any) -> None: - try: - chat_id = getattr(event, "chat_id", None) - if chat_id is None: - return - fwd_monitors = _get_forward_monitors() - monitor = fwd_monitors.get(int(chat_id)) - if not monitor: - return - msg = event.message - text = (msg.text or "") + " " + (msg.caption or "") - # Keyword filter - keywords_str = str(monitor.get("keywords") or "").strip() - if keywords_str: - keywords_list = [k.strip() for k in keywords_str.split(",") if k.strip()] - if keywords_list and not any(k.lower() in text.lower() for k in keywords_list): - return - # Media type filter - media_types_str = str(monitor.get("media_types") or "").strip() - if media_types_str: - allowed = {t.strip().lower() for t in media_types_str.split(",") if t.strip()} - if allowed and not msg.media: - has_text_only = bool(text.strip()) - if not has_text_only: - return - # Determine forward target - forward_to = str(monitor.get("forward_to") or "admin").strip() - if forward_to == "admin": - targets = all_admin_chat_ids() - elif forward_to == "saved": - targets = ["me"] - else: - try: - targets = [int(forward_to)] - except (TypeError, ValueError): - targets = all_admin_chat_ids() - # Forward the message - for target in targets: - try: - await msg.forward_to(target) - except Exception: - logger.exception("forward failed target=%s msg_id=%s", target, msg.id) - logger.info("forwarded message from chat=%s msg_id=%s to %s", chat_id, msg.id, targets) - except Exception: - logger.exception("on_new_message_for_forward error") - - await client.start() - logger.info("channel media forward listener started") - await client.run_until_disconnected() - except asyncio.CancelledError: - logger.info("channel media forward listener cancelled") - raise - except Exception: - logger.exception("channel media forward listener crashed") - - async def telethon_download_from_channel(monitor_id: int, download_history: bool = False) -> int: monitor = channel_media_monitor_get(monitor_id) if not monitor: @@ -1162,7 +1076,7 @@ async def telethon_download_from_channel(monitor_id: int, download_history: bool if channel_media_download_exists(channel_id, message.id): continue if keywords_list: - msg_text = (message.text or "") + " " + (message.caption or "") + msg_text = (message.message or getattr(message, "text", "") or "")[:500] if not any(k.lower() in msg_text.lower() for k in keywords_list): continue media = message.media @@ -1185,7 +1099,7 @@ async def telethon_download_from_channel(monitor_id: int, download_history: bool async def download_one(msg: Any, mt: str, fs: int) -> tuple[int, int]: nonlocal count, total_size_added async with semaphore: - caption = (msg.text or msg.caption or "")[:500] + caption = (msg.message or getattr(msg, "text", "") or "")[:500] sender_id = msg.sender_id or 0 file_name = "" for attr in (getattr(getattr(msg.media, "document", None), "attributes", None) or []): @@ -1588,13 +1502,34 @@ async def run_user_session_group_listener() -> None: client = TelegramClient(StringSession(session), api_id, api_hash) user_session_client = client - @client.on(events.NewMessage(incoming=True)) # type: ignore[misc] + @client.on(events.NewMessage) # type: ignore[misc] async def on_new_group_message(event: Any) -> None: - if not (getattr(event, "is_group", False) or getattr(event, "is_channel", False)): + chat_id = getattr(event, "chat_id", None) + if chat_id is None: return + cid = int(chat_id) + msg = event.message + # ---- group monitor (keywords) ---- + from_cfg = group_monitors() + cfg_chat_ids = {int(m["chat_id"]) for m in from_cfg if m.get("enabled")} + if cid in cfg_chat_ids: + pseudo = build_pseudo_message_from_user_session_event(event) + if pseudo is not None: + text_preview = (getattr(pseudo, "text", "") or "")[:80] + logger.info("user_session: msg chat=%s title=%s text=%s", pseudo.chat.id, getattr(pseudo.chat, "title", ""), text_preview) + record_discovered_group_chat_data( + int(pseudo.chat.id), + str(getattr(pseudo.chat, "title", "") or pseudo.chat.id), + str(getattr(pseudo.chat, "username", "") or ""), + ) + await handle_group_keyword_message(pseudo, listen_source="user_session") + pseudo = build_pseudo_message_from_user_session_event(event) if pseudo is None: + logger.debug("user_session: build_pseudo returned None for chat_id=%s", getattr(event, "chat_id", "?")) return + text_preview = (getattr(pseudo, "text", "") or "")[:80] + logger.info("user_session: msg from chat=%s title=%s text=%s", pseudo.chat.id, getattr(pseudo.chat, "title", ""), text_preview) record_discovered_group_chat_data( int(pseudo.chat.id), str(getattr(pseudo.chat, "title", "") or pseudo.chat.id), @@ -2920,7 +2855,7 @@ main{{padding:24px 30px;min-width:0;max-width:1440px;animation:mainIn .25s var(- nav{{display:grid;gap:13px}} nav section{{display:grid;gap:6px;padding:9px;border:3px solid var(--ink);background:#fff;box-shadow:3px 3px 0 var(--ink);transition:transform .18s var(--ease);contain:paint}} nav section:hover{{transform:translateY(-1px)}} -nav section>b{{display:inline-block;width:max-content;margin:-20px 0 2px -2px;padding:3px 8px;border:3px solid var(--ink);background:var(--yellow);font-size:12px;font-weight:900;text-transform:uppercase}} +nav section>b{{display:inline-block;width:max-content;margin:-12px 0 2px -2px;padding:3px 8px;border:3px solid var(--ink);background:var(--yellow);font-size:12px;font-weight:900;text-transform:uppercase}} nav a{{position:relative;padding:9px 10px;border:3px solid var(--ink);background:var(--white);color:var(--ink);font-weight:900;text-transform:uppercase;font-size:12px;box-shadow:2px 2px 0 var(--ink);transition:transform .14s var(--ease),box-shadow .14s var(--ease),background-color .14s var(--ease);will-change:transform}} nav section:nth-child(2)>b{{background:var(--blue);color:white}} nav section:nth-child(3)>b{{background:var(--red);color:white}} @@ -2983,7 +2918,7 @@ pre{{white-space:pre-wrap;background:#121212;color:#fff;padding:13px;border:4px @media (prefers-reduced-motion: reduce){{ *,*::before,*::after{{animation:none!important;transition:none!important}} }} -

{html_escape(title)}

WatchBot Panel
+

{html_escape(title)}

WatchBot Panel
{body}
""" @@ -4313,11 +4248,7 @@ async def main_async(run_once: bool = False, panel_only: bool = False) -> None: ) else: user_session_listener_task = asyncio.create_task(run_user_session_group_listener()) - if TelegramClient is not None and user_session_ready(): - asyncio.create_task(channel_media_forward_listener()) - logger.info("channel media forward listener started") - else: - logger.info("channel media forward listener skipped: telethon not installed or user session not configured") + await admin_send(f"tg-watchbot 已启动\n时间:{now_iso()}") logger.info("bot polling start") await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) diff --git a/docker-compose.yml b/docker-compose.yml index 62653f3..ec2e053 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,12 +5,10 @@ services: restart: unless-stopped ports: - "8765:8765" - env_file: - - .env environment: - WEB_PANEL_HOST: 0.0.0.0 + WEB_PANEL_HOST: "0.0.0.0" volumes: - - ./.env:/app/.env + - ./.env:/app/.env:ro - ./config.yaml:/app/config.yaml - ./tg-watchbot.sqlite3:/app/tg-watchbot.sqlite3 - ./tg-watchbot.log:/app/tg-watchbot.log