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:
@@ -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:
|
||||
|
||||
50
apps/api/src/hermes/approvers.py
Normal file
50
apps/api/src/hermes/approvers.py
Normal 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
|
||||
Reference in New Issue
Block a user