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

|
||||
|
||||
### 频道媒体下载
|
||||
|
||||
- 使用 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。
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user