Skip to content

Commit 8575210

Browse files
committed
feat(im): add pending bind approvals and harden bind flow
1 parent c305477 commit 8575210

File tree

15 files changed

+601
-28
lines changed

15 files changed

+601
-28
lines changed

docs/guide/im-bridge/telegram.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,11 @@ New chats must be authorized before they can use the bot:
106106
2. Start a chat with the bot
107107
3. Send `/subscribe` — the bot replies with a one-time binding key:
108108
```
109-
/bind abc123xyz
109+
abc123xyz
110110
```
111111
4. **Authorize the chat** (choose one):
112-
- **Web chat (recommended):** Copy the `/bind <key>` line and paste it in the CCCC Web chat — the foreman will complete the binding automatically
112+
- **Web (recommended):** Open **CCCC Web → Settings → IM Bridge**, find it in **Pending Requests**, and click **Approve** (or paste the key in **Bind**)
113+
- **Foreman-assisted:** If foreman is online, send the key to foreman and ask foreman to bind it
113114
- **CLI:** Run `cccc im bind --key <key>` on the server
114115
5. Once authorized, the chat can immediately send and receive messages — no further commands needed
115116

@@ -174,7 +175,6 @@ Attach files to your message. They're downloaded and stored in CCCC's blob stora
174175
| Command | Description |
175176
|---------|-------------|
176177
| `/subscribe` | Start authorization flow — generates a one-time binding key |
177-
| `/bind <key>` | Authorize this chat using a binding key (paste in CCCC Web chat) |
178178
| `/unsubscribe` | Stop receiving messages |
179179
| `/send <message>` | Send to foreman (default) |
180180
| `/send @<actor> <message>` | Send to a specific agent |
@@ -213,7 +213,7 @@ Your token is invalid. Get a new one from BotFather:
213213

214214
### Messages not delivered
215215

216-
1. Ensure the chat is authorized (run `/subscribe` → bind the key via Web or CLI)
216+
1. Ensure the chat is authorized (run `/subscribe` → bind the key via Web, foreman-assisted bind, or CLI)
217217
2. Check that the CCCC daemon is running
218218
3. Verify the bridge status in Web UI or via `cccc im status`
219219

@@ -229,6 +229,6 @@ Telegram has rate limits. If you're sending many messages:
229229
- Consider enabling 2FA on your Telegram account
230230
- Review who has access to chats where the bot is subscribed
231231
- The bot can see all messages in groups where it's added
232-
- **New chats require key-based authorization** before they can interact with the bot — send `/subscribe` to generate a one-time key, then confirm it from the server side (Web chat or CLI); once bound, the chat is fully authorized
232+
- **New chats require key-based authorization** before they can interact with the bot — send `/subscribe` to generate a one-time key, then confirm it from the server side (Web Settings > IM Bridge, foreman-assisted bind, or CLI); once bound, the chat is fully authorized
233233
- Binding keys expire after **10 minutes**; generate a new one with `/subscribe` if it lapses
234-
- The bind operation must be performed via CCCC Web chat or `cccc im bind --key` on the server — Telegram users cannot self-authorize
234+
- The bind operation must be performed via CCCC Web Settings (IM Bridge), foreman-assisted bind, or `cccc im bind --key` on the server — Telegram users cannot self-authorize

docs/standards/CCCC_DAEMON_IPC_V1.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,6 +1433,53 @@ Errors:
14331433
- `missing_group_id``group_id` is empty.
14341434
- `group_not_found` – group does not exist.
14351435
1436+
#### `im_list_pending`
1437+
1438+
List pending one-time bind requests for a group (expired keys are omitted).
1439+
1440+
Args:
1441+
```ts
1442+
{ group_id: string }
1443+
```
1444+
1445+
Result:
1446+
```ts
1447+
{
1448+
pending: Array<{
1449+
key: string
1450+
chat_id: string
1451+
thread_id: number
1452+
platform: string
1453+
created_at: number
1454+
expires_at: number
1455+
expires_in_seconds: number
1456+
}>
1457+
}
1458+
```
1459+
1460+
Errors:
1461+
- `missing_group_id``group_id` is empty.
1462+
- `group_not_found` – group does not exist.
1463+
1464+
#### `im_reject_pending`
1465+
1466+
Reject a pending one-time bind key.
1467+
1468+
Args:
1469+
```ts
1470+
{ group_id: string; key: string }
1471+
```
1472+
1473+
Result:
1474+
```ts
1475+
{ rejected: boolean } // idempotent: false when key is already absent/expired
1476+
```
1477+
1478+
Errors:
1479+
- `missing_key``key` is empty.
1480+
- `missing_group_id``group_id` is empty.
1481+
- `group_not_found` – group does not exist.
1482+
14361483
#### `im_revoke_chat`
14371484
14381485
Revoke authorization for an IM chat.

src/cccc/daemon/ops/chat_ops.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,13 @@ def handle_send(
171171
src_event_id = ""
172172
to_raw = args.get("to")
173173
to_tokens: list[str] = []
174-
to_explicitly_set = isinstance(to_raw, list) and len(to_raw) > 0
175174
if isinstance(to_raw, list):
176175
to_tokens = [str(x).strip() for x in to_raw if isinstance(x, str) and str(x).strip()]
176+
elif isinstance(to_raw, str):
177+
token = to_raw.strip()
178+
if token:
179+
to_tokens = [token]
180+
to_explicitly_set = len(to_tokens) > 0
177181

178182
if priority not in ("normal", "attention"):
179183
return _error("invalid_priority", "priority must be 'normal' or 'attention'")

src/cccc/daemon/ops/im_ops.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ def handle_im_list_authorized(args: Dict[str, Any]) -> DaemonResponse:
6767
return DaemonResponse(ok=True, result={"authorized": km.list_authorized()})
6868

6969

70+
def handle_im_list_pending(args: Dict[str, Any]) -> DaemonResponse:
71+
"""List pending bind requests for a group."""
72+
err, km, _group = _load_km(args)
73+
if err is not None:
74+
return err
75+
return DaemonResponse(ok=True, result={"pending": km.list_pending()})
76+
77+
78+
def handle_im_reject_pending(args: Dict[str, Any]) -> DaemonResponse:
79+
"""Reject a pending bind request key."""
80+
key = str(args.get("key") or "").strip()
81+
if not key:
82+
return _error("missing_key", "key is required")
83+
err, km, _group = _load_km(args)
84+
if err is not None:
85+
return err
86+
rejected = km.reject_pending(key)
87+
return DaemonResponse(ok=True, result={"rejected": bool(rejected)})
88+
89+
7090
def handle_im_revoke_chat(args: Dict[str, Any]) -> DaemonResponse:
7191
"""Revoke authorization for a chat."""
7292
chat_id = str(args.get("chat_id") or "").strip()
@@ -98,6 +118,10 @@ def try_handle_im_op(op: str, args: Dict[str, Any]) -> Optional[DaemonResponse]:
98118
return handle_im_bind_chat(args)
99119
if op == "im_list_authorized":
100120
return handle_im_list_authorized(args)
121+
if op == "im_list_pending":
122+
return handle_im_list_pending(args)
123+
if op == "im_reject_pending":
124+
return handle_im_reject_pending(args)
101125
if op == "im_revoke_chat":
102126
return handle_im_revoke_chat(args)
103127
return None

src/cccc/ports/im/auth.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,48 @@ def revoke(self, chat_id: str, thread_id: int) -> bool:
138138
def list_authorized(self) -> List[Dict[str, Any]]:
139139
return list(self._authorized.values())
140140

141+
def list_pending(self) -> List[Dict[str, Any]]:
142+
"""List pending bind requests (expired keys are purged first)."""
143+
removed = self._purge_expired()
144+
if removed:
145+
self._save_pending()
146+
now = time.time()
147+
items: List[Dict[str, Any]] = []
148+
for key, entry in self._pending.items():
149+
created_at = float(entry.get("created_at", 0) or 0)
150+
expires_at = created_at + KEY_TTL_SECONDS
151+
items.append({
152+
"key": str(key),
153+
"chat_id": str(entry.get("chat_id") or ""),
154+
"thread_id": int(entry.get("thread_id") or 0),
155+
"platform": str(entry.get("platform") or ""),
156+
"created_at": created_at,
157+
"expires_at": expires_at,
158+
"expires_in_seconds": max(0, int(expires_at - now)),
159+
})
160+
items.sort(key=lambda item: float(item.get("created_at") or 0), reverse=True)
161+
return items
162+
163+
def reject_pending(self, key: str) -> bool:
164+
"""Reject a pending bind key. Returns True when removed."""
165+
token = str(key or "").strip()
166+
if not token:
167+
return False
168+
removed = self._purge_expired()
169+
existed = token in self._pending
170+
if existed:
171+
self._pending.pop(token, None)
172+
self._save_pending()
173+
return True
174+
if removed:
175+
self._save_pending()
176+
return False
177+
141178
# ------------------------------------------------------------------
142179
# Internal
143180
# ------------------------------------------------------------------
144181

145-
def _purge_expired(self) -> None:
182+
def _purge_expired(self) -> bool:
146183
"""Remove expired pending keys (best-effort, no save)."""
147184
now = time.time()
148185
expired = [
@@ -151,3 +188,4 @@ def _purge_expired(self) -> None:
151188
]
152189
for k in expired:
153190
del self._pending[k]
191+
return bool(expired)

src/cccc/ports/im/bridge.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,9 @@ def _should_forward(self, event: Dict[str, Any], verbose: bool) -> bool:
641641

642642
def _handle_subscribe(self, chat_id: str, chat_title: str, thread_id: int = 0) -> None:
643643
"""Handle /subscribe command."""
644+
# Reload auth state on-demand as subscribe semantics depend on current
645+
# authorization truth (bind/revoke can happen in daemon/web concurrently).
646+
self.key_manager._load()
644647
platform = str(getattr(self.adapter, "platform", "") or "").strip().lower()
645648

646649
# If the chat is not yet authorized, generate a binding key.
@@ -649,8 +652,11 @@ def _handle_subscribe(self, chat_id: str, chat_title: str, thread_id: int = 0) -
649652
self.adapter.send_message(
650653
chat_id,
651654
f"🔑 Authorization required.\n\n"
652-
f"Copy and paste in CCCC Web chat:\n"
653-
f"`/bind {key}`\n\n"
655+
f"Open CCCC Web → Settings → IM Bridge, then approve this request in Pending Requests "
656+
f"(or paste this key in Bind):\n"
657+
f"`{key}`\n\n"
658+
f"If foreman is online, send this message to foreman:\n"
659+
f"`Please help bind my IM key: {key}`\n\n"
654660
f"Or run in terminal:\n"
655661
f"`cccc im bind --key {key}`\n\n"
656662
f"Key expires in 10 minutes.",
@@ -659,6 +665,7 @@ def _handle_subscribe(self, chat_id: str, chat_title: str, thread_id: int = 0) -
659665
self._log(f"[subscribe] Pending auth key generated for chat={chat_id} thread={thread_id}")
660666
return
661667

668+
was_subscribed = self.subscribers.is_subscribed(chat_id, thread_id=thread_id)
662669
sub = self.subscribers.subscribe(chat_id, chat_title, thread_id=thread_id, platform=platform)
663670
verbose_str = "on" if sub.verbose else "off"
664671
platform = str(getattr(self.adapter, "platform", "") or "").strip().lower() or "telegram"
@@ -668,9 +675,14 @@ def _handle_subscribe(self, chat_id: str, chat_title: str, thread_id: int = 0) -
668675
tip = "Channel tip: @mention the bot to route plain text. Use /send for explicit recipients."
669676
else:
670677
tip = "Tip: plain text routes to foreman by default; use /send for explicit recipients."
678+
target_label = self.group.doc.get("title", self.group.group_id)
679+
if was_subscribed:
680+
headline = f"✅ Already authorized for this chat ({target_label})"
681+
else:
682+
headline = f"✅ Subscribed to {target_label}"
671683
self.adapter.send_message(
672684
chat_id,
673-
f"✅ Subscribed to {self.group.doc.get('title', self.group.group_id)}\n"
685+
f"{headline}\n"
674686
f"Verbose mode: {verbose_str}\n"
675687
f"{tip}\n"
676688
f"Use /help for commands.",

src/cccc/ports/mcp/toolspecs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,8 +1182,8 @@
11821182
"name": "cccc_im_bind",
11831183
"description": (
11841184
"Bind a Telegram (or other IM) chat using a one-time key.\n\n"
1185-
"Typical flow: user runs /subscribe in Telegram, gets a key, pastes `/bind <key>` in CCCC Web chat, "
1186-
"and the foreman calls this tool to complete the binding.\n\n"
1185+
"Typical flow: user runs /subscribe in IM chat, gets a key, opens CCCC Web > Settings > IM Bridge > Bind, "
1186+
"and the foreman can also call this tool to complete the binding.\n\n"
11871187
"The key expires after 10 minutes."
11881188
),
11891189
"inputSchema": {

src/cccc/ports/web/app.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,16 @@ class IMActionRequest(BaseModel):
309309
group_id: str
310310

311311

312+
class IMBindRequest(BaseModel):
313+
group_id: str
314+
key: str
315+
316+
317+
class IMPendingRejectRequest(BaseModel):
318+
group_id: str
319+
key: str
320+
321+
312322
def _is_env_var_name(value: str) -> bool:
313323
# Shell-friendly env var name (portable).
314324
return bool(re.fullmatch(r"[A-Z_][A-Z0-9_]*", (value or "").strip()))
@@ -2879,12 +2889,18 @@ async def im_stop(req: IMActionRequest) -> Dict[str, Any]:
28792889

28802890
return {"ok": True, "result": {"group_id": req.group_id, "stopped": stopped}}
28812891

2882-
# ----- IM auth (bind / list / revoke) -----
2892+
# ----- IM auth (bind / pending / list / revoke) -----
28832893

28842894
@app.post("/api/im/bind")
2885-
async def im_bind(group_id: str, key: str) -> Dict[str, Any]:
2886-
"""Bind a pending authorization key to authorize a Telegram chat."""
2887-
resp = await _daemon({"op": "im_bind_chat", "args": {"group_id": group_id, "key": key}})
2895+
async def im_bind(req: Optional[IMBindRequest] = None, group_id: str = "", key: str = "") -> Dict[str, Any]:
2896+
"""Bind a pending authorization key to authorize an IM chat."""
2897+
gid = str((req.group_id if isinstance(req, IMBindRequest) else group_id) or "").strip()
2898+
k = str((req.key if isinstance(req, IMBindRequest) else key) or "").strip()
2899+
if not gid:
2900+
raise HTTPException(status_code=400, detail={"code": "missing_group_id", "message": "group_id is required"})
2901+
if not k:
2902+
raise HTTPException(status_code=400, detail={"code": "missing_key", "message": "key is required"})
2903+
resp = await _daemon({"op": "im_bind_chat", "args": {"group_id": gid, "key": k}})
28882904
if not resp.get("ok"):
28892905
err = resp.get("error") if isinstance(resp.get("error"), dict) else {}
28902906
code = str(err.get("code") or "bind_failed")
@@ -2901,6 +2917,36 @@ async def im_list_authorized(group_id: str) -> Dict[str, Any]:
29012917
raise HTTPException(status_code=400, detail=err)
29022918
return resp
29032919

2920+
@app.get("/api/im/pending")
2921+
async def im_list_pending(group_id: str) -> Dict[str, Any]:
2922+
"""List pending bind requests for a group."""
2923+
resp = await _daemon({"op": "im_list_pending", "args": {"group_id": group_id}})
2924+
if not resp.get("ok"):
2925+
err = resp.get("error") if isinstance(resp.get("error"), dict) else {}
2926+
raise HTTPException(status_code=400, detail=err)
2927+
return resp
2928+
2929+
@app.post("/api/im/pending/reject")
2930+
async def im_reject_pending(
2931+
req: Optional[IMPendingRejectRequest] = None,
2932+
group_id: str = "",
2933+
key: str = "",
2934+
) -> Dict[str, Any]:
2935+
"""Reject a pending bind request key."""
2936+
gid = str((req.group_id if isinstance(req, IMPendingRejectRequest) else group_id) or "").strip()
2937+
k = str((req.key if isinstance(req, IMPendingRejectRequest) else key) or "").strip()
2938+
if not gid:
2939+
raise HTTPException(status_code=400, detail={"code": "missing_group_id", "message": "group_id is required"})
2940+
if not k:
2941+
raise HTTPException(status_code=400, detail={"code": "missing_key", "message": "key is required"})
2942+
resp = await _daemon({"op": "im_reject_pending", "args": {"group_id": gid, "key": k}})
2943+
if not resp.get("ok"):
2944+
err = resp.get("error") if isinstance(resp.get("error"), dict) else {}
2945+
code = str(err.get("code") or "reject_failed")
2946+
msg = str(err.get("message") or "reject failed")
2947+
raise HTTPException(status_code=400, detail={"code": code, "message": msg})
2948+
return resp
2949+
29042950
@app.post("/api/im/revoke")
29052951
async def im_revoke(group_id: str, chat_id: str, thread_id: int = 0) -> Dict[str, Any]:
29062952
"""Revoke authorization for a chat."""

tests/test_chat_ops.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,22 @@ def test_send_to_array_is_routed_correctly(self) -> None:
168168
finally:
169169
cleanup()
170170

171+
def test_send_to_string_direct_payload_is_routed_correctly(self) -> None:
172+
"""T067 scenario: daemon send op tolerates string `to` payload."""
173+
group_id, cleanup = self._setup_group_with_actors()
174+
try:
175+
resp, _ = self._call("send", {
176+
"group_id": group_id,
177+
"by": "user",
178+
"to": "peer1",
179+
"text": "hello peer1",
180+
})
181+
self.assertTrue(resp.ok, getattr(resp, "error", None))
182+
event = (resp.result or {}).get("event", {})
183+
self.assertEqual(event.get("data", {}).get("to", []), ["peer1"])
184+
finally:
185+
cleanup()
186+
171187
def test_send_empty_to_uses_default(self) -> None:
172188
"""T067 scenario 3: empty `to` falls back to group default."""
173189
group_id, cleanup = self._setup_group_with_actors()
@@ -184,6 +200,22 @@ def test_send_empty_to_uses_default(self) -> None:
184200
finally:
185201
cleanup()
186202

203+
def test_send_to_malformed_list_entries_falls_back_to_default(self) -> None:
204+
"""T067 edge: malformed list entries should not become broadcast."""
205+
group_id, cleanup = self._setup_group_with_actors()
206+
try:
207+
resp, _ = self._call("send", {
208+
"group_id": group_id,
209+
"by": "user",
210+
"to": [None, ""],
211+
"text": "malformed to payload",
212+
})
213+
self.assertTrue(resp.ok, getattr(resp, "error", None))
214+
event = (resp.result or {}).get("event", {})
215+
self.assertEqual(event.get("data", {}).get("to", []), ["@foreman"])
216+
finally:
217+
cleanup()
218+
187219
def test_send_multiple_recipients(self) -> None:
188220
"""T067 scenario 4: multiple explicit recipients."""
189221
group_id, cleanup = self._setup_group_with_actors()

0 commit comments

Comments
 (0)