Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 啟用後,一般私聊文字、群組觸發訊息及必要上下文會送至供應商;說明不得宣稱只傳已辨識的提醒。
- 建立提醒仍須使用者確認;確認卡從草稿解析紀錄顯示「使用 … 解析」,不依當下開關推測來源。

Expand Down Expand Up @@ -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 實測。
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Remindly 的重要變更會記錄在這裡。

## Unreleased

- 群組共用 AI:管理員開放綁定、群組按鈕進私聊選既有 Key 授權、管理員確認啟用;僅引用指定 Key,支援撤銷、刪 Key 失效及群組內建解析降級(schema v10)。

## v0.5.0 - 2026-11-05

### Added
Expand Down
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 解析遇到缺漏或歧義則追問。

Expand Down Expand Up @@ -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 設定(選用)

Expand Down Expand Up @@ -107,10 +107,10 @@ Groq 模型 metadata 沒有可讀名稱,確認卡顯示「使用 AI 解析」
| 刪除 Key | `/ai delete <Key ID>` |

- **費用自己掌握:** 依你的供應商帳號/專案計費;付費帳號可能直接收費,不會先跳出付費確認。同帳號/專案多組 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 只有在供應商提供
配額資訊時才區分每日額度與每分鐘/每秒速率,否則標示無法確認;不顯示上游錯誤原文。
Expand Down Expand Up @@ -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` 時可能會直接送出,這是正常行為;送出後再用卡片按鈕切換即可。也可以直接輸入:
Expand Down
10 changes: 8 additions & 2 deletions src/remindly/ai/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
27 changes: 24 additions & 3 deletions src/remindly/ai/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
74 changes: 74 additions & 0 deletions src/remindly/ai/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 10 additions & 3 deletions src/remindly/ai/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -138,15 +138,20 @@ def parse(
*,
context: ReminderDraft | None = None,
purpose: str = "reminder",
chat_id: int | None = None,
) -> ParseResult: ...


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,
Expand All @@ -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 字以內。")
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/remindly/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
BotCommand("cancel", "取消提醒"),
BotCommand("timezone", "設定時區"),
BotCommand("groupmode", "群組自然語言模式"),
BotCommand("ai", "個人 AI Key 與語意解析設定"),
BotCommand("ai", "個人 AI Key 與群組 AI 設定"),
]


Expand Down
Loading
Loading