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.
- cccc_pair-0.2.5/.cccc/adapters/telegram_bridge.py +1339 -0
- cccc_pair-0.2.5/.cccc/delivery.py +282 -0
- cccc_pair-0.2.5/.cccc/evidence_runner.py +127 -0
- cccc_pair-0.2.5/.cccc/mailbox.py +129 -0
- cccc_pair-0.2.5/.cccc/mock_agent.py +67 -0
- cccc_pair-0.2.5/.cccc/orchestrator_tmux.py +2260 -0
- cccc_pair-0.2.5/.cccc/panel_status.py +169 -0
- cccc_pair-0.2.5/.cccc/prompt_weaver.py +98 -0
- cccc_pair-0.2.5/.cccc/settings/cli_profiles.yaml +92 -0
- cccc_pair-0.2.5/.cccc/settings/policies.yaml +63 -0
- cccc_pair-0.2.5/.cccc/settings/roles.yaml +7 -0
- cccc_pair-0.2.5/.cccc/settings/telegram.yaml +63 -0
- cccc_pair-0.2.5/LICENSE +201 -0
- cccc_pair-0.2.5/MANIFEST.in +12 -0
- cccc_pair-0.2.5/PKG-INFO +479 -0
- cccc_pair-0.2.5/README.md +249 -0
- cccc_pair-0.2.5/cccc.py +643 -0
- cccc_pair-0.2.5/cccc_pair.egg-info/PKG-INFO +479 -0
- cccc_pair-0.2.5/cccc_pair.egg-info/SOURCES.txt +35 -0
- cccc_pair-0.2.5/cccc_pair.egg-info/dependency_links.txt +1 -0
- cccc_pair-0.2.5/cccc_pair.egg-info/entry_points.txt +2 -0
- cccc_pair-0.2.5/cccc_pair.egg-info/requires.txt +1 -0
- cccc_pair-0.2.5/cccc_pair.egg-info/top_level.txt +2 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/adapters/telegram_bridge.py +1339 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/delivery.py +282 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/evidence_runner.py +127 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/mailbox.py +129 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/mock_agent.py +67 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/orchestrator_tmux.py +2260 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/panel_status.py +169 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/prompt_weaver.py +98 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/settings/cli_profiles.yaml +92 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/settings/policies.yaml +63 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/settings/roles.yaml +7 -0
- cccc_pair-0.2.5/cccc_scaffold/scaffold/settings/telegram.yaml +63 -0
- cccc_pair-0.2.5/pyproject.toml +68 -0
- 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()
|