cccc-pair 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. cccc_pair-0.2.5/.cccc/adapters/telegram_bridge.py +1339 -0
  2. cccc_pair-0.2.5/.cccc/delivery.py +282 -0
  3. cccc_pair-0.2.5/.cccc/evidence_runner.py +127 -0
  4. cccc_pair-0.2.5/.cccc/mailbox.py +129 -0
  5. cccc_pair-0.2.5/.cccc/mock_agent.py +67 -0
  6. cccc_pair-0.2.5/.cccc/orchestrator_tmux.py +2260 -0
  7. cccc_pair-0.2.5/.cccc/panel_status.py +169 -0
  8. cccc_pair-0.2.5/.cccc/prompt_weaver.py +98 -0
  9. cccc_pair-0.2.5/.cccc/settings/cli_profiles.yaml +92 -0
  10. cccc_pair-0.2.5/.cccc/settings/policies.yaml +63 -0
  11. cccc_pair-0.2.5/.cccc/settings/roles.yaml +7 -0
  12. cccc_pair-0.2.5/.cccc/settings/telegram.yaml +63 -0
  13. cccc_pair-0.2.5/LICENSE +201 -0
  14. cccc_pair-0.2.5/MANIFEST.in +12 -0
  15. cccc_pair-0.2.5/PKG-INFO +479 -0
  16. cccc_pair-0.2.5/README.md +249 -0
  17. cccc_pair-0.2.5/cccc.py +643 -0
  18. cccc_pair-0.2.5/cccc_pair.egg-info/PKG-INFO +479 -0
  19. cccc_pair-0.2.5/cccc_pair.egg-info/SOURCES.txt +35 -0
  20. cccc_pair-0.2.5/cccc_pair.egg-info/dependency_links.txt +1 -0
  21. cccc_pair-0.2.5/cccc_pair.egg-info/entry_points.txt +2 -0
  22. cccc_pair-0.2.5/cccc_pair.egg-info/requires.txt +1 -0
  23. cccc_pair-0.2.5/cccc_pair.egg-info/top_level.txt +2 -0
  24. cccc_pair-0.2.5/cccc_scaffold/scaffold/adapters/telegram_bridge.py +1339 -0
  25. cccc_pair-0.2.5/cccc_scaffold/scaffold/delivery.py +282 -0
  26. cccc_pair-0.2.5/cccc_scaffold/scaffold/evidence_runner.py +127 -0
  27. cccc_pair-0.2.5/cccc_scaffold/scaffold/mailbox.py +129 -0
  28. cccc_pair-0.2.5/cccc_scaffold/scaffold/mock_agent.py +67 -0
  29. cccc_pair-0.2.5/cccc_scaffold/scaffold/orchestrator_tmux.py +2260 -0
  30. cccc_pair-0.2.5/cccc_scaffold/scaffold/panel_status.py +169 -0
  31. cccc_pair-0.2.5/cccc_scaffold/scaffold/prompt_weaver.py +98 -0
  32. cccc_pair-0.2.5/cccc_scaffold/scaffold/settings/cli_profiles.yaml +92 -0
  33. cccc_pair-0.2.5/cccc_scaffold/scaffold/settings/policies.yaml +63 -0
  34. cccc_pair-0.2.5/cccc_scaffold/scaffold/settings/roles.yaml +7 -0
  35. cccc_pair-0.2.5/cccc_scaffold/scaffold/settings/telegram.yaml +63 -0
  36. cccc_pair-0.2.5/pyproject.toml +68 -0
  37. cccc_pair-0.2.5/setup.cfg +4 -0
@@ -0,0 +1,1339 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Telegram Bridge (MVP skeleton)
5
+ - Dry-run friendly: default to file-based mock (no network), then gate real network by token/allowlist.
6
+ - Inbound: messages -> .cccc/mailbox/<peer>/inbox.md (with optional a:/b:/both: prefix routing), append [MID].
7
+ - Outbound: tail .cccc/mailbox/peer*/to_user.md changes, debounce and send concise summaries to chat(s).
8
+ """
9
+ from __future__ import annotations
10
+ from pathlib import Path
11
+ from typing import Dict, Any, List, Tuple
12
+ import os, sys, time, json, re, threading
13
+ import urllib.request, urllib.parse
14
+ try:
15
+ import fcntl # POSIX lock for inbox sequencing
16
+ except Exception:
17
+ fcntl = None # type: ignore
18
+
19
+ try:
20
+ import yaml # type: ignore
21
+ except Exception:
22
+ yaml = None
23
+
24
+ ROOT = Path.cwd()
25
+ HOME = ROOT/".cccc"
26
+
27
+ def read_yaml(p: Path) -> Dict[str, Any]:
28
+ if not p.exists():
29
+ return {}
30
+ try:
31
+ import yaml as _y
32
+ return _y.safe_load(p.read_text(encoding='utf-8')) or {}
33
+ except Exception:
34
+ # Try JSON fallback
35
+ try:
36
+ return json.loads(p.read_text(encoding='utf-8'))
37
+ except Exception:
38
+ return {}
39
+
40
+ def _now():
41
+ import time
42
+ return time.strftime('%Y-%m-%d %H:%M:%S')
43
+
44
+ def _acquire_singleton_lock(name: str = "telegram-bridge"):
45
+ """Prevent multiple bridge instances from running concurrently (avoids duplicate replies).
46
+ Returns an open file handle holding an exclusive lock for process lifetime.
47
+ """
48
+ lf_path = HOME/"state"/f"{name}.lock"
49
+ lf_path.parent.mkdir(parents=True, exist_ok=True)
50
+ f = open(lf_path, 'w')
51
+ try:
52
+ if fcntl is not None:
53
+ fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
54
+ # record pid for diagnostics
55
+ f.write(str(os.getpid()))
56
+ f.flush()
57
+ except Exception:
58
+ # Another instance holds the lock
59
+ try:
60
+ print("[telegram_bridge] Another instance is already running. Exiting.")
61
+ _append_log(HOME/"state"/"bridge-telegram.log", "[warn] duplicate instance detected; exiting")
62
+ except Exception:
63
+ pass
64
+ sys.exit(0)
65
+ return f
66
+
67
+ def _write_text(p: Path, s: str):
68
+ p.parent.mkdir(parents=True, exist_ok=True)
69
+ p.write_text(s, encoding='utf-8')
70
+
71
+ def _append_log(p: Path, line: str):
72
+ p.parent.mkdir(parents=True, exist_ok=True)
73
+ with p.open('a', encoding='utf-8') as f:
74
+ f.write(f"{_now()} {line}\n")
75
+
76
+ def _mid() -> str:
77
+ import uuid, time
78
+ return f"tg-{int(time.time())}-{uuid.uuid4().hex[:6]}"
79
+
80
+ def _route_from_text(text: str, default_route: str):
81
+ t = text.strip()
82
+ # Support plain prefixes: a:/b:/both:
83
+ m = re.match(r"^(a:|b:|both:)\s*", t, re.I)
84
+ if m:
85
+ tag = m.group(1).lower()
86
+ t = t[m.end():]
87
+ if tag == 'a:':
88
+ return ['peerA'], t
89
+ if tag == 'b:':
90
+ return ['peerB'], t
91
+ return ['peerA','peerB'], t
92
+ # Support slash commands (group privacy mode): /a …, /b …, /both …, with optional @BotName
93
+ m2 = re.match(r"^/(a|b|both)(?:@\S+)?\s+", t, re.I)
94
+ if m2:
95
+ cmd = m2.group(1).lower()
96
+ t = t[m2.end():]
97
+ if cmd == 'a':
98
+ return ['peerA'], t
99
+ if cmd == 'b':
100
+ return ['peerB'], t
101
+ return ['peerA','peerB'], t
102
+ # Support mention form: @BotName a: … or @BotName /a …
103
+ m3 = re.match(r"^@\S+\s+(a:|b:|both:)\s*", t, re.I)
104
+ if m3:
105
+ tag = m3.group(1).lower()
106
+ t = t[m3.end():]
107
+ if tag == 'a:':
108
+ return ['peerA'], t
109
+ if tag == 'b:':
110
+ return ['peerB'], t
111
+ return ['peerA','peerB'], t
112
+ m4 = re.match(r"^@\S+\s+/(a|b|both)(?:@\S+)?\s+", t, re.I)
113
+ if m4:
114
+ cmd = m4.group(1).lower()
115
+ t = t[m4.end():]
116
+ if cmd == 'a':
117
+ return ['peerA'], t
118
+ if cmd == 'b':
119
+ return ['peerB'], t
120
+ return ['peerA','peerB'], t
121
+ if default_route == 'a':
122
+ return ['peerA'], t
123
+ if default_route == 'b':
124
+ return ['peerB'], t
125
+ return ['peerA','peerB'], t
126
+
127
+ def _wrap_with_mid(payload: str, mid: str) -> str:
128
+ """Insert [MID: …] after the first recognized opening tag if present;
129
+ otherwise prefix at the top. Keeps wrappers as the first line for peers.
130
+ Recognized tags: FROM_USER, FROM_PeerA, FROM_PeerB, TO_PEER, TO_USER, FROM_SYSTEM
131
+ """
132
+ marker = f"[MID: {mid}]"
133
+ try:
134
+ m = re.search(r"<(\s*(FROM_USER|FROM_PeerA|FROM_PeerB|TO_PEER|TO_USER|FROM_SYSTEM)\s*)>", payload, re.I)
135
+ if m:
136
+ start, end = m.span()
137
+ head = payload[:end]
138
+ tail = payload[end:]
139
+ # Ensure single newline after the tag
140
+ if not head.endswith("\n"):
141
+ head = head + "\n"
142
+ return head + marker + "\n" + tail.lstrip("\n")
143
+ else:
144
+ return marker + "\n" + payload
145
+ except Exception:
146
+ return marker + "\n" + payload
147
+
148
+ TAG_RE = re.compile(r"<\s*(FROM_USER|FROM_PeerA|FROM_PeerB|TO_PEER|TO_USER|FROM_SYSTEM)\s*>", re.I)
149
+ def _wrap_user_if_needed(body: str) -> str:
150
+ """Ensure inbound payload is inside <FROM_USER> … when no known tags are present."""
151
+ if TAG_RE.search(body or ''):
152
+ return body
153
+ b = (body or '').strip()
154
+ return f"<FROM_USER>\n{b}\n</FROM_USER>\n" if b else b
155
+
156
+ def _ensure_dirs(home: Path, peer: str) -> Tuple[Path, Path, Path]:
157
+ base = home/"mailbox"/peer
158
+ inbox_dir = base/"inbox"
159
+ proc_dir = base/"processed"
160
+ state = home/"state"
161
+ inbox_dir.mkdir(parents=True, exist_ok=True)
162
+ proc_dir.mkdir(parents=True, exist_ok=True)
163
+ state.mkdir(parents=True, exist_ok=True)
164
+ return inbox_dir, proc_dir, state
165
+
166
+ def _next_seq(inbox: Path, processed: Path, state: Path, peer: str) -> str:
167
+ lock_path = state/f"inbox-seq-{peer}.lock"
168
+ counter_path = state/f"inbox-seq-{peer}.txt"
169
+ def compute_from_fs() -> int:
170
+ mx = 0
171
+ for d in (inbox, processed):
172
+ try:
173
+ for f in d.iterdir():
174
+ n = f.name
175
+ if len(n) >= 6 and n[:6].isdigit():
176
+ mx = max(mx, int(n[:6]))
177
+ except Exception:
178
+ pass
179
+ return mx + 1
180
+ def compute() -> int:
181
+ try:
182
+ return int(counter_path.read_text().strip()) + 1
183
+ except Exception:
184
+ return compute_from_fs()
185
+ if fcntl is not None:
186
+ with open(lock_path, 'w') as lf:
187
+ try:
188
+ fcntl.flock(lf, fcntl.LOCK_EX)
189
+ except Exception:
190
+ pass
191
+ val = compute()
192
+ try:
193
+ with open(counter_path, 'w') as cf:
194
+ cf.write(str(val))
195
+ except Exception:
196
+ pass
197
+ try:
198
+ fcntl.flock(lf, fcntl.LOCK_UN)
199
+ except Exception:
200
+ pass
201
+ return f"{val:06d}"
202
+ # Fallback without fcntl
203
+ val = compute()
204
+ try:
205
+ counter_path.write_text(str(val))
206
+ except Exception:
207
+ pass
208
+ return f"{val:06d}"
209
+
210
+ def _deliver_inbound(home: Path, routes: List[str], payload: str, mid: str):
211
+ """Write numbered inbox files per peer to integrate with orchestrator NUDGE.
212
+ Also write inbox.md as a last-resort for bridge mode users.
213
+ """
214
+ for peer in routes:
215
+ inbox_dir, proc_dir, state = _ensure_dirs(home, peer)
216
+ seq = _next_seq(inbox_dir, proc_dir, state, peer)
217
+ fname = f"{seq}.{mid}.txt"
218
+ _write_text(inbox_dir/fname, payload)
219
+ # Best-effort: also mirror to inbox.md for adapter users
220
+ _write_text((home/"mailbox"/peer/"inbox.md"), payload)
221
+
222
+ def _append_ledger(entry: Dict[str, Any]):
223
+ try:
224
+ entry = {"ts": _now(), **entry}
225
+ lp = HOME/"state"/"ledger.jsonl"
226
+ lp.parent.mkdir(parents=True, exist_ok=True)
227
+ with lp.open('a', encoding='utf-8') as f:
228
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
229
+ except Exception:
230
+ pass
231
+
232
+ def _runtime_path() -> Path:
233
+ return HOME/"state"/"telegram-runtime.json"
234
+
235
+ def load_runtime() -> Dict[str, Any]:
236
+ p = _runtime_path()
237
+ try:
238
+ if p.exists():
239
+ return json.loads(p.read_text(encoding='utf-8'))
240
+ except Exception:
241
+ pass
242
+ return {}
243
+
244
+ def save_runtime(obj: Dict[str, Any]):
245
+ p = _runtime_path()
246
+ try:
247
+ p.parent.mkdir(parents=True, exist_ok=True)
248
+ p.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding='utf-8')
249
+ except Exception:
250
+ pass
251
+
252
+ def _summarize(text: str, max_chars: int, max_lines: int = 8) -> str:
253
+ """Summarize while preserving line breaks for readability.
254
+ - Normalize newlines, trim trailing spaces
255
+ - Collapse consecutive blank lines
256
+ - Keep at most max_lines; then cap by max_chars
257
+ """
258
+ if not text:
259
+ return ""
260
+ t = text.replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ")
261
+ lines = [ln.rstrip() for ln in t.split("\n")]
262
+ # strip leading/trailing empty lines
263
+ while lines and not lines[0].strip():
264
+ lines.pop(0)
265
+ while lines and not lines[-1].strip():
266
+ lines.pop()
267
+ # collapse multiple blank lines
268
+ kept = []
269
+ empty = 0
270
+ for ln in lines:
271
+ if not ln.strip():
272
+ empty += 1
273
+ if empty <= 1:
274
+ kept.append("")
275
+ else:
276
+ empty = 0
277
+ kept.append(ln)
278
+ # limit lines
279
+ kept = kept[:max_lines]
280
+ out = "\n".join(kept).strip()
281
+ if len(out) > max_chars:
282
+ out = out[: max(0, max_chars - 1) ] + "…"
283
+ return out
284
+
285
+ def _subs_path() -> Path:
286
+ return HOME/"state"/"telegram-subs.json"
287
+
288
+ def load_subs() -> List[int]:
289
+ p = _subs_path()
290
+ try:
291
+ if p.exists():
292
+ arr = json.loads(p.read_text(encoding='utf-8'))
293
+ out = []
294
+ for x in arr:
295
+ try:
296
+ out.append(int(x))
297
+ except Exception:
298
+ pass
299
+ return out
300
+ except Exception:
301
+ pass
302
+ return []
303
+
304
+ def save_subs(items: List[int]):
305
+ p = _subs_path()
306
+ try:
307
+ p.parent.mkdir(parents=True, exist_ok=True)
308
+ p.write_text(json.dumps(sorted(set(int(x) for x in items))), encoding='utf-8')
309
+ except Exception:
310
+ pass
311
+
312
+ def dry_run_loop(cfg: Dict[str, Any]):
313
+ _acquire_singleton_lock("telegram-bridge-dryrun")
314
+ mock = cfg.get('mock') or {}
315
+ inbox_dir = Path(mock.get('inbox_dir') or HOME/"work"/"telegram_inbox")
316
+ outlog = Path(mock.get('outbox_log') or HOME/"state"/"bridge-telegram.log")
317
+ inbox_dir.mkdir(parents=True, exist_ok=True)
318
+ outlog.parent.mkdir(parents=True, exist_ok=True)
319
+ seen = set()
320
+ default_route = str(cfg.get('default_route') or 'both')
321
+ max_chars = int(cfg.get('max_msg_chars') or 900)
322
+ max_lines = int(cfg.get('max_msg_lines') or 8)
323
+ _append_log(outlog, "[dry-run] bridge started")
324
+
325
+ def watch_outputs():
326
+ to_user_paths = [HOME/"mailbox"/"peerA"/"to_user.md", HOME/"mailbox"/"peerB"/"to_user.md"]
327
+ last = {str(p): '' for p in to_user_paths}
328
+ while True:
329
+ for p in to_user_paths:
330
+ try:
331
+ txt = p.read_text(encoding='utf-8').strip()
332
+ except Exception:
333
+ txt = ''
334
+ key = str(p)
335
+ if txt and txt != last[key]:
336
+ last[key] = txt
337
+ preview = _summarize(txt, max_chars, max_lines)
338
+ _append_log(outlog, f"[outbound] {p.name} {len(txt)} chars | {preview}")
339
+ time.sleep(1.0)
340
+
341
+ th = threading.Thread(target=watch_outputs, daemon=True)
342
+ th.start()
343
+
344
+ while True:
345
+ for f in sorted(inbox_dir.glob('*.txt')):
346
+ if f in seen:
347
+ continue
348
+ try:
349
+ text = f.read_text(encoding='utf-8')
350
+ except Exception:
351
+ text = ''
352
+ seen.add(f)
353
+ routes, body = _route_from_text(text, default_route)
354
+ mid = _mid()
355
+ body2 = _wrap_user_if_needed(body)
356
+ payload = _wrap_with_mid(body2, mid)
357
+ _deliver_inbound(HOME, routes, payload, mid)
358
+ _append_log(outlog, f"[inbound] routes={routes} mid={mid} size={len(body)} from={f.name}")
359
+ time.sleep(0.8)
360
+
361
+ def main():
362
+ cfg = read_yaml(HOME/"settings"/"telegram.yaml")
363
+ dry = bool(cfg.get('dry_run', True))
364
+ if dry:
365
+ dry_run_loop(cfg)
366
+ return
367
+ # Real network path: gate by token and allowlist; long-poll getUpdates; send concise summaries
368
+ _acquire_singleton_lock("telegram-bridge")
369
+ token_env = str(cfg.get('token_env') or 'TELEGRAM_BOT_TOKEN')
370
+ token = os.environ.get(token_env, '')
371
+ def _coerce_allowlist(val) -> set:
372
+ def to_int(x):
373
+ try:
374
+ return int(str(x).strip())
375
+ except Exception:
376
+ return None
377
+ if isinstance(val, (list, tuple, set)):
378
+ out = set()
379
+ for x in val:
380
+ v = to_int(x)
381
+ if v is not None:
382
+ out.add(v)
383
+ return out
384
+ if isinstance(val, str):
385
+ s = val.strip().strip('"\'')
386
+ if not s:
387
+ return set()
388
+ # Try JSON-style list first
389
+ if s.startswith('[') and s.endswith(']'):
390
+ try:
391
+ arr = json.loads(s)
392
+ return _coerce_allowlist(arr)
393
+ except Exception:
394
+ pass
395
+ # Fallback: split by comma/whitespace and brackets
396
+ s2 = s.strip('[]')
397
+ parts = re.split(r"[\s,]+", s2)
398
+ out = set()
399
+ for p in parts:
400
+ v = to_int(p)
401
+ if v is not None:
402
+ out.add(v)
403
+ return out
404
+ return set()
405
+
406
+ allow_raw = cfg.get('allow_chats') or []
407
+ allow_cfg = _coerce_allowlist(allow_raw)
408
+ subs = set(load_subs())
409
+ allow = set(allow_cfg) | subs
410
+ policy = str(cfg.get('autoregister') or 'off').lower()
411
+ max_auto = int(cfg.get('max_auto_subs') or 3)
412
+ discover = bool(cfg.get('discover_allowlist', False))
413
+ if not token or (not allow and not discover and policy != 'open'):
414
+ print("[telegram_bridge] Missing token or allowlist; enable dry_run, set discover_allowlist, or configure settings.")
415
+ sys.exit(1)
416
+
417
+ def tg_api(method: str, params: Dict[str, Any], *, timeout: int = 35) -> Dict[str, Any]:
418
+ base = f"https://api.telegram.org/bot{token}/{method}"
419
+ data = urllib.parse.urlencode(params).encode('utf-8')
420
+ req = urllib.request.Request(base, data=data, method='POST')
421
+ try:
422
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
423
+ body = resp.read().decode('utf-8', errors='replace')
424
+ return json.loads(body)
425
+ except Exception as e:
426
+ _append_log(HOME/"state"/"bridge-telegram.log", f"[error] api {method}: {e}")
427
+ return {"ok": False, "error": str(e)}
428
+
429
+ def tg_poll(offset: int) -> Tuple[int, List[Dict[str, Any]]]:
430
+ # Use POST for consistency
431
+ res = tg_api('getUpdates', {
432
+ 'offset': offset,
433
+ 'timeout': 25,
434
+ 'allowed_updates': json.dumps(["message", "edited_message", "callback_query"]) # type: ignore
435
+ }, timeout=35)
436
+ updates = []
437
+ new_offset = offset
438
+ if res.get('ok') and isinstance(res.get('result'), list):
439
+ for u in res['result']:
440
+ try:
441
+ uid = int(u.get('update_id'))
442
+ new_offset = max(new_offset, uid + 1)
443
+ updates.append(u)
444
+ except Exception:
445
+ pass
446
+ return new_offset, updates
447
+
448
+ def redact(s: str) -> str:
449
+ pats = cfg.get('redact_patterns') or []
450
+ out = s
451
+ for p in pats:
452
+ try:
453
+ out = re.sub(p, '[REDACTED]', out)
454
+ except Exception:
455
+ continue
456
+ return out
457
+
458
+ def is_cmd(s: str, name: str) -> bool:
459
+ return re.match(rf"^/{name}(?:@\S+)?(?:\s|$)", s.strip(), re.I) is not None
460
+
461
+ outlog = HOME/"state"/"bridge-telegram.log"
462
+ _append_log(outlog, "[net] bridge started")
463
+ # Outbound watcher (send summaries when to_user changes; debounced per peer)
464
+ debounce = int(cfg.get('debounce_seconds') or 30)
465
+ max_chars = int(cfg.get('max_msg_chars') or 900)
466
+ max_lines = int(cfg.get('max_msg_lines') or 8)
467
+ peer_debounce = int(cfg.get('peer_debounce_seconds') or debounce)
468
+ peer_max_chars = int(cfg.get('peer_message_max_chars') or 600)
469
+ peer_max_lines = int(cfg.get('peer_message_max_lines') or 6)
470
+ runtime = load_runtime()
471
+ show_peers_default = bool(cfg.get('show_peer_messages', True))
472
+ show_peers = bool(runtime.get('show_peer_messages', show_peers_default))
473
+
474
+ # Routing policy
475
+ routing = cfg.get('routing') or {}
476
+ require_explicit = bool(routing.get('require_explicit', True))
477
+ allow_prefix = bool(routing.get('allow_prefix', True))
478
+ require_mention = bool(routing.get('require_mention', False))
479
+ dm_conf = cfg.get('dm') or {}
480
+ dm_route_default = str(dm_conf.get('route_default', 'both'))
481
+ hints = cfg.get('hints') or {}
482
+ hint_cooldown = int(hints.get('cooldown_seconds', 300))
483
+
484
+ # Files policy
485
+ files_conf = cfg.get('files') or {}
486
+ files_enabled = bool(files_conf.get('enabled', True))
487
+ max_mb = int(files_conf.get('max_mb', 16))
488
+ max_bytes = max_mb * 1024 * 1024
489
+ allowed_mime = [str(x) for x in (files_conf.get('allowed_mime') or [])]
490
+ inbound_dir = Path(files_conf.get('inbound_dir') or HOME/"work"/"upload"/"inbound")
491
+ outbound_dir = Path(files_conf.get('outbound_dir') or HOME/"work"/"upload"/"outbound")
492
+ strip_exif = bool(files_conf.get('strip_exif', True))
493
+
494
+ # Hint cooldown memory { (chat_id,user_id): ts }
495
+ hint_last: Dict[Tuple[int,int], float] = {}
496
+
497
+ def _mime_allowed(m: str) -> bool:
498
+ if not allowed_mime:
499
+ return True
500
+ for pat in allowed_mime:
501
+ if pat.endswith('/*'):
502
+ if m.startswith(pat[:-1]):
503
+ return True
504
+ if m.lower() == pat.lower():
505
+ return True
506
+ return False
507
+
508
+ def _sanitize_name(name: str) -> str:
509
+ name = re.sub(r"[^A-Za-z0-9_.\-]+", "_", name)
510
+ return name[:120] or f"file_{int(time.time())}"
511
+
512
+ def _save_file_from_telegram(file_id: str, orig_name: str, chat_id: int, mid: str) -> Tuple[Path, Dict[str,Any]]:
513
+ meta: Dict[str,Any] = {}
514
+ # getFile
515
+ res = tg_api('getFile', {'file_id': file_id}, timeout=20)
516
+ if not res.get('ok'):
517
+ raise RuntimeError(f"getFile failed: {res}")
518
+ file_path = (res.get('result') or {}).get('file_path')
519
+ if not file_path:
520
+ raise RuntimeError("file_path missing")
521
+ url = f"https://api.telegram.org/file/bot{token}/{file_path}"
522
+ # Prepare path
523
+ day = time.strftime('%Y%m%d')
524
+ safe = _sanitize_name(orig_name or os.path.basename(file_path))
525
+ out_dir = inbound_dir/str(chat_id)/day
526
+ out_dir.mkdir(parents=True, exist_ok=True)
527
+ out_path = out_dir/f"{mid}__{safe}"
528
+ # Download
529
+ with urllib.request.urlopen(url, timeout=60) as resp, open(out_path, 'wb') as f:
530
+ data = resp.read()
531
+ if len(data) > max_bytes:
532
+ raise RuntimeError(f"file too large: {len(data)} bytes > {max_bytes}")
533
+ f.write(data)
534
+ meta['bytes'] = len(data)
535
+ # Hash
536
+ import hashlib
537
+ h = hashlib.sha256()
538
+ with open(out_path, 'rb') as f:
539
+ while True:
540
+ chunk = f.read(1024*64)
541
+ if not chunk: break
542
+ h.update(chunk)
543
+ meta['sha256'] = h.hexdigest()
544
+ meta['path'] = str(out_path)
545
+ meta['name'] = safe
546
+ return out_path, meta
547
+
548
+ def _maybe_hint(chat_id: int, user_id: int):
549
+ now = time.time()
550
+ key = (chat_id, user_id)
551
+ if now - float(hint_last.get(key, 0)) < hint_cooldown:
552
+ return
553
+ hint_last[key] = now
554
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'No route detected. Prefix with /a /b /both or a: b: both: to route.'}, timeout=15)
555
+ last_sent_ts = {"peerA": 0.0, "peerB": 0.0}
556
+ last_seen = {"peerA": "", "peerB": ""}
557
+
558
+ # Outbound baseline persistence to avoid re-sending history on restart
559
+ def _seen_path() -> Path:
560
+ return HOME/"state"/"outbound_seen.json"
561
+ def load_outbound_seen() -> dict:
562
+ p = _seen_path()
563
+ try:
564
+ if p.exists():
565
+ return json.loads(p.read_text(encoding='utf-8'))
566
+ except Exception:
567
+ pass
568
+ return {"peerA": {"to_user": "", "to_peer": ""}, "peerB": {"to_user": "", "to_peer": ""}}
569
+ def save_outbound_seen(obj: dict):
570
+ p = _seen_path()
571
+ try:
572
+ p.parent.mkdir(parents=True, exist_ok=True)
573
+ p.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding='utf-8')
574
+ except Exception:
575
+ pass
576
+ def _hash_text(t: str) -> str:
577
+ import hashlib
578
+ return hashlib.sha1((t or "").encode('utf-8', errors='ignore')).hexdigest()
579
+
580
+ # Delete-on-success semantics for outbound files; no persistent sent-cache needed
581
+
582
+
583
+ def send_summary(peer: str, text: str):
584
+ label = "PeerA" if peer == 'peerA' else "PeerB"
585
+ msg = f"[{label}]\n" + _summarize(redact(text), max_chars, max_lines)
586
+ for chat_id in allow:
587
+ tg_api('sendMessage', {
588
+ 'chat_id': chat_id,
589
+ 'text': msg,
590
+ 'disable_web_page_preview': True
591
+ }, timeout=15)
592
+ _append_log(outlog, f"[outbound] sent {label} {len(msg)} chars")
593
+ _append_ledger({"kind":"bridge-outbound","to":"telegram","peer":label.lower(),"chars":len(msg)})
594
+
595
+ def send_peer_summary(sender_peer: str, text: str):
596
+ label = "PeerA→PeerB" if sender_peer == 'peerA' else "PeerB→PeerA"
597
+ msg = f"[{label}]\n" + _summarize(redact(text), peer_max_chars, peer_max_lines)
598
+ for chat_id in allow:
599
+ tg_api('sendMessage', {
600
+ 'chat_id': chat_id,
601
+ 'text': msg,
602
+ 'disable_web_page_preview': True
603
+ }, timeout=15)
604
+ _append_log(outlog, f"[outbound] sent {label} {len(msg)} chars")
605
+ _append_ledger({"kind":"bridge-outbound","to":"telegram","peer":"to_peer","chars":len(msg)})
606
+
607
+ def watch_outputs():
608
+ outbound_conf = cfg.get('outbound') or {}
609
+ watch_peers = outbound_conf.get('watch_to_user_peers') or ['peerA']
610
+ to_user_paths = {}
611
+ if 'peerA' in watch_peers:
612
+ to_user_paths['peerA'] = HOME/'mailbox'/'peerA'/'to_user.md'
613
+ if 'peerB' in watch_peers:
614
+ to_user_paths['peerB'] = HOME/'mailbox'/'peerB'/'to_user.md'
615
+ to_peer_paths = {
616
+ 'peerA': HOME/"mailbox"/"peerA"/"to_peer.md",
617
+ 'peerB': HOME/"mailbox"/"peerB"/"to_peer.md",
618
+ }
619
+ # RFD watcher state with persistence to avoid resending on restart
620
+ def _rfd_seen_path() -> Path:
621
+ return HOME/"state"/"rfd-seen.json"
622
+ def _load_rfd_seen() -> set:
623
+ p = _rfd_seen_path()
624
+ try:
625
+ if p.exists():
626
+ obj = json.loads(p.read_text(encoding='utf-8'))
627
+ arr = obj.get('ids') or []
628
+ return set(str(x) for x in arr)
629
+ except Exception:
630
+ pass
631
+ return set()
632
+ def _save_rfd_seen(ids: set):
633
+ p = _rfd_seen_path(); p.parent.mkdir(parents=True, exist_ok=True)
634
+ try:
635
+ # Trim to last 2000 items to bound file size
636
+ arr = list(ids)[-2000:]
637
+ p.write_text(json.dumps({'ids': arr}, ensure_ascii=False, indent=2), encoding='utf-8')
638
+ except Exception:
639
+ pass
640
+
641
+ def watch_ledger_for_rfd():
642
+ ledger = HOME/"state"/"ledger.jsonl"
643
+ seen_rfd_ids = _load_rfd_seen()
644
+ baseline_done = False
645
+ window = 1000 # scan recent lines window
646
+ while True:
647
+ try:
648
+ if ledger.exists():
649
+ lines = ledger.read_text(encoding='utf-8').splitlines()[-window:]
650
+ changed = False
651
+ for line in lines:
652
+ try:
653
+ ev = json.loads(line)
654
+ except Exception:
655
+ continue
656
+ kind = str(ev.get('kind') or '').lower()
657
+ if kind != 'rfd':
658
+ continue
659
+ rid = str(ev.get('id') or '')
660
+ if not rid:
661
+ import hashlib
662
+ rid = hashlib.sha1(line.encode('utf-8')).hexdigest()[:8]
663
+ if rid in seen_rfd_ids:
664
+ continue
665
+ # On first run, baseline: mark existing RFDs as seen but do not send
666
+ if not baseline_done:
667
+ seen_rfd_ids.add(rid); changed = True
668
+ continue
669
+ # New RFD → send interactive card once
670
+ text = ev.get('title') or ev.get('summary') or f"RFD {rid}"
671
+ markup = {
672
+ 'inline_keyboard': [[
673
+ {'text': 'Approve', 'callback_data': f'rfd:{rid}:approve'},
674
+ {'text': 'Reject', 'callback_data': f'rfd:{rid}:reject'},
675
+ {'text': 'Ask More', 'callback_data': f'rfd:{rid}:askmore'},
676
+ ]]
677
+ }
678
+ for chat_id in allow:
679
+ tg_api('sendMessage', {
680
+ 'chat_id': chat_id,
681
+ 'text': f"[RFD] {text}",
682
+ 'reply_markup': json.dumps(markup)
683
+ }, timeout=15)
684
+ _append_ledger({'kind':'bridge-rfd-card','id':rid})
685
+ seen_rfd_ids.add(rid); changed = True
686
+ if changed:
687
+ _save_rfd_seen(seen_rfd_ids)
688
+ baseline_done = True
689
+ except Exception:
690
+ pass
691
+ time.sleep(2.0)
692
+
693
+ threading.Thread(target=watch_ledger_for_rfd, daemon=True).start()
694
+ # Outbound files watcher state
695
+ # Track attempts within this run only (avoid rapid duplicates if filesystem timestamps don't change)
696
+ sent_files: Dict[str, float] = {}
697
+ def _is_image(path: Path) -> bool:
698
+ return path.suffix.lower() in ('.jpg','.jpeg','.png','.gif','.webp')
699
+ def _send_file(peer: str, fp: Path, caption: str) -> bool:
700
+ cap = f"[{ 'PeerA' if peer=='peerA' else 'PeerB' }]\n" + _summarize(redact(caption or ''), max_chars, max_lines)
701
+ # Choose send method: sidecar override > dir/ext heuristic
702
+ method = 'sendPhoto' if _is_image(fp) or fp.parent.name == 'photos' else 'sendDocument'
703
+ any_fail = False
704
+ try:
705
+ sidecars = [fp.with_suffix(fp.suffix + '.sendas'), fp.with_name(fp.name + '.sendas')]
706
+ for sc in sidecars:
707
+ if sc.exists():
708
+ try:
709
+ m = (sc.read_text(encoding='utf-8').strip() or '').lower()
710
+ if m == 'photo':
711
+ method = 'sendPhoto'
712
+ elif m == 'document':
713
+ method = 'sendDocument'
714
+ except Exception:
715
+ pass
716
+ break
717
+ except Exception:
718
+ pass
719
+ for chat_id in allow:
720
+ try:
721
+ with open(fp, 'rb') as f:
722
+ data = f.read()
723
+ # Use multipart/form-data via urllib is complex; rely on Telegram auto-download for MVP: send link not possible.
724
+ # For simplicity in MVP, fall back to sendDocument by URL is not allowed; so we will skip if too large to read.
725
+ # Here we implement minimal upload using `urllib.request` with manual boundary.
726
+ boundary = f"----cccc{int(time.time()*1000)}"
727
+ def _multipart(fields, files):
728
+ crlf = "\r\n"; lines=[]
729
+ for k,v in fields.items():
730
+ lines.append(f"--{boundary}")
731
+ lines.append(f"Content-Disposition: form-data; name=\"{k}\"")
732
+ lines.append("")
733
+ lines.append(str(v))
734
+ for k, (filename, content, mime) in files.items():
735
+ lines.append(f"--{boundary}")
736
+ lines.append(f"Content-Disposition: form-data; name=\"{k}\"; filename=\"{filename}\"")
737
+ lines.append(f"Content-Type: {mime}")
738
+ lines.append("")
739
+ lines.append(content)
740
+ lines.append(f"--{boundary}--")
741
+ body = b""
742
+ for part in lines:
743
+ if isinstance(part, bytes):
744
+ body += part + b"\r\n"
745
+ else:
746
+ body += part.encode('utf-8') + b"\r\n"
747
+ return body, boundary
748
+ api_url = f"https://api.telegram.org/bot{token}/{method}"
749
+ fields = { 'chat_id': chat_id, 'caption': cap }
750
+ import mimetypes
751
+ mt = mimetypes.guess_type(fp.name)[0] or ''
752
+ if method=='sendPhoto':
753
+ mime = mt if mt.startswith('image/') else 'image/jpeg'
754
+ else:
755
+ mime = mt or 'application/octet-stream'
756
+ files = { ('photo' if method=='sendPhoto' else 'document'): (fp.name, data, mime) }
757
+ body, bnd = _multipart(fields, files)
758
+ req = urllib.request.Request(api_url, data=body, method='POST')
759
+ req.add_header('Content-Type', f'multipart/form-data; boundary={bnd}')
760
+ with urllib.request.urlopen(req, timeout=60) as resp:
761
+ _ = resp.read()
762
+ except Exception as e:
763
+ any_fail = True
764
+ try:
765
+ import urllib.error as _ue
766
+ if isinstance(e, _ue.HTTPError):
767
+ try:
768
+ detail = e.read().decode('utf-8','ignore')
769
+ except Exception:
770
+ detail = ''
771
+ _append_log(outlog, f"[error] outbound-file send {fp}: {e} {detail[:200]}")
772
+ else:
773
+ _append_log(outlog, f"[error] outbound-file send {fp}: {e}")
774
+ except Exception:
775
+ _append_log(outlog, f"[error] outbound-file send {fp}: {e}")
776
+ _append_log(outlog, f"[outbound-file] {fp}")
777
+ _append_ledger({"kind":"bridge-file-outbound","peer":peer,"path":str(fp)})
778
+ # Delete file and sidecars only when all sends succeeded
779
+ if not any_fail:
780
+ try:
781
+ for side in (
782
+ fp.with_suffix(fp.suffix + '.caption.txt'),
783
+ fp.with_suffix(fp.suffix + '.sendas'),
784
+ fp.with_name(fp.name + '.sendas'),
785
+ fp.with_suffix(fp.suffix + '.meta.json'),
786
+ ):
787
+ try:
788
+ if side.exists():
789
+ side.unlink()
790
+ except Exception:
791
+ pass
792
+ fp.unlink()
793
+ except Exception as de:
794
+ _append_log(outlog, f"[warn] failed to delete outbound file {fp}: {de}")
795
+ return True
796
+ return False
797
+
798
+ # Optional reset on start: baseline|archive|clear
799
+ # Default to 'clear' to avoid blasting residual files on startup
800
+ reset_mode = str((outbound_conf.get('reset_on_start') or 'clear')).lower()
801
+ try:
802
+ if reset_mode in ('archive','clear'):
803
+ arch = HOME/'state'/'outbound-archive'; arch.mkdir(parents=True, exist_ok=True)
804
+ # Clear to_user and to_peer files to prevent re-sending summaries on restart
805
+ for peer, pth in {**to_user_paths, **to_peer_paths}.items():
806
+ try:
807
+ txt = pth.read_text(encoding='utf-8')
808
+ except Exception:
809
+ txt = ''
810
+ if txt:
811
+ if reset_mode == 'archive':
812
+ import time as _t
813
+ ts = _t.strftime('%Y%m%d-%H%M%S')
814
+ dest_dir = arch/peer
815
+ dest_dir.mkdir(parents=True, exist_ok=True)
816
+ (dest_dir/f"{pth.name}-{ts}").write_text(txt, encoding='utf-8')
817
+ try:
818
+ pth.write_text('', encoding='utf-8')
819
+ except Exception:
820
+ pass
821
+ # Also clear/archive outbound files to avoid blasting residual uploads
822
+ for peer in ('peerA','peerB'):
823
+ for sub in ('files','photos'):
824
+ d = outbound_dir/peer/sub
825
+ if not d.exists():
826
+ continue
827
+ for fp in sorted(d.glob('*')):
828
+ if fp.is_dir():
829
+ continue
830
+ nm = str(fp.name).lower()
831
+ if nm.endswith('.caption.txt') or nm.endswith('.sendas') or nm.endswith('.meta.json'):
832
+ continue
833
+ if reset_mode == 'archive':
834
+ import time as _t
835
+ ts = _t.strftime('%Y%m%d-%H%M%S')
836
+ dest_dir = arch/peer/sub
837
+ dest_dir.mkdir(parents=True, exist_ok=True)
838
+ try:
839
+ (dest_dir/f"{fp.name}-{ts}").write_bytes(fp.read_bytes())
840
+ except Exception:
841
+ pass
842
+ try:
843
+ fp.unlink()
844
+ except Exception:
845
+ pass
846
+ # After clearing, outbound directory is empty; no extra bookkeeping needed
847
+ except Exception:
848
+ pass
849
+ # Initialize baseline and persist seen hashes
850
+ try:
851
+ seen = load_outbound_seen()
852
+ for peer, pth in to_user_paths.items():
853
+ try:
854
+ txt = pth.read_text(encoding='utf-8').strip()
855
+ except Exception:
856
+ txt = ''
857
+ last_seen[peer] = txt; last_sent_ts[peer] = time.time()
858
+ pk = 'peerA' if peer=='peerA' else 'peerB'
859
+ seen.setdefault(pk, {})['to_user'] = _hash_text(txt)
860
+ for peer, pth in to_peer_paths.items():
861
+ try:
862
+ txt = pth.read_text(encoding='utf-8').strip()
863
+ except Exception:
864
+ txt = ''
865
+ last_seen[f"peer_{peer}"] = txt; last_sent_ts[f"peer_{peer}"] = time.time()
866
+ pk = 'peerA' if peer=='peerA' else 'peerB'
867
+ seen.setdefault(pk, {})['to_peer'] = _hash_text(txt)
868
+ save_outbound_seen(seen)
869
+ # Baseline mode: do nothing special; existing files (if any) will be sent once and deleted on success
870
+ except Exception:
871
+ pass
872
+
873
+ while True:
874
+ now = time.time()
875
+ for peer, p in to_user_paths.items():
876
+ try:
877
+ txt = p.read_text(encoding='utf-8').strip()
878
+ except Exception:
879
+ txt = ''
880
+ if txt and txt != last_seen[peer] and (now - last_sent_ts[peer] >= debounce):
881
+ last_seen[peer] = txt
882
+ last_sent_ts[peer] = now
883
+ send_summary(peer, txt)
884
+ # to_peer (peer-to-peer) messages
885
+ eff_show = bool(load_runtime().get('show_peer_messages', show_peers))
886
+ if eff_show:
887
+ for peer, p in to_peer_paths.items():
888
+ try:
889
+ txt = p.read_text(encoding='utf-8').strip()
890
+ except Exception:
891
+ txt = ''
892
+ key = f"peer_{peer}"
893
+ if txt and txt != last_seen.get(key, '') and (now - last_sent_ts.get(key, 0.0) >= peer_debounce):
894
+ last_seen[key] = txt
895
+ last_sent_ts[key] = now
896
+ send_peer_summary(peer, txt)
897
+ # Outbound files
898
+ try:
899
+ for peer in ('peerA','peerB'):
900
+ for sub in ('files','photos'):
901
+ d = outbound_dir/peer/sub
902
+ if not d.exists():
903
+ continue
904
+ for fp in sorted(d.glob('*')):
905
+ if fp.is_dir():
906
+ continue
907
+ name=str(fp.name).lower()
908
+ if name.endswith('.caption.txt') or name.endswith('.sendas') or name.endswith('.meta.json'):
909
+ continue
910
+ # optional caption sidecar
911
+ cap_fp = fp.with_suffix(fp.suffix + '.caption.txt')
912
+ if cap_fp.exists():
913
+ try:
914
+ cap = cap_fp.read_text(encoding='utf-8').strip()
915
+ except Exception:
916
+ cap = ''
917
+ else:
918
+ cap = ''
919
+ # Send and delete on success; on failure, keep file for retry
920
+ _send_file(peer, fp, cap)
921
+ except Exception as e:
922
+ _append_log(outlog, f"[error] watch_outbound: {e}")
923
+ time.sleep(1.0)
924
+
925
+ t_out = threading.Thread(target=watch_outputs, daemon=True)
926
+ t_out.start()
927
+
928
+ # Inbound poll loop
929
+ offset_path = HOME/"state"/"telegram-offset.json"
930
+ try:
931
+ off = int(json.loads(offset_path.read_text()).get('offset', 0)) if offset_path.exists() else 0
932
+ except Exception:
933
+ off = 0
934
+ default_route = str(cfg.get('default_route') or 'both')
935
+ while True:
936
+ off, updates = tg_poll(off)
937
+ if updates:
938
+ offset_path.parent.mkdir(parents=True, exist_ok=True)
939
+ offset_path.write_text(json.dumps({"offset": off}), encoding='utf-8')
940
+ for u in updates:
941
+ # Handle inline button callbacks (e.g., RFD approvals)
942
+ if u.get('callback_query'):
943
+ cq = u['callback_query']
944
+ data = str(cq.get('data') or '')
945
+ cchat = ((cq.get('message') or {}).get('chat') or {})
946
+ cchat_id = int(cchat.get('id', 0) or 0)
947
+ try:
948
+ if data.startswith('rfd:'):
949
+ parts = data.split(':', 2)
950
+ rid = parts[1] if len(parts) > 1 else ''
951
+ decision = parts[2] if len(parts) > 2 else ''
952
+ _append_ledger({'kind':'decision','rfd_id':rid,'decision':decision,'chat':cchat_id})
953
+ tg_api('answerCallbackQuery', {'callback_query_id': cq.get('id'), 'text': f'Decision recorded: {decision}'}, timeout=10)
954
+ tg_api('sendMessage', {'chat_id': cchat_id, 'text': f"[RFD] {rid} → {decision}"}, timeout=15)
955
+ else:
956
+ tg_api('answerCallbackQuery', {'callback_query_id': cq.get('id')}, timeout=10)
957
+ except Exception:
958
+ pass
959
+ continue
960
+ msg = u.get('message') or u.get('edited_message') or {}
961
+ chat = (msg.get('chat') or {})
962
+ chat_id = int(chat.get('id', 0) or 0)
963
+ chat_type = str(chat.get('type') or '')
964
+ if chat_id not in allow:
965
+ text = (msg.get('text') or '').strip()
966
+ if policy == 'open' and is_cmd(text, 'subscribe'):
967
+ # Auto-register with cap
968
+ cur = set(load_subs())
969
+ if chat_id in cur:
970
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Already subscribed (allowlist)'}, timeout=15)
971
+ elif len(cur) >= max_auto:
972
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Subscription limit reached; contact admin.'}, timeout=15)
973
+ else:
974
+ cur.add(chat_id); save_subs(sorted(cur)); allow.add(chat_id)
975
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Subscribed. This chat will receive summaries. Send /unsubscribe to leave.'}, timeout=15)
976
+ _append_log(outlog, f"[subscribe] chat={chat_id}")
977
+ _append_ledger({"kind":"bridge-subscribe","chat":chat_id})
978
+ continue
979
+ if policy == 'open' and is_cmd(text, 'unsubscribe'):
980
+ # Allow unsub from non-allowed (no-op) for idempotence
981
+ cur = set(load_subs()); removed = chat_id in cur
982
+ if removed:
983
+ cur.discard(chat_id); save_subs(sorted(cur))
984
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Unsubscribed' if removed else 'Not subscribed'}, timeout=15)
985
+ _append_log(outlog, f"[unsubscribe] chat={chat_id}")
986
+ _append_ledger({"kind":"bridge-unsubscribe","chat":chat_id})
987
+ continue
988
+ # Discovery or closed policy: log and optionally reply to whoami
989
+ _append_log(outlog, f"[drop] message from not-allowed chat={chat_id}")
990
+ _append_ledger({"kind":"bridge-drop","reason":"not-allowed","chat":chat_id})
991
+ if discover and is_cmd(text, 'whoami'):
992
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': f"chat_id={chat_id} (not allowed; send /subscribe to opt-in)"}, timeout=15)
993
+ elif policy == 'open':
994
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Not subscribed. Send /subscribe to opt-in, /unsubscribe to leave.'}, timeout=15)
995
+ continue
996
+ text = (msg.get('text') or '').strip()
997
+ caption = (msg.get('caption') or '').strip()
998
+ is_dm = (chat_type == 'private')
999
+ route_source = text or caption
1000
+ # Enforce mention in group if configured
1001
+ if (not is_dm) and require_mention:
1002
+ ents = msg.get('entities') or []
1003
+ mentions = any(e.get('type')=='mention' for e in ents)
1004
+ if not mentions:
1005
+ _maybe_hint(chat_id, int((msg.get('from') or {}).get('id', 0) or 0))
1006
+ continue
1007
+ # Enforce explicit routing for groups
1008
+ has_explicit = bool(re.match(r"^(?:/(?:a|b|both)(?:@\S+)?|(?:a:|b:|both:))", (route_source or '').strip(), re.I))
1009
+ dr = dm_route_default if is_dm else default_route
1010
+ if (not is_dm) and require_explicit and not has_explicit and not (msg.get('document') or msg.get('photo')):
1011
+ _maybe_hint(chat_id, int((msg.get('from') or {}).get('id', 0) or 0))
1012
+ continue
1013
+ # Reply routing: if message contains only a route and replies to another message,
1014
+ # use the replied message's content/files.
1015
+ rmsg = msg.get('reply_to_message') or {}
1016
+ if rmsg and has_explicit and not (text.strip().split(maxsplit=1)[1:] if text else []) and not caption:
1017
+ rtext = (rmsg.get('text') or rmsg.get('caption') or '').strip()
1018
+ if rtext:
1019
+ route_source = rtext
1020
+ if files_enabled and (rmsg.get('document') or rmsg.get('photo')):
1021
+ metas = []
1022
+ try:
1023
+ if rmsg.get('document'):
1024
+ doc = rmsg['document']
1025
+ fn = doc.get('file_name') or 'document.bin'
1026
+ mime = doc.get('mime_type') or 'application/octet-stream'
1027
+ if _mime_allowed(mime):
1028
+ midf = _mid()
1029
+ path, meta = _save_file_from_telegram(doc.get('file_id'), fn, chat_id, midf)
1030
+ meta.update({'mime': mime, 'caption': rtext, 'mid': midf}); metas.append(meta)
1031
+ if rmsg.get('photo'):
1032
+ ph = sorted(rmsg['photo'], key=lambda p: int(p.get('file_size') or 0))[-1]
1033
+ fn = 'photo.jpg'; mime = 'image/jpeg'
1034
+ midf = _mid(); path, meta = _save_file_from_telegram(ph.get('file_id'), fn, chat_id, midf)
1035
+ meta.update({'mime': mime, 'caption': rtext, 'mid': midf}); metas.append(meta)
1036
+ except Exception as e:
1037
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': f'Failed to receive quoted file: {e}'}, timeout=15)
1038
+ _append_log(outlog, f"[error] inbound-file-reply: {e}")
1039
+ metas = []
1040
+ if metas:
1041
+ routes, _ = _route_from_text(text or '/both', dr)
1042
+ lines = ["<FROM_USER>", f"[MID: {_mid()}]"]
1043
+ if rtext:
1044
+ lines.append(f"Quoted: {redact(rtext)[:200]}")
1045
+ for mta in metas:
1046
+ rel = os.path.relpath(mta['path'], start=ROOT)
1047
+ lines.append(f"File: {rel}")
1048
+ lines.append(f"SHA256: {mta['sha256']} Size: {mta['bytes']} MIME: {mta['mime']}")
1049
+ try:
1050
+ side = Path(mta['path']).with_suffix(Path(mta['path']).suffix + '.meta.json')
1051
+ side.write_text(json.dumps({
1052
+ 'chat_id': chat_id,
1053
+ 'path': rel,
1054
+ 'sha256': mta['sha256'],
1055
+ 'bytes': mta['bytes'],
1056
+ 'mime': mta['mime'],
1057
+ 'caption': rtext,
1058
+ 'mid': mta.get('mid'),
1059
+ 'ts': time.strftime('%Y-%m-%d %H:%M:%S')
1060
+ }, ensure_ascii=False, indent=2), encoding='utf-8')
1061
+ except Exception:
1062
+ pass
1063
+ lines.append("</FROM_USER>")
1064
+ payload = "\n".join(lines) + "\n"
1065
+ _deliver_inbound(HOME, routes, payload, _mid())
1066
+ _append_log(outlog, f"[inbound-file-reply] routes={routes} files={len(metas)} chat={chat_id}")
1067
+ _append_ledger({'kind': 'bridge-file-inbound', 'chat': chat_id, 'routes': routes,
1068
+ 'files': [{'path': m['path'], 'sha256': m['sha256']} for m in metas]})
1069
+ continue
1070
+ # Inbound files
1071
+ if files_enabled and (msg.get('document') or msg.get('photo')):
1072
+ if (not is_dm) and require_explicit and not has_explicit:
1073
+ _maybe_hint(chat_id, int((msg.get('from') or {}).get('id', 0) or 0))
1074
+ continue
1075
+ metas = []
1076
+ try:
1077
+ if msg.get('document'):
1078
+ doc = msg['document']
1079
+ fn = doc.get('file_name') or 'document.bin'
1080
+ mime = doc.get('mime_type') or 'application/octet-stream'
1081
+ if not _mime_allowed(mime):
1082
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': f'File type not allowed: {mime}'}, timeout=15)
1083
+ continue
1084
+ midf = _mid()
1085
+ path, meta = _save_file_from_telegram(doc.get('file_id'), fn, chat_id, midf)
1086
+ meta.update({'mime': mime, 'caption': caption, 'mid': midf})
1087
+ metas.append(meta)
1088
+ if msg.get('photo'):
1089
+ ph = sorted(msg['photo'], key=lambda p: int(p.get('file_size') or 0))[-1]
1090
+ fn = 'photo.jpg'
1091
+ mime = 'image/jpeg'
1092
+ midf = _mid()
1093
+ path, meta = _save_file_from_telegram(ph.get('file_id'), fn, chat_id, midf)
1094
+ meta.update({'mime': mime, 'caption': caption, 'mid': midf})
1095
+ metas.append(meta)
1096
+ # Build inbox payload
1097
+ routes, _ = _route_from_text(route_source or '', dr)
1098
+ lines = ["<FROM_USER>"]
1099
+ lines.append(f"[MID: {_mid()}]")
1100
+ if caption:
1101
+ lines.append(f"Caption: {redact(caption)}")
1102
+ for mta in metas:
1103
+ rel = os.path.relpath(mta['path'], start=ROOT)
1104
+ lines.append(f"File: {rel}")
1105
+ lines.append(f"SHA256: {mta['sha256']} Size: {mta['bytes']} MIME: {mta['mime']}")
1106
+ # write sidecar meta json
1107
+ try:
1108
+ side = Path(mta['path']).with_suffix(Path(mta['path']).suffix + '.meta.json')
1109
+ side.write_text(json.dumps({
1110
+ 'chat_id': chat_id,
1111
+ 'path': rel,
1112
+ 'sha256': mta['sha256'],
1113
+ 'bytes': mta['bytes'],
1114
+ 'mime': mta['mime'],
1115
+ 'caption': caption,
1116
+ 'mid': mta.get('mid'),
1117
+ 'ts': time.strftime('%Y-%m-%d %H:%M:%S')
1118
+ }, ensure_ascii=False, indent=2), encoding='utf-8')
1119
+ except Exception:
1120
+ pass
1121
+ lines.append("</FROM_USER>")
1122
+ payload = "\n".join(lines) + "\n"
1123
+ _deliver_inbound(HOME, routes, payload, _mid())
1124
+ _append_log(outlog, f"[inbound-file] routes={routes} files={len(metas)} chat={chat_id}")
1125
+ _append_ledger({"kind":"bridge-file-inbound","chat":chat_id,"routes":routes,"files":[{"path":m['path'],"sha256":m['sha256'],"bytes":m['bytes'],"mime":m['mime']} for m in metas]})
1126
+ continue
1127
+ except Exception as e:
1128
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': f'Failed to receive file: {e}'}, timeout=15)
1129
+ _append_log(outlog, f"[error] inbound-file: {e}")
1130
+ continue
1131
+ # minimal commands
1132
+ if is_cmd(text, 'subscribe'):
1133
+ if policy == 'open':
1134
+ cur = set(load_subs()); added = chat_id not in cur
1135
+ if added:
1136
+ cur.add(chat_id); save_subs(sorted(cur)); allow.add(chat_id)
1137
+ tg_api('sendMessage', {
1138
+ 'chat_id': chat_id,
1139
+ 'text': 'Subscribed. This chat will receive summaries. Send /unsubscribe to leave.' if added else 'Already subscribed (allowlist)'
1140
+ }, timeout=15)
1141
+ _append_log(outlog, f"[subscribe] chat={chat_id}{' (noop)' if not added else ''}")
1142
+ _append_ledger({"kind":"bridge-subscribe","chat":chat_id,"noop": (not added)})
1143
+ else:
1144
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Self-subscribe disabled; contact admin.'}, timeout=15)
1145
+ continue
1146
+ if is_cmd(text, 'status'):
1147
+ st_path = HOME/"state"/"status.json"
1148
+ try:
1149
+ st = json.loads(st_path.read_text(encoding='utf-8')) if st_path.exists() else {}
1150
+ except Exception:
1151
+ st = {}
1152
+ phase = st.get('phase'); paused = st.get('paused'); leader = st.get('leader')
1153
+ counts = st.get('mailbox_counts') or {}
1154
+ a = counts.get('peerA') or {}; b = counts.get('peerB') or {}
1155
+ lines = [
1156
+ f"Phase: {phase} Paused: {paused}",
1157
+ f"Leader: {leader}",
1158
+ f"peerA to_user:{a.get('to_user',0)} to_peer:{a.get('to_peer',0)} patch:{a.get('patch',0)}",
1159
+ f"peerB to_user:{b.get('to_user',0)} to_peer:{b.get('to_peer',0)} patch:{b.get('patch',0)}",
1160
+ ]
1161
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': "\n".join(lines)}, timeout=15)
1162
+ continue
1163
+ if is_cmd(text, 'queue'):
1164
+ q_path = HOME/"state"/"queue.json"; qA=qB=0; inflA=inflB=False
1165
+ try:
1166
+ q = json.loads(q_path.read_text(encoding='utf-8')) if q_path.exists() else {}
1167
+ qA = int(q.get('peerA') or 0); qB = int(q.get('peerB') or 0)
1168
+ infl = q.get('inflight') or {}; inflA = bool(infl.get('peerA')); inflB = bool(infl.get('peerB'))
1169
+ except Exception:
1170
+ pass
1171
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': f"Queue: PeerA={qA} inflight={inflA} | PeerB={qB} inflight={inflB}"}, timeout=15)
1172
+ continue
1173
+ if is_cmd(text, 'locks'):
1174
+ l_path = HOME/"state"/"locks.json"
1175
+ try:
1176
+ l = json.loads(l_path.read_text(encoding='utf-8')) if l_path.exists() else {}
1177
+ locks = l.get('inbox_seq_locks') or []
1178
+ infl = l.get('inflight') or {}
1179
+ lines=[
1180
+ f"InboxSeqLocks: {', '.join(locks) if locks else 'none'}",
1181
+ f"Inflight: PeerA={bool(infl.get('peerA'))} PeerB={bool(infl.get('peerB'))}",
1182
+ ]
1183
+ except Exception:
1184
+ lines=["No locks info"]
1185
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': "\n".join(lines)}, timeout=15)
1186
+ continue
1187
+ if is_cmd(text, 'whoami'):
1188
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': f"chat_id={chat_id}"}, timeout=15)
1189
+ _append_log(outlog, f"[meta] whoami chat={chat_id}")
1190
+ continue
1191
+ if is_cmd(text, 'help'):
1192
+ help_txt = (
1193
+ "Usage: a:/b:/both: or /a /b /both to route to PeerA/PeerB/both; /whoami shows chat_id; /status shows status; /queue shows queue; /locks shows locks; "
1194
+ "/subscribe opt-in (if enabled); /unsubscribe opt-out; /showpeers on|off toggle Peer↔Peer summary; /files [in|out] [N] list recent files; /file N view; /rfd list|show <id>."
1195
+ )
1196
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': help_txt}, timeout=15)
1197
+ continue
1198
+ # /rfd list|show <id>
1199
+ if re.match(r"^/rfd(?:@\S+)?\b", text.strip(), re.I):
1200
+ try:
1201
+ cmd = text.strip().split()
1202
+ sub = cmd[1].lower() if len(cmd) > 1 else 'list'
1203
+ except Exception:
1204
+ sub = 'list'
1205
+ ledger = HOME/"state"/"ledger.jsonl"
1206
+ entries = []
1207
+ try:
1208
+ lines = ledger.read_text(encoding='utf-8').splitlines()[-500:]
1209
+ for ln in lines:
1210
+ try:
1211
+ ev = json.loads(ln)
1212
+ entries.append(ev)
1213
+ except Exception:
1214
+ pass
1215
+ except Exception:
1216
+ entries = []
1217
+ if sub == 'list':
1218
+ rfds = [e for e in entries if str(e.get('kind') or '').lower() == 'rfd'][-10:]
1219
+ if not rfds:
1220
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'No RFD entries'}, timeout=15)
1221
+ continue
1222
+ lines = [f"{e.get('id') or '?'} | {e.get('title') or e.get('summary') or ''}" for e in rfds]
1223
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': "\n".join(lines)}, timeout=15)
1224
+ continue
1225
+ if sub == 'show':
1226
+ rid = cmd[2] if len(cmd) > 2 else ''
1227
+ if not rid:
1228
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Usage: /rfd show <id>'}, timeout=15)
1229
+ continue
1230
+ # Find the RFD and latest decision
1231
+ rfd = None; decision = None
1232
+ for ev in entries:
1233
+ k = str(ev.get('kind') or '').lower()
1234
+ if k == 'rfd' and str(ev.get('id') or '') == rid:
1235
+ rfd = ev
1236
+ if k == 'decision' and str(ev.get('rfd_id') or '') == rid:
1237
+ decision = ev
1238
+ text_out = [f"RFD {rid}"]
1239
+ if rfd:
1240
+ text_out.append(f"title={rfd.get('title') or rfd.get('summary') or ''}")
1241
+ text_out.append(f"ts={rfd.get('ts')}")
1242
+ else:
1243
+ text_out.append('not found in tail')
1244
+ if decision:
1245
+ text_out.append(f"decision={decision.get('decision')} by chat={decision.get('chat')} ts={decision.get('ts')}")
1246
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': "\n".join(text_out)}, timeout=15)
1247
+ continue
1248
+ # /files and /file
1249
+ if re.match(r"^/files(?:@\S+)?\b", text.strip(), re.I):
1250
+ m = re.match(r"^/files(?:@\S+)?\s*(in|out)?\s*(\d+)?", text.strip(), re.I)
1251
+ mode = (m.group(1).lower() if (m and m.group(1)) else 'in')
1252
+ limit = int(m.group(2)) if (m and m.group(2)) else 10
1253
+ base = inbound_dir if mode == 'in' else outbound_dir
1254
+ items = []
1255
+ for root, dirs, files in os.walk(base):
1256
+ for fn in files:
1257
+ if fn.endswith('.meta.json') or fn.endswith('.caption.txt'):
1258
+ continue
1259
+ fp = Path(root)/fn
1260
+ try:
1261
+ st = fp.stat()
1262
+ items.append((st.st_mtime, fp))
1263
+ except Exception:
1264
+ pass
1265
+ items.sort(key=lambda x: x[0], reverse=True)
1266
+ items = items[:max(1, min(limit, 50))]
1267
+ lines=[f"Recent files ({ 'inbound' if mode=='in' else 'outbound' }, top {len(items)}):"]
1268
+ map_paths=[str(p) for _,p in items]
1269
+ # Save last listing map for /file N
1270
+ rt = load_runtime(); lm = rt.get('last_files',{})
1271
+ lm[str(chat_id)] = {'mode': mode, 'items': map_paths}
1272
+ rt['last_files']=lm; save_runtime(rt)
1273
+ for idx,(ts,fp) in enumerate(items, start=1):
1274
+ rel = os.path.relpath(fp, start=ROOT)
1275
+ try:
1276
+ size = fp.stat().st_size
1277
+ except Exception:
1278
+ size = 0
1279
+ lines.append(f"{idx}. {rel} ({size} bytes)")
1280
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': "\n".join(lines)}, timeout=15)
1281
+ continue
1282
+ if re.match(r"^/file(?:@\S+)?\s+\d+\b", text.strip(), re.I):
1283
+ m = re.match(r"^/file(?:@\S+)?\s+(\d+)\b", text.strip(), re.I)
1284
+ n = int(m.group(1)) if m else 0
1285
+ rt = load_runtime(); lm = (rt.get('last_files') or {}).get(str(chat_id)) or {}
1286
+ arr = lm.get('items') or []
1287
+ if n<=0 or n>len(arr):
1288
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Invalid index. Run /files, then /file N.'}, timeout=15)
1289
+ continue
1290
+ fp = Path(arr[n-1])
1291
+ info=[]
1292
+ rel = os.path.relpath(fp, start=ROOT)
1293
+ info.append(f"Path: {rel}")
1294
+ try:
1295
+ st=fp.stat(); info.append(f"Size: {st.st_size} bytes MTime: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(st.st_mtime))}")
1296
+ except Exception:
1297
+ pass
1298
+ meta_fp = fp.with_suffix(fp.suffix+'.meta.json')
1299
+ if meta_fp.exists():
1300
+ try:
1301
+ meta = json.loads(meta_fp.read_text(encoding='utf-8'))
1302
+ sha = meta.get('sha256'); mime = meta.get('mime'); cap = meta.get('caption')
1303
+ if sha: info.append(f"SHA256: {sha}")
1304
+ if mime: info.append(f"MIME: {mime}")
1305
+ if cap: info.append(f"Caption: {redact(cap)}")
1306
+ except Exception:
1307
+ pass
1308
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': "\n".join(info)}, timeout=15)
1309
+ continue
1310
+ if re.match(r"^/showpeers(?:@\S+)?\s+(on|off)\b", text.strip(), re.I):
1311
+ m = re.match(r"^/showpeers(?:@\S+)?\s+(on|off)\b", text.strip(), re.I)
1312
+ val = m.group(1).lower() == 'on' if m else False
1313
+ runtime = load_runtime(); runtime['show_peer_messages'] = bool(val); save_runtime(runtime)
1314
+ show_peers = bool(val)
1315
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': f"Peer↔Peer summary set to: {'ON' if val else 'OFF'} (global)"}, timeout=15)
1316
+ _append_log(outlog, f"[runtime] show_peer_messages={val}")
1317
+ continue
1318
+ if is_cmd(text, 'unsubscribe'):
1319
+ if policy == 'open':
1320
+ cur = set(load_subs()); removed = chat_id in cur
1321
+ if removed:
1322
+ cur.discard(chat_id); save_subs(sorted(cur)); allow.discard(chat_id)
1323
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Unsubscribed' if removed else 'Not subscribed'}, timeout=15)
1324
+ _append_log(outlog, f"[unsubscribe] chat={chat_id}")
1325
+ _append_ledger({"kind":"bridge-unsubscribe","chat":chat_id})
1326
+ else:
1327
+ tg_api('sendMessage', {'chat_id': chat_id, 'text': 'Self-unsubscribe disabled; contact admin.'}, timeout=15)
1328
+ continue
1329
+ routes, body = _route_from_text(route_source, dr)
1330
+ mid = _mid()
1331
+ body2 = _wrap_user_if_needed(body)
1332
+ payload = _wrap_with_mid(body2, mid)
1333
+ _deliver_inbound(HOME, routes, payload, mid)
1334
+ _append_log(outlog, f"[inbound] routes={routes} mid={mid} size={len(body)} chat={chat_id}")
1335
+ _append_ledger({"kind":"bridge-inbound","from":"telegram","chat":chat_id,"routes":routes,"mid":mid,"chars":len(body)})
1336
+ time.sleep(0.5)
1337
+
1338
+ if __name__ == '__main__':
1339
+ main()