feat(ws2): ADR-093 路由統一 — BIGINT + NotificationMatrix + feature flag

## 修復

### T2.1 BigInteger overflow 修復
- `db/models.py`: telegram_chat_id Integer → BigInteger
  (原 int32 無法容納群組 ID -1003711974679)

### T2.2 移除 CAST workaround
- `approval_db.py:739`: 移除 CAST(:telegram_chat_id AS BIGINT)
  ORM 已正確使用 BigInteger,workaround 可退役

### T2.3 Redis key 一致性修復
- `heartbeat_report_service.py:575`: telegram:polling_leader → telegram:polling:leader
  (telegram_gateway.py 使用冒號分隔,heartbeat 用底線是 bug)

## 新增

### T2.4 notification_matrix.py
- `services/notification_matrix.py`: ADR-093 路由矩陣
  - Destination(DM/GROUP/BOTH) + RoutingRule dataclass
  - NOTIFICATION_ROUTING dict(TYPE-1 ~ TYPE-8M 完整映射)
  - resolve_chat_ids(type, dm, group, *, tg_group_cutover=False) 灰階切流 API

### T2.5 telegram_gateway.py feature flag 保護
- line 43: 加 notification_matrix import
- line 1827-1834: TG_GROUP_CUTOVER=False 時維持舊行為
  TG_GROUP_CUTOVER=True 時解除 _interactive_types 黑名單,由矩陣控制

### T2.6 Migration SQL
- `migrations/adr093_notification_routing.sql`:
  - CREATE TABLE approval_records (telegram_chat_id BIGINT)
  - CREATE ROLE awoooi_migrator (IF NOT EXISTS)
  - 含舊環境 ALTER COLUMN int→bigint 保護

## 測試同步
- `tests/integration/setup_test_schema.sql`: telegram_chat_id BIGINT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-25 01:45:16 +08:00
parent 054d0ae422
commit 6d5fd3c124
8 changed files with 176 additions and 8 deletions

View File

@@ -0,0 +1,81 @@
"""
Notification routing matrix — ADR-093
======================================
單一矩陣決定每種通知類型的發送目標,取代 telegram_gateway.py 內 24 處硬碼 chat_id。
設計原則:
- tg_group_cutover=False預設時維持舊行為灰階切流用
- tg_group_cutover=True 時由矩陣完全控制路由
- 未知通知類型預設發群組
2026-04-25 ogt + Claude Sonnet 4.6
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class Destination(str, Enum):
DM = "dm" # OPENCLAW_TG_CHAT_ID (個人 DM)
GROUP = "group" # SRE_GROUP_CHAT_ID
BOTH = "both" # 兩者都發(群組版去按鈕)
@dataclass(frozen=True)
class RoutingRule:
destination: Destination
strip_buttons_for_group: bool = False # BOTH 時群組版是否去除 Callback Button
# ADR-093 D1-D4 路由矩陣
# TYPE-5S / TYPE-7E 暫留 DMv2 再討論)
NOTIFICATION_ROUTING: dict[str, RoutingRule] = {
"TYPE-1": RoutingRule(Destination.GROUP),
"TYPE-2": RoutingRule(Destination.BOTH, strip_buttons_for_group=True),
"TYPE-3": RoutingRule(Destination.GROUP),
"TYPE-4": RoutingRule(Destination.GROUP),
"TYPE-4D": RoutingRule(Destination.GROUP),
"TYPE-5S": RoutingRule(Destination.DM),
"TYPE-6B": RoutingRule(Destination.GROUP),
"TYPE-7E": RoutingRule(Destination.BOTH, strip_buttons_for_group=False),
"TYPE-8M": RoutingRule(Destination.GROUP),
}
_DEFAULT_RULE = RoutingRule(Destination.GROUP)
def get_routing_rule(notification_type: str) -> RoutingRule:
"""根據通知類型回傳路由規則。未知類型預設發群組。"""
return NOTIFICATION_ROUTING.get(notification_type, _DEFAULT_RULE)
def resolve_chat_ids(
notification_type: str,
dm_chat_id: str,
group_chat_id: str,
*,
tg_group_cutover: bool = False,
) -> list[str]:
"""
回傳此通知應發送的 chat_id 清單。
tg_group_cutover=False 時維持原本的 DM only 行為(灰階切流用)。
"""
if not tg_group_cutover:
# Feature flag 關閉 → 維持舊行為DM only for interactive, group for info
interactive = {"TYPE-3", "TYPE-4", "TYPE-4D", "TYPE-8M"}
if notification_type in interactive:
return [dm_chat_id] if dm_chat_id else []
return [group_chat_id] if group_chat_id else []
rule = get_routing_rule(notification_type)
if rule.destination == Destination.DM:
return [dm_chat_id] if dm_chat_id else []
elif rule.destination == Destination.GROUP:
return [group_chat_id] if group_chat_id else []
else: # BOTH
result = []
if group_chat_id:
result.append(group_chat_id)
if dm_chat_id:
result.append(dm_chat_id)
return result