feat(ws5): ADR-093 Approvers 白名單 chat_member 同步

- hermes/approvers.py: Redis Set hermes:approvers:{group_id}
  sync_member_joined / sync_member_left / get_approvers / is_approved_member
  空集合 → 降級不阻擋,由 config whitelist 把關
- telegram_webhook.py: chat_member / my_chat_member 事件處理
  member/administrator/creator → sadd; left/kicked → srem
  get_redis() 同步取 async client,再 await approvers 函數

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-25 02:07:52 +08:00
parent 2572ec46d2
commit 834a65c833
2 changed files with 73 additions and 0 deletions

View File

@@ -71,6 +71,29 @@ async def telegram_webhook(request: Request) -> dict:
update_id=body.get("update_id"),
)
# WS5: chat_member 同步 Approvers 白名單ADR-093
if update_type in ("chat_member", "my_chat_member") or (
"chat_member" in body or "my_chat_member" in body
):
event = body.get("chat_member") or body.get("my_chat_member", {})
if event:
group_id = str(event.get("chat", {}).get("id", ""))
member = event.get("new_chat_member", {})
user = member.get("user", {})
member_user_id = user.get("id", 0)
status = member.get("status", "")
if group_id and member_user_id:
try:
from src.core.redis_client import get_redis # type: ignore[import]
from src.hermes.approvers import sync_member_joined, sync_member_left # type: ignore[import]
redis = get_redis()
if status in ("member", "administrator", "creator"):
await sync_member_joined(redis, group_id, member_user_id)
elif status in ("left", "kicked"):
await sync_member_left(redis, group_id, member_user_id)
except Exception as exc:
logger.error("approvers_sync_error", error=str(exc))
# WS4: Hermes NL 接入ADR-095
# 只在 HERMES_NL_ENABLED=true 且 update_type=message 時處理
if update_type == "message" and settings.HERMES_NL_ENABLED:

View File

@@ -0,0 +1,50 @@
"""
Approvers 白名單管理 — ADR-093 群組遷移安全模型
chat_member 事件 → 同步 Redis Set hermes:approvers:{group_id}
"""
from __future__ import annotations
import structlog
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
logger = structlog.get_logger()
APPROVERS_KEY_PREFIX = "hermes:approvers"
def _approvers_key(group_id: str | int) -> str:
return f"{APPROVERS_KEY_PREFIX}:{group_id}"
async def sync_member_joined(redis, group_id: str | int, user_id: int) -> None:
"""成員加入群組 → 加入 Approvers Redis Set"""
key = _approvers_key(group_id)
await redis.sadd(key, str(user_id))
logger.info("approvers_member_joined", group_id=group_id, user_id=user_id)
async def sync_member_left(redis, group_id: str | int, user_id: int) -> None:
"""成員離開群組 → 從 Approvers Redis Set 移除"""
key = _approvers_key(group_id)
await redis.srem(key, str(user_id))
logger.info("approvers_member_left", group_id=group_id, user_id=user_id)
async def get_approvers(redis, group_id: str | int) -> set[int]:
"""取得群組 Approvers 集合(空集 = 未初始化,退回 config whitelist"""
key = _approvers_key(group_id)
raw = await redis.smembers(key)
return {int(uid) for uid in raw if uid}
async def is_approved_member(redis, group_id: str | int, user_id: int) -> bool:
"""
user_id 是否在群組 Approvers Set 中。
若 Redis Set 為空(未初始化),回傳 True 讓 caller 退回 config whitelist。
"""
approvers = await get_approvers(redis, group_id)
if not approvers:
return True # 未初始化 → 不阻擋,由 config whitelist 把關
return user_id in approvers