feat: group/channel keyword monitor with user session; remove channel media

- Add user_session group monitor: Telethon listens to group/channel messages, matches keywords, notifies admin via bot
- Fix Telethon Message.caption AttributeError (use msg.message instead)
- Fix two Telethon clients conflicting on same session (merged into one)
- Remove channel media download/forward feature (not needed)
- Remove 'channel-media' from web panel navigation
- Fix nav label overlap issue (reduced negative margin)
- Docker: preserve WEB_PANEL_HOST env var from docker-compose (0.0.0.0)
- Update README: new changelog, remove channel media docs
This commit is contained in:
GongyiChuren
2026-06-02 02:41:27 +08:00
parent 639c655e06
commit 99f59df7a0
3 changed files with 44 additions and 120 deletions
+14 -19
View File
@@ -1,7 +1,7 @@
<div align="center">
<h1>tg-watchbot</h1>
<p>Telegram 双向客服机器人 + Web/RSS 监控推送 + 频道媒体下载 + 可视化管理面板</p>
<p>双向对话 · 关键词监控 · 频道媒体下载 · 私聊广告拦截 · 多管理员 · 配置导入导出</p>
<p>Telegram 双向客服机器人 + Web/RSS 监控推送 + 群组/频道关键词监听 + 可视化管理面板</p>
<p>双向对话 · 关键词监控 · 群组/频道监听 · 私聊广告拦截 · 多管理员 · 配置导入导出</p>
<p>
<a href="#ai-one-line-install">AI 一句话安装</a> ·
<a href="#docker-install">Docker 安装</a> ·
@@ -13,12 +13,12 @@
</div>
## 简介:
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。
+28 -97
View File
@@ -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}}
}}
</style></head><body><div class=shell><aside><div class=brand><div class=mark><i></i></div><div><b>tg-watchbot</b><small>Telegram 自动化</small></div></div><nav><section><b>常用</b><a href='/'>总览</a><a href='/inbox'>收件箱</a><a href='/users'>用户</a><a href='/send'>发消息</a></section><section><b>转发</b><a href='/group-monitors'>群监听</a><a href='/channel-media'>频道媒体</a><a href='/monitor/events'>历史</a></section><section><b>设置</b><a href='/settings'>面板设置</a><a href='/yaml'>YAML</a><a href='/config/export'>导入导出</a></section><section><b>系统</b><a href='/update'>更新</a><a href='/logs'>日志</a><a href='/restart' onclick='return confirm("确定重启机器人服务?")'>重启</a><a class=logout href='/logout'>退出</a></section></nav></aside><main><div class=top><h1>{html_escape(title)}</h1><span class=badge>WatchBot Panel</span></div>
</style></head><body><div class=shell><aside><div class=brand><div class=mark><i></i></div><div><b>tg-watchbot</b><small>Telegram 自动化</small></div></div><nav><section><b>常用</b><a href='/'>总览</a><a href='/inbox'>收件箱</a><a href='/users'>用户</a><a href='/send'>发消息</a></section><section><b>转发</b><a href='/group-monitors'>群监听</a><a href='/monitor/events'>历史</a></section><section><b>设置</b><a href='/settings'>面板设置</a><a href='/yaml'>YAML</a><a href='/config/export'>导入导出</a></section><section><b>系统</b><a href='/update'>更新</a><a href='/logs'>日志</a><a href='/restart' onclick='return confirm("确定重启机器人服务?")'>重启</a><a class=logout href='/logout'>退出</a></section></nav></aside><main><div class=top><h1>{html_escape(title)}</h1><span class=badge>WatchBot Panel</span></div>
{body}<div class=friend-links><b>友链</b><a href='https://linux.do' target='_blank' rel='noopener noreferrer'>Linux.do</a><span>·</span><a href='https://www.nodeseek.com' target='_blank' rel='noopener noreferrer'>NodeSeek</a></div></main></div></body></html>"""
@@ -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())
+2 -4
View File
@@ -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