diff --git a/AGENTS.md b/AGENTS.md index 38d3c53..2041e74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,14 @@ ## AI 產品邊界 - 不區分免費/付費用戶,AI 為選用;未提供 Key 或關閉 AI 時使用內建規則解析。 -- 平台不提供共用 Key,也不代付費用;只使用請求者的個人 Key,設定僅限私聊。 +- 平台不提供 Key,也不代付費用;私聊使用請求者個人 Key,Key 新增與管理僅限私聊。 +- 群組 AI 必須由持有人私聊授權指定既有 Key,並由群組管理員同意啟用;引用原加密 Key,不另存副本。 + 群組只用該 Key,不使用發訊者、管理員或持有人其他備援 Key,且不受個人 AI 開關限制。 + 群組按鈕透過 deep link 進私聊選擇既有 Key,不要求手填群組 ID 或 Key ID;選取 callback 綁定持有人。 + 授權前查核群組成員資格;群組管理動作查核管理員權限,查詢失敗不變更。 + 持有人可撤銷,刪 Key 立即使引用失效;管理員可關閉或解除綁定,換綁須重新同意。 + 群組未綁定、關閉或 Key 不可用時使用內建解析;呼叫失敗清楚提示降級,私聊原有失敗行為不變。 + 維持既有群組觸發及追問流程,不送出全部群聊;費用與額度歸 Key 持有人。 - AI 啟用後,一般私聊文字、群組觸發訊息及必要上下文會送至供應商;說明不得宣稱只傳已辨識的提醒。 - 建立提醒仍須使用者確認;確認卡從草稿解析紀錄顯示「使用 … 解析」,不依當下開關推測來源。 @@ -40,5 +47,6 @@ Schema v7 只新增供應商欄位,原有 v6 Key 標記為 `gemini`,加密內容、排序及冷卻狀態保留。 Schema v8 保存 AI 編輯時間的追問狀態;schema 與版本紀錄必須在同一交易內提交。 Schema v9 新增 `ai_keys.last_error`;既有 Key 保留,舊紀錄的失敗原因預設為 `NULL`。 +Schema v10 新增群組 Key 引用、啟用及接受授權狀態;刪 Key 的 trigger 同步清除引用與啟用狀態。 Groq 使用 strict JSON Schema,沒有可讀模型名稱 metadata,回傳 `None` 並顯示共用 AI 標籤。 供應商隔離、備援與確認流程透過模擬 HTTP 回應驗證;不代表已完成真實 Groq Key 實測。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ac02c42..8c526b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Remindly 的重要變更會記錄在這裡。 ## Unreleased +- 群組共用 AI:管理員開放綁定、群組按鈕進私聊選既有 Key 授權、管理員確認啟用;僅引用指定 Key,支援撤銷、刪 Key 失效及群組內建解析降級(schema v10)。 + ## v0.5.0 - 2026-11-05 ### Added diff --git a/README.md b/README.md index b68bd3c..6025d69 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - 🔎 **自然語言建立提醒**:支援「明天下午三點提醒我倒垃圾」這類中文輸入,拆出提醒時間、提醒事項與參與者。 -- 🧠 **自帶 Key 的 AI 語意解析**:個人可設定多組 Gemini/Groq API Key,以 Gemini 3.5 Flash-Lite 或 GPT-OSS 120B 理解口語、不同日期及草稿修正;不明確時追問,仍需確認才建立。未設定或關閉 AI 時使用原本規則解析。 +- 🧠 **自帶 Key 的 AI 語意解析**:個人可設定多組 Gemini/Groq API Key,以 Gemini 3.5 Flash-Lite 或 GPT-OSS 120B 理解口語、不同日期及草稿修正;不明確時追問,仍需確認才建立。群組可經持有人授權及管理員同意共用指定 Key;未設定或關閉 AI 時使用原本規則解析。 - 🔁 **週期性提醒**:`每天 09:00`、`每週一 09:00`、`每個月 1, 18, 25 號 09:00`、`每年 12/25 08:00`、`每 15 分鐘 / 每 2 小時` 都能一句話建立,到期後自動排下一次。內建解析遇到無效語法會直接拒絕;AI 解析遇到缺漏或歧義則追問。 @@ -78,7 +78,7 @@ Telegram Update | `/cancel R-8F3K` | 取消提醒。 | | `/timezone Asia/Taipei` | 設定時區。 | | `/groupmode on\|off\|status` | 查看或切換群組自然語言模式。 | -| `/ai` | 私聊管理個人 Gemini/Groq Key、開關及備援順序。 | +| `/ai` | 私聊管理個人 Gemini/Groq Key、開關及備援順序;`/ai group` 查看群組授權與設定。 | ### 個人 AI 設定(選用) @@ -107,10 +107,10 @@ Groq 模型 metadata 沒有可讀名稱,確認卡顯示「使用 AI 解析」 | 刪除 Key | `/ai delete ` | - **費用自己掌握:** 依你的供應商帳號/專案計費;付費帳號可能直接收費,不會先跳出付費確認。同帳號/專案多組 Key 可能共用額度。 -- **資料會傳出:** Key 經 Telegram 傳至 Bot 加密保存;啟用後,一般私聊文字、群組觸發訊息及必要上下文會送至實際使用的供應商(Google 或 Groq);備援時可能依序送至兩者。 +- **資料會傳出:** Key 經 Telegram 傳至 Bot 加密保存;個人啟用後,一般私聊文字及必要上下文會送至實際使用的供應商(Google 或 Groq);私聊備援時可能依序送至兩者。群組另需授權,觸發訊息及必要上下文只送至群組指定 Key 的供應商。 - **時間與事項約束:** 可明確辨識的時間及事項區由共用本機規則定案,事項保留原文;只補時間時保留草稿日期、事項、對象與週期。已確定欄位的多餘追問會移除,未解條件則保留;無效或已過的時間會追問,不接受模型換成其他日期。其他口語歧義仍須確認。 -- **AI 暫時不能用:** 草稿會保留;稍後重試,或 `/ai off` 後重送,改用內建解析。 -- **等待時間:** 最多嘗試三組 Key,共用 18 秒等待預算;逾時不代表 Key 失效或免費額度用盡。 +- **AI 暫時不能用:** 私聊草稿會保留;稍後重試,或 `/ai off` 後重送,改用內建解析。群組則直接改用內建解析並提示,仍須確認才建立。 +- **等待時間:** 私聊最多嘗試三組 Key,共用 18 秒等待預算;群組只嘗試授權的單組 Key,沿用相同預算。逾時不代表 Key 失效或免費額度用盡。 - **失敗原因:** 供應商呼叫失敗時,依 Key 顯示憑證、權限、額度/速率、連線、逾時或服務異常; `/ai` 也會顯示最近失敗原因,重啟後保留,呼叫成功後清除。HTTP 429 只有在供應商提供 配額資訊時才區分每日額度與每分鐘/每秒速率,否則標示無法確認;不顯示上游錯誤原文。 @@ -150,6 +150,31 @@ Groq 模型 metadata 沒有可讀名稱,確認卡顯示「使用 AI 解析」 一般群組聊天預設不會被當成提醒,避免「提醒我...」這類對話誤觸 bot。 +群組 AI 預設關閉,**不會使用發訊者或管理員的個人 Key**。要共用 AI: + +1. 管理員在目標群組輸入 `/ai group allow`,Bot 會開放一次綁定,並顯示「授權我的 Key」按鈕。 +2. Key 持有人點按鈕進入 Bot 私聊,再點選自己已保存的 Key;清單以供應商與指紋區分,點選即同意授權。 + 不必查詢或輸入群組 ID、Key ID;若 Telegram 顯示「開始」,先點一下即可顯示 Key 清單。 + 沒有既有 Key 時會提示先用 `/ai` 新增,再回群組點按鈕。Bot 會查核群組成員與 Key 所有權。 +3. 管理員在群組用 `/ai group` 核對持有人,再輸入 `/ai group on` 同意啟用。 + 同一人可以完成持有人及管理員兩種角色。 + +觸發訊息及必要上下文會送至指定 Key 的供應商,**費用與額度歸 Key 持有人**。 +只授權這一組 Key,不會使用任何人的其他備援 Key;個人 `/ai on|off` 不影響群組。 +群組未綁定、關閉、授權撤銷或 Key 不可用時使用內建解析;呼叫失敗會提示降級。 +仍由發起者確認才建立,確認卡依草稿顯示實際解析來源;不會送出全部群聊。 + +| 想做什麼 | 指令與位置 | +| --- | --- | +| 查看自己的群組授權 | 私聊 `/ai group` | +| 隨時撤銷自己的授權 | 私聊 `/ai group revoke <群組 ID>` | +| 暫停/恢復群組 AI | 管理員在群組 `/ai group off` / `/ai group on` | +| 解除群組綁定 | 管理員在群組 `/ai group unlink` | +| 改綁另一組 Key | 管理員 allow → 點群組授權按鈕 → 私聊選 Key → 管理員 on;換綁後須重新同意 | + +刪除個人 Key 會立即清除所有群組對該 Key 的引用。一般成員不能管理群組 AI 或查看 Key ID; +Telegram 權限查詢失敗時不變更設定,Bot 需具備可成功查核成員及管理員的群組權限。 + 管理員可在群組輸入 `/groupmode` 查看狀態卡,卡片會寫清楚開關意義,並顯示「開啟」或「關閉」按鈕。 Telegram 的指令選單點選 `/groupmode` 時可能會直接送出,這是正常行為;送出後再用卡片按鈕切換即可。也可以直接輸入: diff --git a/src/remindly/ai/contracts.py b/src/remindly/ai/contracts.py index 21754e4..afb104c 100644 --- a/src/remindly/ai/contracts.py +++ b/src/remindly/ai/contracts.py @@ -75,8 +75,14 @@ class GeneratedResponse: class SemanticGenerator(Protocol): - def enabled(self, user_id: int) -> bool: ... + def enabled(self, user_id: int, *, chat_id: int | None = None) -> bool: ... def generate( - self, user_id: int, system: str, prompt: dict[str, object], schema: dict[str, object] + self, + user_id: int, + system: str, + prompt: dict[str, object], + schema: dict[str, object], + *, + chat_id: int | None = None, ) -> GeneratedResponse: ... diff --git a/src/remindly/ai/gateway.py b/src/remindly/ai/gateway.py index 5d8234b..a2df94d 100644 --- a/src/remindly/ai/gateway.py +++ b/src/remindly/ai/gateway.py @@ -24,7 +24,10 @@ def __init__( self.providers = dict(providers) self.default_provider = default_provider - def enabled(self, user_id: int) -> bool: + def enabled(self, user_id: int, *, chat_id: int | None = None) -> bool: + if chat_id is not None: + owner, key_id, enabled, _ = self.keys.group_settings(chat_id) + return self.keys.configured and enabled and owner is not None and key_id is not None return self.keys.enabled(user_id) def add_key(self, user_id: int, key: str, provider: str | None = None) -> str: @@ -34,11 +37,27 @@ def add_key(self, user_id: int, key: str, provider: str | None = None) -> str: return self.keys.add(user_id, key, provider=provider) def generate( - self, user_id: int, system: str, prompt: dict[str, object], schema: dict[str, object] + self, + user_id: int, + system: str, + prompt: dict[str, object], + schema: dict[str, object], + *, + chat_id: int | None = None, ) -> GeneratedResponse: + selected_key = None + if chat_id is not None: + owner, selected_key, enabled, _ = self.keys.group_settings(chat_id) + if not enabled or owner is None or selected_key is None: + raise AiUnavailable("群組 AI 未啟用。") + user_id = owner deadline = time.monotonic() + AI_TIME_BUDGET_SECONDS # shortcut: 未提供專案資訊時無法合併跨 Key 配額,最多嘗試三組避免重複轟炸。 - available = [k for k in self.keys.available(user_id) if k.provider in self.providers] + available = [ + k + for k in self.keys.available(user_id) + if k.provider in self.providers and (selected_key is None or k.id == selected_key) + ] for key in available[:3]: remaining = deadline - time.monotonic() if remaining <= 0: @@ -73,6 +92,8 @@ def generate( else None ) return GeneratedResponse(result, provider.id, provider.model, display_name) + if chat_id is not None: + raise AiUnavailable("群組 AI 暫時無法使用。") lines = ["AI 解析未成功:"] for key in self.keys.list(user_id): if key.provider not in self.providers: diff --git a/src/remindly/ai/keys.py b/src/remindly/ai/keys.py index 6c3f6ca..e327a4a 100644 --- a/src/remindly/ai/keys.py +++ b/src/remindly/ai/keys.py @@ -116,6 +116,80 @@ def delete(self, user_id: int, key_id: str) -> bool: ) return result.rowcount == 1 + def group_settings(self, chat_id: int) -> tuple[int | None, str | None, bool, bool]: + with self._repository.connect() as connection: + row = connection.execute( + "select * from group_ai_settings where chat_id = ?", (chat_id,) + ).fetchone() + if row is None: + return None, None, False, False + return ( + row["owner_user_id"], + row["key_id"], + bool(row["enabled"]), + bool(row["accepting_grants"]), + ) + + def grant_group(self, user_id: int, chat_id: int, key_id: str) -> None: + with self._repository.connect() as connection: + result = connection.execute( + """update group_ai_settings set owner_user_id = ?, key_id = ?, + enabled = 0, accepting_grants = 0 + where chat_id = ? and accepting_grants = 1 and exists + (select 1 from ai_keys where user_id = ? and id = ?)""", + (user_id, key_id, chat_id, user_id, key_id), + ) + if result.rowcount != 1: + raise ValueError("請確認 Key 屬於你,且群組管理員已用 /ai group allow 開放綁定。") + + def revoke_group(self, user_id: int, chat_id: int) -> bool: + with self._repository.connect() as connection: + result = connection.execute( + """update group_ai_settings set owner_user_id = null, key_id = null, + enabled = 0, accepting_grants = 0 where chat_id = ? and owner_user_id = ?""", + (chat_id, user_id), + ) + return result.rowcount == 1 + + def manage_group(self, chat_id: int, action: str) -> None: + if chat_id >= 0 or action not in {"allow", "on", "off", "unlink"}: + raise ValueError("群組設定不正確。") + with self._repository.connect() as connection: + connection.execute( + "insert into group_ai_settings(chat_id) values (?) on conflict do nothing", + (chat_id,), + ) + if action == "on": + result = connection.execute( + """update group_ai_settings set enabled = 1, accepting_grants = 0 + where chat_id = ? and exists + (select 1 from ai_keys where id = key_id and user_id = owner_user_id)""", + (chat_id,), + ) + if result.rowcount != 1: + raise ValueError("請先由 Key 持有人於私聊授權此群組。") + elif action == "off": + connection.execute( + """update group_ai_settings set enabled = 0, accepting_grants = 0 + where chat_id = ?""", + (chat_id,), + ) + elif action == "allow": + connection.execute( + "update group_ai_settings set accepting_grants = 1 where chat_id = ?", + (chat_id,), + ) + else: + connection.execute("delete from group_ai_settings where chat_id = ?", (chat_id,)) + + def group_grants(self, user_id: int) -> list[tuple[int, str, bool]]: + with self._repository.connect() as connection: + rows = connection.execute( + "select chat_id, key_id, enabled from group_ai_settings where owner_user_id = ?", + (user_id,), + ).fetchall() + return [(row["chat_id"], row["key_id"], bool(row["enabled"])) for row in rows] + def move(self, user_id: int, key_id: str, position: int) -> None: keys = self.list(user_id) selected = next((key for key in keys if key.id == key_id), None) diff --git a/src/remindly/ai/parser.py b/src/remindly/ai/parser.py index 24e8708..1a444c4 100644 --- a/src/remindly/ai/parser.py +++ b/src/remindly/ai/parser.py @@ -127,7 +127,7 @@ class SemanticParsing(Protocol): - def enabled(self, user_id: int) -> bool: ... + def enabled(self, user_id: int, *, chat_id: int | None = None) -> bool: ... def parse( self, @@ -138,6 +138,7 @@ def parse( *, context: ReminderDraft | None = None, purpose: str = "reminder", + chat_id: int | None = None, ) -> ParseResult: ... @@ -145,8 +146,12 @@ class SemanticParser: def __init__(self, gateway: SemanticGenerator) -> None: self._gateway = gateway - def enabled(self, user_id: int) -> bool: - return self._gateway.enabled(user_id) + def enabled(self, user_id: int, *, chat_id: int | None = None) -> bool: + return ( + self._gateway.enabled(user_id, chat_id=chat_id) + if chat_id is not None + else self._gateway.enabled(user_id) + ) def parse( self, @@ -157,6 +162,7 @@ def parse( *, context: ReminderDraft | None = None, purpose: str = "reminder", + chat_id: int | None = None, ) -> ParseResult: if len(text) > 4000: raise AiUnavailable("提醒文字太長,請縮短至 4000 字以內。") @@ -196,6 +202,7 @@ def parse( ], }, SCHEMA, + **({"chat_id": chat_id} if chat_id is not None else {}), ) try: result = validate_result(response.data, now, participants) diff --git a/src/remindly/app.py b/src/remindly/app.py index a6a16ac..0b8d812 100644 --- a/src/remindly/app.py +++ b/src/remindly/app.py @@ -33,7 +33,7 @@ BotCommand("cancel", "取消提醒"), BotCommand("timezone", "設定時區"), BotCommand("groupmode", "群組自然語言模式"), - BotCommand("ai", "個人 AI Key 與語意解析設定"), + BotCommand("ai", "個人 AI Key 與群組 AI 設定"), ] diff --git a/src/remindly/bot/ai_commands.py b/src/remindly/bot/ai_commands.py index 9b4ab18..0ffa431 100644 --- a/src/remindly/bot/ai_commands.py +++ b/src/remindly/bot/ai_commands.py @@ -1,12 +1,15 @@ from __future__ import annotations +import re from datetime import datetime from remindly.ai.contracts import PROVIDER_ERRORS from remindly.ai.gateway import AiGateway +from remindly.bot.groupmode import GROUP_CHAT_TYPES, GroupModeAuthorizer +from remindly.reminders.callback_data import CallbackData from remindly.reminders.text import command_body from remindly.telegram.client import TelegramApiError, TelegramClient -from remindly.telegram.models import TelegramMessage +from remindly.telegram.models import TelegramCallbackQuery, TelegramMessage class AiCommands: @@ -17,6 +20,10 @@ def __init__(self, client: TelegramClient, gateway: AiGateway | None, bot_userna self._bot_username = bot_username def handle(self, message: TelegramMessage) -> bool: + start = command_body(message.text, "start", self._bot_username) + if start is not None and start.startswith("aigroup_"): + self._show_group_keys(message, start.removeprefix("aigroup_")) + return True body = command_body(message.text, "ai", self._bot_username) if body is None: return False @@ -27,6 +34,9 @@ def handle(self, message: TelegramMessage) -> bool: self._client.delete_message(message.chat.id, message.id) except TelegramApiError: deletion_failed = True + if parts and parts[0] == "group": + self._handle_group(message, parts[1:]) + return True if message.chat.type != "private": self._send(message, "API Key 只能在與 Bot 的私聊設定;請勿在群組貼上 Key。") return True @@ -68,6 +78,192 @@ def handle(self, message: TelegramMessage) -> bool: self._send(message, text) return True + def _handle_group(self, message: TelegramMessage, parts: list[str]) -> None: + if message.from_user is None: + return + if self._keys is None: + self._send(message, "伺服器尚未設定 AI,目前使用內建解析。") + return + user_id = message.from_user.id + markup = None + try: + if message.chat.type == "private": + if len(parts) == 3 and parts[0] == "grant": + chat_id = group_chat_id(parts[1]) + self._grant_group(user_id, chat_id, parts[2]) + text = ( + f"已授權群組 {chat_id} 使用指定 Key,等待管理員在群組 /ai group on。" + "觸發訊息及必要上下文將送至該 Key 的供應商;費用與額度歸你。" + f"隨時撤銷:/ai group revoke {chat_id}" + ) + elif len(parts) == 2 and parts[0] == "revoke": + revoked = self._keys.revoke_group(user_id, group_chat_id(parts[1])) + text = "已撤銷群組授權。" if revoked else "找不到你的群組授權。" + else: + text = ( + "請管理員在目標群組輸入 /ai group allow," + "再點「授權我的 Key」進入私聊選擇既有 Key。\n" + "觸發訊息及必要上下文" + "會送至指定 Key 的供應商,費用與額度歸持有人。\n" + "撤銷:/ai group revoke <群組 ID>\n" + "只授權選定 Key,不使用其他備援 Key;刪 Key 立即失效。" + ) + for chat_id, key_id, enabled in self._keys.group_grants(user_id): + text += ( + f"\n群組 {chat_id} · Key {key_id} · " + f"{'啟用' if enabled else '待啟用/關閉'}" + ) + elif message.chat.type in GROUP_CHAT_TYPES: + action = parts[0] if parts else "status" + if len(parts) > 1 or action not in {"status", "allow", "on", "off", "unlink"}: + raise ValueError("用法:/ai group status|allow|on|off|unlink") + if action != "status": + allowed = GroupModeAuthorizer(self._client).can_change_group_settings( + message.chat.id, user_id + ) + if allowed is None: + raise ValueError("無法確認你的群組權限,未變更設定。") + if not allowed: + raise ValueError("只有群組管理員可以管理群組 AI。") + self._keys.manage_group(message.chat.id, action) + owner, _, enabled, accepting = self._keys.group_settings(message.chat.id) + markup = None + if accepting: + markup = { + "inline_keyboard": [ + [ + { + "text": "授權我的 Key", + "url": self._group_link(message.chat.id), + } + ] + ] + } + text = ( + f"群組 ID:{message.chat.id}\n" + f"群組 AI:{'啟用' if enabled else '關閉(內建解析)'}\n" + f"Key 持有人:{owner if owner is not None else '尚未綁定'}\n" + f"接受新授權:{'是' if accepting else '否'}\n" + "管理員先用 /ai group allow 開放一次綁定。\n" + + ( + "Key 持有人點下方「授權我的 Key」,進入私聊選擇既有 Key。\n" + if accepting + else "開放綁定後,Key 持有人即可點按鈕進私聊選 Key。\n" + ) + + "授權後,管理員在此群組 /ai group on 啟用。\n" + "關閉:/ai group off;解除:/ai group unlink。\n" + "觸發訊息及必要上下文會送至指定 Key 的供應商;費用與額度歸持有人。" + "AI 不可用時使用內建解析;仍須發起者確認才建立提醒。" + ) + else: + raise ValueError("僅支援私聊與群組。") + except ValueError as exc: + text = str(exc) + markup = None + self._client.send_message( + message.chat.id, + text, + reply_markup=markup, + ) + + def _group_link(self, chat_id: int) -> str: + if not self._bot_username: + try: + result = self._client.call("getMe", {}).get("result", {}) + except TelegramApiError: + raise ValueError("無法取得 Bot 帳號,請稍後重試 /ai group。") from None + username = result.get("username") if isinstance(result, dict) else None + if not isinstance(username, str) or not re.fullmatch(r"[A-Za-z0-9_]+", username): + raise ValueError("無法取得 Bot 帳號,請設定 BOT_USERNAME 後重試。") + self._bot_username = username + return f"https://t.me/{self._bot_username.lstrip('@')}?start=aigroup_{chat_id}" + + def _check_group_member(self, user_id: int, chat_id: int) -> None: + if self._keys is None or not self._keys.configured: + raise ValueError("伺服器尚未設定 AI_ENCRYPTION_KEY。") + try: + status = self._client.get_chat_member(chat_id, user_id) + except TelegramApiError: + raise ValueError("無法確認你的群組成員資格,未變更授權。") from None + if status not in {"creator", "administrator", "member"}: + raise ValueError("只有該群組成員能授權 Key。") + + def _grant_group(self, user_id: int, chat_id: int, key_id: str) -> None: + self._check_group_member(user_id, chat_id) + self._keys.grant_group(user_id, chat_id, key_id) + + def _show_group_keys(self, message: TelegramMessage, target: str) -> None: + try: + if message.chat.type != "private" or message.from_user is None: + raise ValueError("請從群組按「授權我的 Key」,在 Bot 私聊選擇 Key。") + chat_id = group_chat_id(target) + user_id = message.from_user.id + self._check_group_member(user_id, chat_id) + if not self._keys.group_settings(chat_id)[3]: + raise ValueError("此群組尚未開放或已完成綁定,請管理員重新 /ai group allow。") + keys = [k for k in self._keys.list(user_id) if k.provider in self._gateway.providers] + if not keys: + raise ValueError("你還沒有可選的 Key,請先在私聊用 /ai 新增,再回群組按授權按鈕。") + rows = [ + [ + { + "text": ( + f"{i}. {self._gateway.providers[key.provider].name} · {key.fingerprint}" + ), + "callback_data": CallbackData( + "ai", "g", str(chat_id), f"{user_id}_{key.id}" + ).encode(), + } + ] + for i, key in enumerate(keys, 1) + ] + self._client.send_message( + message.chat.id, + f"授權群組 {chat_id}:請選擇你的一組既有 Key。\n" + "點選即同意將群組觸發訊息及必要上下文送至此 Key 的供應商;" + "費用與額度歸你,不會使用其他備援 Key。仍須群組管理員確認啟用。", + reply_markup={"inline_keyboard": rows}, + ) + except ValueError as exc: + self._send(message, str(exc)) + + def handle_callback(self, callback: TelegramCallbackQuery) -> bool: + if not callback.data.startswith("ai:"): + return False + try: + data = CallbackData.decode(callback.data) + if data.action != "g" or not data.value or "_" not in data.value: + raise ValueError("授權按鈕不正確,請回群組重新開始。") + owner, key_id = data.value.split("_", 1) + if ( + callback.message is None + or callback.message.chat.type != "private" + or owner != str(callback.from_user.id) + ): + raise ValueError("請在你與 Bot 的私聊選擇自己的 Key。") + chat_id = group_chat_id(data.target_id) + self._grant_group(callback.from_user.id, chat_id, key_id) + except ValueError as exc: + self._client.answer_callback_query(callback.id, str(exc)) + return True + self._client.answer_callback_query(callback.id, "已授權,等待群組管理員啟用。") + text = ( + f"已授權群組 {chat_id} 使用你選擇的 Key," + "請管理員在群組 /ai group on 啟用。\n" + "觸發訊息及必要上下文將送至該 Key 的供應商,費用與額度歸你。\n" + f"隨時撤銷:/ai group revoke {chat_id}" + ) + try: + self._client.edit_message_text( + callback.message.chat.id, + callback.message.id, + text, + reply_markup={"inline_keyboard": []}, + ) + except TelegramApiError: + self._send(callback.message, text) + return True + def _panel(self, user_id: int) -> str: enabled = self._keys.enabled(user_id) lines = [f"AI 語意解析:{'開啟' if enabled else '關閉(使用基本解析)'}"] @@ -99,8 +295,9 @@ def _panel(self, user_id: int) -> str: "開關:/ai on 或 /ai off", "刪除:/ai delete ", "排序:/ai move <順位>", + "群組授權與撤銷:/ai group(與個人開關獨立)", "Key 會經 Telegram 傳給此 Bot,於伺服器加密保存。", - "啟用後,一般私聊文字、群組觸發訊息及必要上下文會送至 Key 所屬供應商。", + "個人啟用後,一般私聊文字及必要上下文會送至 Key 所屬供應商;群組需另行授權。", "費用和共用額度依供應商帳戶設定。", ] ) @@ -108,3 +305,13 @@ def _panel(self, user_id: int) -> str: def _send(self, message: TelegramMessage, text: str) -> None: self._client.send_message(message.chat.id, text) + + +def group_chat_id(value: str) -> int: + try: + chat_id = int(value) + except ValueError: + raise ValueError("群組 ID 不正確,請在目標群組用 /ai group 查看。") from None + if not -(2**63) <= chat_id < 0: + raise ValueError("群組 ID 不正確,請在目標群組用 /ai group 查看。") + return chat_id diff --git a/src/remindly/bot/command_handlers.py b/src/remindly/bot/command_handlers.py index a634c11..ca0ef03 100644 --- a/src/remindly/bot/command_handlers.py +++ b/src/remindly/bot/command_handlers.py @@ -83,7 +83,7 @@ def _help(self, message: TelegramMessage, body: str, now: datetime) -> None: "/cancel - 取消提醒", "/timezone - 設定時區", "/groupmode on|off|status - 群組自然語言模式", - "/ai - 個人 AI Key 與語意解析設定(私聊)", + "/ai - 個人 AI Key 設定(私聊);/ai group - 群組 AI 授權與設定", ] ), ) diff --git a/src/remindly/bot/response_sender.py b/src/remindly/bot/response_sender.py index 766a6fa..d10b1ba 100644 --- a/src/remindly/bot/response_sender.py +++ b/src/remindly/bot/response_sender.py @@ -180,6 +180,8 @@ def send_draft_result( if isinstance(result, RecurrenceRejected): self.send_recurrence_error(chat_id, result.error) return + if result.draft.parse_result.get("ai_fallback"): + notice = (notice or "") + "群組 AI 暫時無法使用,已改用內建解析。\n" if isinstance(result, Confirmation): message_id = self._client.send_message( chat_id, diff --git a/src/remindly/bot/router.py b/src/remindly/bot/router.py index fe7eed0..2613082 100644 --- a/src/remindly/bot/router.py +++ b/src/remindly/bot/router.py @@ -45,6 +45,10 @@ def handle_update(self, update: TelegramUpdate) -> None: """接收 Telegram update,依型別分派給 message 或 callback 流程。""" now = datetime.now(ZoneInfo(self._default_timezone)) if update.callback_query: + if self._ai_commands is not None and self._ai_commands.handle_callback( + update.callback_query + ): + return self._callback_handlers.handle(update.callback_query, now) return @@ -74,7 +78,7 @@ def _handle_message(self, message: TelegramMessage, now: datetime) -> None: actor_id = message.from_user.id starts_new_reminder = self._should_treat_as_reminder(message, text) - if self._reminder_service.ai_enabled(actor_id): + if self._reminder_service.ai_enabled(actor_id, message): self._handle_ai_message(message, text, now, starts_new_reminder) return diff --git a/src/remindly/reminders/service.py b/src/remindly/reminders/service.py index 8f48ecf..241d8a6 100644 --- a/src/remindly/reminders/service.py +++ b/src/remindly/reminders/service.py @@ -6,6 +6,7 @@ from enum import StrEnum from zoneinfo import ZoneInfo +from remindly.ai.contracts import AiUnavailable from remindly.ai.parser import SemanticParsing, followup_question from remindly.reminders.drafts import ( EditSession, @@ -24,6 +25,7 @@ from remindly.reminders.parser import ReminderParser, dedupe_participants from remindly.reminders.recurrence import next_fire from remindly.reminders.repositories import ReminderRepository +from remindly.reminders.text import looks_like_reminder_request from remindly.telegram.models import ( TelegramCallbackQuery, TelegramMessage, @@ -132,8 +134,12 @@ def __init__( self._default_timezone = default_timezone self.ai_parser = ai_parser - def ai_enabled(self, user_id: int) -> bool: - return self.ai_parser is not None and self.ai_parser.enabled(user_id) + def ai_enabled(self, user_id: int, message: TelegramMessage | None = None) -> bool: + if self.ai_parser is None: + return False + if message is not None and message.chat.type != "private": + return self.ai_parser.enabled(user_id, chat_id=message.chat.id) + return self.ai_parser.enabled(user_id) def parse_ai( self, @@ -153,14 +159,47 @@ def parse_ai( candidates = self._parser.extract_participants("提醒我 " + text, message) if context: candidates = dedupe_participants(candidates + context.participants) - result = self.ai_parser.parse( - text, - creator.id, - now.astimezone(ZoneInfo(timezone)), - candidates, - context=context, - purpose=purpose, - ) + group = message.chat.type != "private" + try: + return self.ai_parser.parse( + text, + creator.id, + now.astimezone(ZoneInfo(timezone)), + candidates, + context=context, + purpose=purpose, + **({"chat_id": message.chat.id} if group else {}), + ) + except AiUnavailable: + if not group: + raise + result = self._parser.parse(text, message, now=now.astimezone(ZoneInfo(timezone))) + if looks_like_reminder_request(text): + context = None + if context: + draft = replace( + context, + missing_fields=(list(context.missing_fields) or ["time"]) + if purpose == "edit_time" + else list(context.missing_fields), + parse_result={ + k: v + for k, v in context.parse_result.items() + if k not in {"provider", "model", "model_display_name", "grain", "ai_fallback"} + }, + ) + self._merge_answer(draft, text, message, now) + result = replace( + result, + title=draft.title, + remind_at=draft.remind_at, + participants=tuple(draft.participants), + missing_fields=tuple(draft.missing_fields), + recurrence=draft.recurrence, + raw={**draft.parse_result, "intent": "update"}, + ) + result.raw["ai_fallback"] = True + result.raw["timezone"] = timezone return result def record_message_context(self, message: TelegramMessage, now: datetime) -> None: @@ -214,7 +253,7 @@ def preview_parse( `begin_create` 也可以透過 `parse_result` kwarg 重用此結果避免重複解析。""" creator = require_user(message) timezone = self._repository.get_user_timezone(creator.id, self._default_timezone) - if self.ai_enabled(creator.id): + if self.ai_enabled(creator.id, message): return self.parse_ai(text, message, now) return self._parser.parse(text, message, now=now.astimezone(ZoneInfo(timezone))) @@ -232,7 +271,7 @@ def begin_create( parse_result = self.preview_parse(text, message, now) if parse_result.recurrence_error is not None: return RecurrenceRejected(error=parse_result.recurrence_error) - if parse_result.raw.get("grain") == "ai": + if parse_result.raw.get("grain") == "ai" or parse_result.raw.get("ai_fallback"): timezone = str(parse_result.raw["timezone"]) draft = ReminderDraft( id=new_id("draft"), @@ -521,7 +560,7 @@ def continue_edit( return self._edit_result(updated, "已更新提醒內容。") if session.field == "time": - if self.ai_enabled(actor.id): + if self.ai_enabled(actor.id, message): context = ReminderDraft( id=reminder.id, chat_id=reminder.chat_id, @@ -558,12 +597,28 @@ def continue_edit( "condition_question": parsed.raw.get("condition_question"), } self._edit_store.save(session, now) - return EditPrompt(reminder, followup_question(parsed.missing_fields)) + return EditPrompt( + reminder, + ( + "群組 AI 暫時無法使用,已改用內建解析。\n" + if parsed.raw.get("ai_fallback") + else "" + ) + + followup_question(parsed.missing_fields), + ) updated = self._repository.update_remind_at( message.chat.id, session.short_id, actor.id, parsed.remind_at, now ) self._edit_store.delete(message.chat.id, actor.id) - return self._edit_result(updated, "已更新下次提醒時間。") + return self._edit_result( + updated, + ( + "群組 AI 暫時無法使用,已改用內建解析。\n" + if parsed.raw.get("ai_fallback") + else "" + ) + + "已更新下次提醒時間。", + ) parsed_time = self._parser.parse_time_only( message.text, now.astimezone(ZoneInfo(reminder.timezone)), diff --git a/src/remindly/storage/migrations.py b/src/remindly/storage/migrations.py index 061ed79..398eeaf 100644 --- a/src/remindly/storage/migrations.py +++ b/src/remindly/storage/migrations.py @@ -174,6 +174,24 @@ class Migration: name="ai_key_last_error", sql="alter table ai_keys add column last_error text;", ), + Migration( + version=10, + name="group_ai_keys", + sql=""" + create table group_ai_settings ( + chat_id integer primary key check(chat_id < 0), + owner_user_id integer, + key_id text references ai_keys(id), + enabled integer not null default 0, + accepting_grants integer not null default 0 + ); + create trigger delete_group_ai_key after delete on ai_keys + begin + update group_ai_settings set owner_user_id = null, key_id = null, enabled = 0 + where key_id = old.id; + end; + """, + ), ) diff --git a/tests/test_ai_flow.py b/tests/test_ai_flow.py index 4f0c53b..858b419 100644 --- a/tests/test_ai_flow.py +++ b/tests/test_ai_flow.py @@ -302,6 +302,344 @@ def test_not_reminder_question_is_shown_without_replacing_draft(self) -> None: self.assertIn("未來日期與時間", self.telegram.messages[-1].text) self.assertEqual(original.id, self.service.peek_draft(CHAT.id, USER.id, NOW).id) + def bind_group(self, *, owner=USER, secret=SECRET, provider="gemini"): + key_id = self.keys.add(owner.id, provider=provider, key=secret) + self.telegram.chat_member_statuses[USER.id] = "administrator" + self.send("/ai group allow", chat=GROUP_CHAT) + self.send(f"/ai group grant {GROUP_CHAT.id} {key_id}", user=owner) + self.assertFalse(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + self.send("/ai group on", chat=GROUP_CHAT) + self.assertTrue(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + return key_id + + def click_ai(self, data, *, user=USER, chat=CHAT): + message = self.telegram.messages[-1] + callback = TelegramCallbackQuery( + id="ai-click", + from_user=user, + data=data, + message=TelegramMessage( + id=message.id, chat=chat, from_user=USER, text=message.text, entities=() + ), + ) + self.router.handle_update(TelegramUpdate(id=9000, callback_query=callback)) + + def open_group_picker(self): + self.telegram.chat_member_statuses[USER.id] = "administrator" + self.send("/ai group allow", chat=GROUP_CHAT) + button = self.telegram.messages[-1].reply_markup["inline_keyboard"][0][0] + self.assertEqual("授權我的 Key", button["text"]) + self.assertEqual(f"https://t.me/ReminderBot?start=aigroup_{GROUP_CHAT.id}", button["url"]) + self.send("/start " + button["url"].split("?start=", 1)[1]) + return self.telegram.messages[-1] + + def test_group_button_private_key_picker_and_confirmation(self): + first = self.keys.add(USER.id, provider="gemini", key=SECRET) + second = self.keys.add(USER.id, provider="gemini", key=SECOND) + self.keys.add(8, provider="gemini", key="other-owner-key") + picker = self.open_group_picker() + rows = picker.reply_markup["inline_keyboard"] + self.assertEqual(2, len(rows)) + self.assertIn("費用與額度歸你", picker.text) + self.assertIn("點選即同意", picker.text) + for row in rows: + self.assertLessEqual(len(row[0]["callback_data"].encode()), 64) + self.assertNotIn(first, row[0]["text"]) + self.assertNotIn(second, row[0]["text"]) + self.click_ai(rows[1][0]["callback_data"]) + self.assertEqual((USER.id, second, False, False), self.keys.group_settings(GROUP_CHAT.id)) + self.assertEqual({"inline_keyboard": []}, self.telegram.messages[-1].reply_markup) + self.send("/ai group on", chat=GROUP_CHAT) + self.assertTrue(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + self.assertNotIn(SECRET, repr(self.telegram.messages)) + self.assertNotIn(SECOND, repr(self.telegram.messages)) + + def test_group_picker_rejects_another_user_and_group_callbacks(self): + key_id = self.keys.add(USER.id, provider="gemini", key=SECRET) + picker = self.open_group_picker() + data = picker.reply_markup["inline_keyboard"][0][0]["callback_data"] + self.click_ai(data, user=TelegramUser(id=8, first_name="Other")) + self.assertIn("自己的 Key", self.telegram.callback_answers[-1]) + self.click_ai(data, chat=GROUP_CHAT) + self.assertIsNone(self.keys.group_settings(GROUP_CHAT.id)[0]) + self.assertIn("私聊", self.telegram.callback_answers[-1]) + other_key = self.keys.add(8, provider="gemini", key=SECOND) + self.click_ai(data.replace(key_id, other_key)) + self.assertIsNone(self.keys.group_settings(GROUP_CHAT.id)[0]) + + def test_group_picker_rechecks_membership_key_and_grant_window(self): + key_id = self.keys.add(USER.id, provider="gemini", key=SECRET) + picker = self.open_group_picker() + data = picker.reply_markup["inline_keyboard"][0][0]["callback_data"] + self.telegram.fail_chat_member_lookup = True + self.click_ai(data) + self.assertIn("無法確認", self.telegram.callback_answers[-1]) + self.telegram.fail_chat_member_lookup = False + self.telegram.chat_member_statuses[USER.id] = "left" + self.click_ai(data) + self.assertIn("只有該群組成員", self.telegram.callback_answers[-1]) + self.telegram.chat_member_statuses[USER.id] = "administrator" + self.send("/ai group off", chat=GROUP_CHAT) + self.click_ai(data) + self.assertIsNone(self.keys.group_settings(GROUP_CHAT.id)[0]) + self.send("/ai group allow", chat=GROUP_CHAT) + self.keys.delete(USER.id, key_id) + self.click_ai(data) + self.assertIsNone(self.keys.group_settings(GROUP_CHAT.id)[0]) + + def test_group_picker_empty_invalid_link_and_no_group_key_disclosure(self): + picker = self.open_group_picker() + self.assertIsNone(picker.reply_markup) + self.assertIn("還沒有可選的 Key", picker.text) + for target in ("abc", "100", str(-(2**80)), "-999"): + self.send(f"/start aigroup_{target}") + self.assertIsNone(self.telegram.messages[-1].reply_markup) + self.keys.add(USER.id, provider="gemini", key=SECRET) + self.send(f"/start aigroup_{GROUP_CHAT.id}", chat=GROUP_CHAT) + self.assertIsNone(self.telegram.messages[-1].reply_markup) + self.assertIn("私聊", self.telegram.messages[-1].text) + self.send("/ai group off", chat=GROUP_CHAT) + self.send(f"/start aigroup_{GROUP_CHAT.id}") + self.assertIsNone(self.telegram.messages[-1].reply_markup) + + def test_group_picker_consumed_button_cannot_regrant_and_edit_failure_is_safe(self): + self.keys.add(USER.id, provider="gemini", key=SECRET) + picker = self.open_group_picker() + data = picker.reply_markup["inline_keyboard"][0][0]["callback_data"] + self.telegram.fail_edit_message = True + self.click_ai(data) + self.assertIn("已授權群組", self.telegram.messages[-1].text) + self.click_ai(data) + self.assertIn("開放綁定", self.telegram.callback_answers[-1]) + self.assertFalse(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + for malformed in ("ai:g", "ai:g:-100:missing", "ai:unknown:-100:7_key"): + self.click_ai(malformed) + self.assertIsNotNone(self.telegram.callback_answers[-1]) + + def test_group_link_resolves_missing_bot_username_and_handles_lookup_failure(self): + self.router._ai_commands._bot_username = None + self.telegram.chat_member_statuses[USER.id] = "administrator" + with patch.object( + self.telegram, "call", create=True, return_value={"result": {"username": "ResolvedBot"}} + ) as call: + self.send("/ai group allow", chat=GROUP_CHAT) + self.assertIn( + "t.me/ResolvedBot?start=", + self.telegram.messages[-1].reply_markup["inline_keyboard"][0][0]["url"], + ) + self.send("/ai group", chat=GROUP_CHAT) + call.assert_called_once_with("getMe", {}) + self.router._ai_commands._bot_username = None + with patch.object( + self.telegram, "call", create=True, side_effect=TelegramApiError("getMe", "failure") + ): + self.send("/ai group", chat=GROUP_CHAT) + self.assertIsNone(self.telegram.messages[-1].reply_markup) + self.assertIn("無法取得 Bot 帳號", self.telegram.messages[-1].text) + + def test_group_consent_permissions_and_private_key_management(self): + key_id = self.keys.add(USER.id, provider="gemini", key=SECRET) + self.send(f"/ai group grant {GROUP_CHAT.id} {key_id}") + self.assertIsNone(self.keys.group_settings(GROUP_CHAT.id)[0]) + self.send("/ai group allow", chat=GROUP_CHAT) + self.assertIn("只有群組管理員", self.telegram.messages[-1].text) + self.telegram.chat_member_statuses[USER.id] = "administrator" + self.send("/ai group on", chat=GROUP_CHAT) + self.assertIn("請先由 Key 持有人", self.telegram.messages[-1].text) + self.send("/ai group allow", chat=GROUP_CHAT) + self.assertEqual( + "授權我的 Key", self.telegram.messages[-1].reply_markup["inline_keyboard"][0][0]["text"] + ) + self.assertNotIn("grant <群組 ID>", self.telegram.messages[-1].text) + self.send(f"/ai group grant {GROUP_CHAT.id} {key_id}", chat=GROUP_CHAT) + self.assertIsNone(self.keys.group_settings(GROUP_CHAT.id)[0]) + self.send(f"/ai group grant {GROUP_CHAT.id} {key_id}") + self.assertFalse(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + other = TelegramUser(id=8, first_name="Other") + for action in ("on", "off", "allow", "unlink"): + self.send(f"/ai group {action}", chat=GROUP_CHAT, user=other) + self.assertIn("只有群組管理員", self.telegram.messages[-1].text) + self.send("/ai group on", chat=GROUP_CHAT) + self.send("/ai group", chat=GROUP_CHAT, user=other) + self.assertNotIn(key_id, self.telegram.messages[-1].text) + self.assertNotIn(SECRET, repr(self.telegram.messages)) + + def test_group_lookup_failures_and_wrong_owner_fail_closed(self): + key_id = self.keys.add(USER.id, provider="gemini", key=SECRET) + self.telegram.chat_member_statuses[USER.id] = "administrator" + self.send("/ai group allow", chat=GROUP_CHAT) + self.telegram.fail_chat_member_lookup = True + self.send(f"/ai group grant {GROUP_CHAT.id} {key_id}") + self.assertIn("無法確認", self.telegram.messages[-1].text) + self.send("/ai group on", chat=GROUP_CHAT) + self.assertIn("無法確認", self.telegram.messages[-1].text) + self.telegram.fail_chat_member_lookup = False + other = TelegramUser(id=8, first_name="Other") + self.send(f"/ai group grant {GROUP_CHAT.id} {key_id}", user=other) + self.assertIsNone(self.keys.group_settings(GROUP_CHAT.id)[0]) + self.telegram.chat_member_statuses[USER.id] = "left" + self.send(f"/ai group grant {GROUP_CHAT.id} {key_id}") + self.assertIn("只有該群組成員", self.telegram.messages[-1].text) + for invalid in ("abc", "100", str(-(2**80))): + self.send(f"/ai group grant {invalid} {key_id}") + self.assertIn("群組 ID 不正確", self.telegram.messages[-1].text) + self.send(f"/ai group revoke {invalid}") + self.assertIn("群組 ID 不正確", self.telegram.messages[-1].text) + self.assertIsNone(self.keys.group_settings(GROUP_CHAT.id)[0]) + + def test_group_uses_only_shared_key_independent_of_personal_switch(self): + owner = TelegramUser(id=9, first_name="Owner") + key_id = self.bind_group(owner=owner, secret=SECOND) + self.keys.set_enabled(owner.id, False) + self.keys.add(USER.id, provider="gemini", key=SECRET) + self.keys.set_enabled(USER.id, False) + self.gemini.responses.append(parsed()) + self.send("明天三點提醒我喝水", chat=GROUP_CHAT) + self.assertEqual([], self.gemini.calls) + self.send("@ReminderBot 明天下午三點提醒我喝水", chat=GROUP_CHAT) + self.assertEqual(SECOND, self.gemini.calls[0][0]) + self.assertTrue(self.gemini.calls[0][1]["participants"][0]["is_self"]) + draft = self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW) + self.assertEqual("gemini", draft.parse_result["provider"]) + self.assertIn("使用 Test gemini 解析", self.telegram.messages[-1].text) + self.assertEqual([], self.repo.list_pending(GROUP_CHAT.id)) + self.assertIsNone(self.service.confirm(draft.id, owner.id, NOW)) + self.assertIsNotNone(self.service.confirm(draft.id, USER.id, NOW)) + self.assertEqual(1, len(self.repo.list_pending(GROUP_CHAT.id))) + self.assertEqual(key_id, self.keys.group_settings(GROUP_CHAT.id)[1]) + + def test_group_failure_cools_only_selected_key_and_falls_back(self): + key_id = self.bind_group() + self.keys.add(USER.id, provider="gemini", key=SECOND) + self.gemini.responses.append(ProviderError("rate")) + self.send("/remind 明天下午三點提醒我喝水", chat=GROUP_CHAT) + self.assertEqual([SECRET], [key for key, _ in self.gemini.calls]) + self.assertEqual("cooldown", self.keys.list(USER.id)[0].status) + self.assertEqual("unchecked", self.keys.list(USER.id)[1].status) + draft = self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW) + self.assertTrue(draft.is_complete) + self.assertTrue(draft.parse_result["ai_fallback"]) + self.assertNotIn("provider", draft.parse_result) + self.assertIn("已改用內建解析", self.telegram.messages[-1].text) + self.assertNotIn(key_id, self.telegram.messages[-1].text) + self.assertNotIn(SECRET, repr(self.telegram.messages)) + self.send("@ReminderBot 明天下午四點提醒我喝水", chat=GROUP_CHAT) + self.assertEqual(1, len(self.gemini.calls)) + draft = self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW) + self.assertIsNotNone(self.service.confirm(draft.id, USER.id, NOW)) + self.gemini.responses.append(parsed()) + self.send("明天下午三點叫我喝水") + self.assertEqual(SECOND, self.gemini.calls[-1][0]) + + def test_group_revoke_delete_off_unlink_and_rebinding(self): + key_id = self.bind_group() + other = TelegramUser(id=8, first_name="Other") + self.send(f"/ai group revoke {GROUP_CHAT.id}", user=other) + self.assertTrue(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + self.send("/ai group off", chat=GROUP_CHAT) + self.assertFalse(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + self.send("/ai group on", chat=GROUP_CHAT) + self.send(f"/ai group revoke {GROUP_CHAT.id}") + self.assertFalse(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + self.send("/ai group on", chat=GROUP_CHAT) + self.assertFalse(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + self.send("/ai group allow", chat=GROUP_CHAT) + self.send(f"/ai group grant {GROUP_CHAT.id} {key_id}") + self.send("/ai group on", chat=GROUP_CHAT) + second = self.keys.add(USER.id, provider="gemini", key=SECOND) + self.send("/ai group allow", chat=GROUP_CHAT) + self.send(f"/ai group grant {GROUP_CHAT.id} {second}") + self.assertFalse(self.gateway.enabled(USER.id, chat_id=GROUP_CHAT.id)) + self.send("/ai group on", chat=GROUP_CHAT) + self.send(f"/ai delete {second}") + self.assertEqual((None, None, False, False), self.keys.group_settings(GROUP_CHAT.id)) + self.send("@ReminderBot 明天下午三點提醒我喝水", chat=GROUP_CHAT) + self.assertEqual([], self.gemini.calls) + self.send("/ai group unlink", chat=GROUP_CHAT) + self.assertEqual([], self.keys.group_grants(USER.id)) + + def test_group_unknown_provider_and_broken_encryption_fall_back(self): + key_id = self.bind_group(provider="unknown") + self.send("/remind 明天下午三點提醒我喝水", chat=GROUP_CHAT) + self.assertIn("已改用內建解析", self.telegram.messages[-1].text) + self.assertEqual([], self.gemini.calls) + self.keys.delete(USER.id, key_id) + self.bind_group() + self.keys._cipher = Fernet(Fernet.generate_key()) + self.send("/remind 明天下午三點提醒我喝水", chat=GROUP_CHAT) + self.assertIn("已改用內建解析", self.telegram.messages[-1].text) + self.assertEqual([], self.gemini.calls) + + def test_group_fallback_keeps_draft_context_and_actual_parser_label(self): + self.bind_group() + self.gemini.responses.extend([parsed(title=None), ProviderError("temporary")]) + self.send("@ReminderBot 明天下午三點提醒我", chat=GROUP_CHAT) + old = self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW) + self.send("喝水", chat=GROUP_CHAT) + draft = self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW) + self.assertEqual(old.remind_at, draft.remind_at) + self.assertEqual("喝水", draft.title) + self.assertTrue(draft.is_complete) + self.assertNotIn("provider", draft.parse_result) + self.assertNotIn("使用 Test gemini", self.telegram.messages[-1].text) + self.assertIsNotNone(self.service.confirm(draft.id, USER.id, NOW)) + + def test_group_groq_isolation_and_natural_language_trigger(self): + groq = ScriptedProvider([parsed()], provider_id="groq") + self.gateway.providers["groq"] = groq + self.bind_group(provider="groq") + self.keys.add(USER.id, provider="gemini", key=SECOND) + self.send("/groupmode on", chat=GROUP_CHAT) + self.send("你提醒我想到一件事", chat=GROUP_CHAT) + self.assertEqual([], groq.calls) + self.send("明天下午三點提醒我喝水", chat=GROUP_CHAT) + self.assertEqual(SECRET, groq.calls[0][0]) + self.assertEqual([], self.gemini.calls) + self.assertEqual( + "groq", self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW).parse_result["provider"] + ) + groq.responses.append(ProviderError("permission")) + self.send("/remind 明天下午四點提醒我喝水", chat=GROUP_CHAT) + self.assertEqual("invalid", self.keys.list(USER.id)[0].status) + self.assertEqual([], self.gemini.calls) + self.assertIn("已改用內建解析", self.telegram.messages[-1].text) + + def test_group_fallback_explicit_reminder_replaces_complete_draft(self): + self.bind_group() + self.gemini.responses.extend([parsed(title="交報告"), ProviderError("temporary")]) + self.send("@ReminderBot 提醒我明天下午三點交報告", chat=GROUP_CHAT) + old = self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW) + self.send("@ReminderBot 提醒我後天下午四點繳費", chat=GROUP_CHAT) + draft = self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW) + self.assertEqual("繳費", draft.title) + self.assertEqual((24, 16), (draft.remind_at.day, draft.remind_at.hour)) + self.assertIsNone(self.service.confirm(old.id, USER.id, NOW)) + + def test_group_fallback_new_request_does_not_merge_old_draft(self): + self.bind_group() + self.gemini.responses.extend([parsed(title=None), ProviderError("response")]) + self.send("@ReminderBot 明天下午三點提醒我", chat=GROUP_CHAT) + self.send("@ReminderBot 另外提醒我後天下午四點喝水", chat=GROUP_CHAT) + draft = self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW) + self.assertEqual("喝水", draft.title) + self.assertEqual((24, 16), (draft.remind_at.day, draft.remind_at.hour)) + self.assertTrue(draft.parse_result["ai_fallback"]) + + def test_group_fallback_time_edit_preserves_series(self): + self.bind_group() + self.gemini.responses.append(parsed(date=None, time=None, recurrence=rule())) + self.send("/remind 每天九點提醒我吃藥", chat=GROUP_CHAT) + draft = self.service.peek_draft(GROUP_CHAT.id, USER.id, NOW) + self.service.confirm(draft.id, USER.id, NOW) + reminder = self.repo.list_pending(GROUP_CHAT.id)[0] + self.service.begin_edit(GROUP_CHAT.id, reminder.short_id, USER.id, "time", NOW) + self.gemini.responses.append(ProviderError("timeout")) + self.send("明天下午三點", chat=GROUP_CHAT) + updated = self.repo.list_pending(GROUP_CHAT.id)[0] + self.assertEqual(15, updated.remind_at.hour) + self.assertEqual(reminder.recurrence, updated.recurrence) + self.assertIn("已改用內建解析", self.telegram.messages[-1].text) + def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.repo = ReminderRepository(Path(self.tmp.name) / "test.db") @@ -563,7 +901,7 @@ def test_explicit_command_uses_ai_once_and_respects_user_timezone(self) -> None: self.assertEqual("Asia/Tokyo", self.gemini.calls[0][1]["timezone"]) self.assertEqual("Asia/Tokyo", self.service.peek_draft(CHAT.id, USER.id, NOW).timezone) - def test_group_trigger_and_keys_are_scoped_to_actor(self) -> None: + def test_unbound_group_never_uses_actor_personal_keys(self) -> None: self.keys.add(USER.id, provider="gemini", key=SECRET) self.send("明晚八點叫我拿包裹", chat=GROUP_CHAT) self.assertEqual([], self.gemini.calls) @@ -572,7 +910,7 @@ def test_group_trigger_and_keys_are_scoped_to_actor(self) -> None: self.assertEqual([], self.gemini.calls) self.gemini.responses.append(parsed()) self.send("@ReminderBot 明天三點叫我喝水", chat=GROUP_CHAT) - self.assertEqual(SECRET, self.gemini.calls[0][0]) + self.assertEqual([], self.gemini.calls) def test_time_edit_passes_context_and_preserves_series(self) -> None: self.keys.add(USER.id, provider="gemini", key=SECRET) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 5fe4d85..1c98b29 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -30,7 +30,7 @@ def test_migrate_records_latest_version_and_is_idempotent(self) -> None: tables = table_names(connection) self.assertEqual(expected_migration_rows(), rows) - self.assertEqual(9, latest_schema_version()) + self.assertEqual(10, latest_schema_version()) self.assertIn("reminders", tables) self.assertIn("reminder_drafts", tables) self.assertIn("edit_sessions", tables) @@ -38,6 +38,28 @@ def test_migrate_records_latest_version_and_is_idempotent(self) -> None: self.assertIn("ai_keys", tables) self.assertIn("ai_settings", tables) + def test_group_migration_ledger_failure_rolls_back_table_and_trigger(self): + with connect(Path(":memory:")) as connection: + ensure_migration_table(connection) + for migration in MIGRATIONS[:9]: + apply_migration(connection, migration) + connection.execute( + """create trigger reject_group_version before insert on schema_migrations + when new.version = 10 + begin select raise(abort, 'ledger unavailable'); end""" + ) + with self.assertRaises(sqlite3.IntegrityError): + migrate_sqlite_database(connection) + self.assertNotIn("group_ai_settings", table_names(connection)) + self.assertIsNone( + connection.execute( + "select name from sqlite_master where name = 'delete_group_ai_key'" + ).fetchone() + ) + connection.execute("drop trigger reject_group_version") + migrate_sqlite_database(connection) + self.assertIn("group_ai_settings", table_names(connection)) + def test_migrate_adopts_unversioned_existing_schema(self) -> None: with tempfile.TemporaryDirectory() as directory: database_path = Path(directory) / "test.db" @@ -116,6 +138,7 @@ def expected_migration_rows() -> list[tuple[int, str]]: (7, "ai_key_providers"), (8, "ai_edit_context"), (9, "ai_key_last_error"), + (10, "group_ai_keys"), ]