diff --git a/apps/api/src/api/v1/telegram_webhook.py b/apps/api/src/api/v1/telegram_webhook.py index 1e63fa3d..a4b1dcd4 100644 --- a/apps/api/src/api/v1/telegram_webhook.py +++ b/apps/api/src/api/v1/telegram_webhook.py @@ -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: diff --git a/apps/api/src/hermes/approvers.py b/apps/api/src/hermes/approvers.py new file mode 100644 index 00000000..f4725200 --- /dev/null +++ b/apps/api/src/hermes/approvers.py @@ -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