Skip to content

Commit 91524a7

Browse files
committed
feat(bot): announce successful private rule submissions
1 parent 95161c7 commit 91524a7

8 files changed

Lines changed: 220 additions & 5 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
- ✅ 自动检查 GitHub 规则与 GEOSITE:CN
66
- ✅ DNS / NS 归属地判断,给出添加建议
7-
-群组验证与群组 @ 提及模式
7+
-群组验证、群组 @ 提及与私聊成功播报
88
- ✅ Docker 部署,支持 `linux/amd64``linux/arm64`
99

1010
> 不提供 Windows 支持,仅建议 Docker 部署。
@@ -52,7 +52,7 @@ skip - 跳过说明
5252

5353
> Token 只显示一次,请妥善保存。
5454
55-
### 获取群组 ID(群组模式 / 群组验证需要
55+
### 获取群组 ID(群组模式 / 群组验证 / 群组播报需要
5656

5757
@userinfobot 加入群组,它会返回完整群组 ID(通常以 `-100` 开头)。
5858

@@ -121,6 +121,7 @@ docker compose logs -f rule-bot
121121
| `REQUIRED_GROUP_NAME` | 群组验证名称 ||
122122
| `REQUIRED_GROUP_LINK` | 群组验证链接 ||
123123
| `ALLOWED_GROUP_IDS` | 群组模式允许的群组 ID,逗号分隔 ||
124+
| `ANNOUNCEMENT_GROUP_ID` | 私聊成功提交后的群组播报目标 ID ||
124125
| `ADMIN_USER_IDS` | 管理员 Telegram 用户 ID,逗号分隔 ||
125126
| `TZ` | 时区 | `Asia/Shanghai` |
126127
| `DNS_CACHE_TTL` | DNS A 记录缓存秒数 | `60` |
@@ -173,6 +174,10 @@ docker compose logs -f rule-bot
173174

174175
同时配置 `REQUIRED_GROUP_ID/NAME/LINK` 后生效,未通过或校验失败会拒绝访问(失败即拒绝)。
175176

177+
### 群组播报(ANNOUNCEMENT_GROUP_ID)
178+
179+
配置后,仅在用户通过私聊成功写入新直连规则时发送一条静默群消息。播报只包含提交结果、规则类型、域名与提交链接,不包含用户名、用户 ID、说明或私聊内容。Bot 未入群、无发言权限、请求超时或发送失败都不会改变私聊添加结果;群内直接添加不会触发重复播报。
180+
176181
### 管理员模式(ADMIN_USER_IDS)
177182

178183
配置 `ADMIN_USER_IDS` 后,指定的管理员用户可以:

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ services:
5656
# 留空则关闭此功能(仅支持私聊模式)
5757
# - ALLOWED_GROUP_IDS=-1001234567890,-1009876543210
5858

59+
# 群组播报 (可选: 私聊成功添加域名后发送隐私安全的规则更新通知)
60+
# Bot 必须已加入目标群组并拥有发言权限;发送失败不影响私聊添加结果
61+
# - ANNOUNCEMENT_GROUP_ID=-1001234567890
62+
5963
# 管理员配置 (可选: 指定管理员用户 ID,拥有强制添加域名的权限)
6064
# 支持逗号分隔多个 Telegram 用户 ID,例如:123456789,987654321
6165
# 管理员可以强制添加被系统检测拒绝的域名

src/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ def __init__(self):
7878
# 支持逗号分隔的多个群组 ID,例如:-1001234567890,-1009876543210
7979
self.ALLOWED_GROUP_IDS = self._parse_group_ids(os.getenv("ALLOWED_GROUP_IDS", ""))
8080

81+
# 私聊成功提交后的群组播报(独立配置,避免改变现有群组行为)
82+
announcement_group_id_raw = os.getenv("ANNOUNCEMENT_GROUP_ID", "").strip()
83+
self.ANNOUNCEMENT_GROUP_ID = self._parse_required_group_id(
84+
announcement_group_id_raw
85+
)
86+
if announcement_group_id_raw and not self.ANNOUNCEMENT_GROUP_ID:
87+
logger.warning(f"无效的 ANNOUNCEMENT_GROUP_ID: {announcement_group_id_raw}")
88+
8189
# 管理员配置(Telegram 用户 ID 列表)
8290
# 支持逗号分隔的多个用户 ID,例如:123456789,987654321
8391
self.ADMIN_USER_IDS = self._parse_user_ids(os.getenv("ADMIN_USER_IDS", ""))

src/handlers/handler_manager.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,24 @@ async def _handle_data_update(self, changes: Dict[str, bool]) -> None:
9393
if changes.get("geoip") or changes.get("cn_ipv4"):
9494
self.geoip_service.reload()
9595

96+
async def _announce_private_addition(self, chat, domain: str, add_result: dict) -> bool:
97+
"""Broadcast only successful additions that originated in private chat."""
98+
if (
99+
not self.group_service
100+
or not add_result.get("success")
101+
or getattr(chat, "type", None) != "private"
102+
):
103+
return False
104+
try:
105+
return await self.group_service.announce_rule_submission(
106+
domain,
107+
add_result.get("commit_sha", ""),
108+
add_result.get("commit_url", ""),
109+
)
110+
except Exception as e:
111+
logger.warning("群组播报调用异常,不影响私聊添加结果: {}", e)
112+
return False
113+
96114
async def check_and_add_domain_auto(
97115
self,
98116
domain: str,
@@ -1359,6 +1377,12 @@ async def _handle_admin_force_add_callback(self, query, user_id: int, data: str)
13591377
reply_markup = InlineKeyboardMarkup(keyboard)
13601378

13611379
await query.edit_message_text(result_text, reply_markup=reply_markup, parse_mode='Markdown')
1380+
if add_result.get("success"):
1381+
await self._announce_private_addition(
1382+
getattr(query.message, "chat", None),
1383+
target_domain,
1384+
add_result,
1385+
)
13621386
self.set_user_state(user_id, "waiting_add_domain")
13631387

13641388
except Exception as e:
@@ -1522,6 +1546,12 @@ async def _add_domain_to_github(self, query, user_id: int, description: str):
15221546
reply_markup = InlineKeyboardMarkup(keyboard)
15231547

15241548
await query.edit_message_text(result_text, reply_markup=reply_markup, parse_mode='Markdown')
1549+
if add_result.get("success"):
1550+
await self._announce_private_addition(
1551+
getattr(query.message, "chat", None),
1552+
target_domain,
1553+
add_result,
1554+
)
15251555

15261556
# 保持添加模式,便于继续输入域名
15271557
self.set_user_state(user_id, "waiting_add_domain")
@@ -1587,6 +1617,12 @@ async def _add_domain_to_github_message(self, message, user_id: int, description
15871617
reply_markup = InlineKeyboardMarkup(keyboard)
15881618

15891619
await processing_msg.edit_text(result_text, reply_markup=reply_markup, parse_mode='Markdown')
1620+
if add_result.get("success"):
1621+
await self._announce_private_addition(
1622+
getattr(message, "chat", None),
1623+
target_domain,
1624+
add_result,
1625+
)
15901626

15911627
# 保持添加模式,便于继续输入域名
15921628
self.set_user_state(user_id, "waiting_add_domain")

src/services/group_service.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""
2-
群组验证服务
3-
检查用户是否加入指定群组
2+
群组服务
3+
检查用户是否加入指定群组,并提供隐私安全的规则提交播报
44
"""
55

6+
import asyncio
67
from typing import Optional
78
from loguru import logger
89
from telegram import Bot
@@ -19,6 +20,7 @@ def __init__(self, config: Config, bot: Bot):
1920
self.config = config
2021
self.bot = bot
2122
self._group_check_enabled = bool(getattr(config, "GROUP_CHECK_ENABLED", False))
23+
self._announcement_group_id = getattr(config, "ANNOUNCEMENT_GROUP_ID", None)
2224
self._membership_cache = TTLCache[int, bool](2048, 300)
2325

2426
def is_group_check_enabled(self) -> bool:
@@ -77,3 +79,63 @@ def get_join_group_message(self) -> str:
7779
message += "加入后请重新尝试使用机器人功能。"
7880

7981
return message
82+
83+
async def announce_rule_submission(
84+
self,
85+
domain: str,
86+
commit_sha: str = "",
87+
commit_url: str = "",
88+
) -> bool:
89+
"""Best-effort broadcast that never changes the core add result."""
90+
if not self._announcement_group_id:
91+
return False
92+
93+
short_sha = (commit_sha or "")[:8]
94+
message = (
95+
"📣 *直连规则更新*\n\n"
96+
"✅ *结果:* 已成功提交\n"
97+
"🧭 *类型:* `DOMAIN-SUFFIX`\n"
98+
f"🌐 *域名:* `{domain}`"
99+
)
100+
if commit_url and short_sha:
101+
message += f"\n🔗 *提交:* [查看 {short_sha}]({commit_url})"
102+
103+
try:
104+
await asyncio.wait_for(
105+
self.bot.send_message(
106+
chat_id=self._announcement_group_id,
107+
text=message,
108+
parse_mode="Markdown",
109+
disable_notification=True,
110+
disable_web_page_preview=True,
111+
),
112+
timeout=8,
113+
)
114+
logger.info(
115+
"群组播报已发送: group={}, domain={}, commit={}",
116+
self._announcement_group_id,
117+
domain,
118+
short_sha or "unknown",
119+
)
120+
return True
121+
except asyncio.TimeoutError:
122+
logger.warning(
123+
"群组播报超时,不影响规则提交: group={}, domain={}",
124+
self._announcement_group_id,
125+
domain,
126+
)
127+
except TelegramError as e:
128+
logger.warning(
129+
"群组播报失败,不影响规则提交: group={}, domain={}, error={}",
130+
self._announcement_group_id,
131+
domain,
132+
e,
133+
)
134+
except Exception as e:
135+
logger.warning(
136+
"群组播报异常,不影响规则提交: group={}, domain={}, error={}",
137+
self._announcement_group_id,
138+
domain,
139+
e,
140+
)
141+
return False

tests/test_config.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import os
2+
import unittest
3+
from unittest.mock import patch
4+
5+
from src.config import Config
6+
7+
8+
class TestConfig(unittest.TestCase):
9+
def test_announcement_group_id_is_optional_and_parsed(self):
10+
env = {
11+
"TELEGRAM_BOT_TOKEN": "telegram-token",
12+
"GITHUB_TOKEN": "github-token",
13+
"GITHUB_REPO": "example/repo",
14+
"DIRECT_RULE_FILE": "rules.list",
15+
"ANNOUNCEMENT_GROUP_ID": "-1001234567890",
16+
}
17+
with patch.dict(os.environ, env, clear=True):
18+
config = Config()
19+
20+
self.assertEqual(config.ANNOUNCEMENT_GROUP_ID, -1001234567890)
21+
22+
23+
if __name__ == "__main__":
24+
unittest.main()

tests/test_group_service.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
import sys
33
import unittest
44
from types import SimpleNamespace
5+
from unittest.mock import AsyncMock
6+
7+
from telegram.error import TelegramError
58

69
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
710

@@ -23,5 +26,58 @@ def test_join_group_message_escapes_markdown_sensitive_content(self):
2326
self.assertIn("https://t.me/group\\_name\\(test\\)", message)
2427

2528

29+
class TestGroupAnnouncements(unittest.IsolatedAsyncioTestCase):
30+
async def test_disabled_announcement_does_not_call_telegram(self):
31+
bot = AsyncMock()
32+
config = SimpleNamespace(
33+
GROUP_CHECK_ENABLED=False,
34+
ANNOUNCEMENT_GROUP_ID=None,
35+
)
36+
service = GroupService(config, bot)
37+
38+
result = await service.announce_rule_submission("example.com", "abc123")
39+
40+
self.assertFalse(result)
41+
bot.send_message.assert_not_awaited()
42+
43+
async def test_announcement_is_concise_and_privacy_safe(self):
44+
bot = AsyncMock()
45+
config = SimpleNamespace(
46+
GROUP_CHECK_ENABLED=False,
47+
ANNOUNCEMENT_GROUP_ID=-1001234567890,
48+
)
49+
service = GroupService(config, bot)
50+
51+
result = await service.announce_rule_submission(
52+
"example.com",
53+
"abcdef1234567890",
54+
"https://github.com/example/repo/commit/abcdef1234567890",
55+
)
56+
57+
self.assertTrue(result)
58+
kwargs = bot.send_message.await_args.kwargs
59+
self.assertEqual(kwargs["chat_id"], -1001234567890)
60+
self.assertIn("已成功提交", kwargs["text"])
61+
self.assertIn("DOMAIN-SUFFIX", kwargs["text"])
62+
self.assertIn("example.com", kwargs["text"])
63+
self.assertIn("abcdef12", kwargs["text"])
64+
self.assertNotIn("用户", kwargs["text"])
65+
self.assertNotIn("说明", kwargs["text"])
66+
self.assertTrue(kwargs["disable_notification"])
67+
68+
async def test_telegram_failure_is_isolated(self):
69+
bot = AsyncMock()
70+
bot.send_message.side_effect = TelegramError("forbidden")
71+
config = SimpleNamespace(
72+
GROUP_CHECK_ENABLED=False,
73+
ANNOUNCEMENT_GROUP_ID=-1001234567890,
74+
)
75+
service = GroupService(config, bot)
76+
77+
result = await service.announce_rule_submission("example.com")
78+
79+
self.assertFalse(result)
80+
81+
2682
if __name__ == "__main__":
2783
unittest.main()

tests/test_runtime_safety.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import unittest
55
from pathlib import Path
66
from types import SimpleNamespace
7-
from unittest.mock import MagicMock, patch
7+
from unittest.mock import AsyncMock, MagicMock, patch
88

99
from src import healthcheck
1010
from src.handlers.group_handler import GroupHandler
@@ -69,6 +69,26 @@ async def test_handler_manager_bounds_user_states(self):
6969
self.assertEqual(len(manager.user_states), 4)
7070
self.assertIn(9, manager.user_states)
7171

72+
async def test_announcement_only_runs_for_private_success(self):
73+
manager = HandlerManager.__new__(HandlerManager)
74+
manager.group_service = SimpleNamespace(
75+
announce_rule_submission=AsyncMock(return_value=True)
76+
)
77+
result = {"success": True, "commit_sha": "abc", "commit_url": "https://example.test"}
78+
79+
private_result = await manager._announce_private_addition(
80+
SimpleNamespace(type="private"), "example.com", result
81+
)
82+
group_result = await manager._announce_private_addition(
83+
SimpleNamespace(type="supergroup"), "other.com", result
84+
)
85+
86+
self.assertTrue(private_result)
87+
self.assertFalse(group_result)
88+
manager.group_service.announce_rule_submission.assert_awaited_once_with(
89+
"example.com", "abc", "https://example.test"
90+
)
91+
7292
async def test_group_mention_uses_utf16_aware_parser(self):
7393
handler = GroupHandler.__new__(GroupHandler)
7494
entity = SimpleNamespace(type="mention")

0 commit comments

Comments
 (0)