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 双向客服机器

-### 频道媒体下载
-
-- 使用 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}}
}}
-