Repository navigation
Expand file tree
/
Copy pathbridge.py
More file actions
3255 lines (2835 loc) · 120 KB
/
Copy pathbridge.py
File metadata and controls
3255 lines (2835 loc) · 120 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Telegram <-> opencode koprusu (daemon).
Ne yapar:
1. Telegram Bot API'yi long-polling ile dinler (getUpdates) — stdlib only.
2. Izinli chat'lerden gelen her soruyu `opencode run` ile calistirir,
cevabi Telegram'a geri gonderir. Ilk soruda yeni oturum acilir, gercek
`ses_...` ID `telegram_sessions.json`'a eslenir; sonraki sorular ayni
oturumda devam eder. Boylece PC basinda olmadan tam kontrol saglanir.
3. opencode TUI icinden `telegram_send` ile kuyruga yazilanlari
(telegram_outbox.json) Telegram'a gonderir.
4. Gelen her mesaji telegram_inbox.json'a da yazar (MCP telegram_poll
ile opencode icinden gorulebilir, audit trail).
Calistirma:
python bridge/bridge.py
python bridge/bridge.py --selftest (ag baglantisiz kontrol)
# veya paket kokunden: .\scripts\start-bridge.ps1
Config (.env veya ortam degiskeni):
TELEGRAM_BOT_TOKEN (zorunlu, calisma icin)
TELEGRAM_ALLOWED_CHAT_IDS (zorunlu, virgul/; listesi. Bos = kimse giremez)
TELEGRAM_DEFAULT_CHAT_ID (opsiyonel, telegram_send hedefi)
TELEGRAM_PROJECT_DIR (varsayilan: repo koku — bu dosyanin 3 ustu)
TELEGRAM_SESSION_PREFIX (varsayilan: tg)
TELEGRAM_OPENCODE_AGENT (varsayilan: orchestrator)
TELEGRAM_BRIDGE_EXEC (1/0, varsayilan 1 — 0 ise sadece kuyruklar, calistirmaz)
TELEGRAM_OPENCODE_TIMEOUT (sn, varsayilan 600)
TELEGRAM_POLL_TIMEOUT (sn, varsayilan 30)
Guvenlik: allowlist disi chat'ler yok sayilir (log'a dusulur, cevap verilmez).
Izinli chat == PC'de opencode yetkisi demektir; chat_id'leri kimseyle paylasmayin.
"""
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
# ---------- yollar ----------
BRIDGE_DIR = os.path.dirname(os.path.abspath(__file__))
def _default_project_dir():
# Ic ice (team-template) kullanimda 2 ust proje kokudur; bagimsiz pakette
# proje isareti yoksa paket dizininin kendisi kullanilir.
# Her durumda TELEGRAM_PROJECT_DIR ile ezilebilir.
nested = os.path.normpath(os.path.join(BRIDGE_DIR, "..", ".."))
for marker in (".opencode", "opencode.json", ".git"):
if os.path.exists(os.path.join(nested, marker)):
return nested
return os.path.normpath(os.path.join(BRIDGE_DIR, ".."))
DEFAULT_PROJECT_DIR = _default_project_dir()
if BRIDGE_DIR not in sys.path:
sys.path.insert(0, BRIDGE_DIR)
import atomic_json as _aj # noqa: E402 (sys.path ayarindan sonra gelmeli)
import md2 as _md2 # noqa: E402
def load_dotenv(path):
if not os.path.exists(path):
return
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip()
v = v.strip('"').strip("'")
if k and k not in os.environ:
os.environ[k] = v
except OSError:
pass
load_dotenv(os.path.join(DEFAULT_PROJECT_DIR, ".env"))
load_dotenv(os.path.join(BRIDGE_DIR, ".env"))
def env(name, default=""):
return os.environ.get(name, default)
PROJECT_DIR = env("TELEGRAM_PROJECT_DIR", DEFAULT_PROJECT_DIR)
SESSION_PREFIX = env("TELEGRAM_SESSION_PREFIX", "tg")
OPENCODE_AGENT = env("TELEGRAM_OPENCODE_AGENT", "orchestrator")
OPENCODE_MODEL = env("TELEGRAM_OPENCODE_MODEL", "")
SERVE_BACKEND = env("TELEGRAM_BACKEND", "cli").lower() == "serve"
SERVE_URL = env("TELEGRAM_SERVE_URL", "http://127.0.0.1:4096")
try:
SERVE_PORT = int(env("TELEGRAM_SERVE_PORT", "4096"))
except ValueError:
SERVE_PORT = 4096
SERVE_PASSWORD = env("TELEGRAM_SERVE_PASSWORD", "")
_serve_proc = None
BRIDGE_EXEC = env("TELEGRAM_BRIDGE_EXEC", "1") not in ("0", "false", "no")
# Mesaj duzenlemesi ayni isi ikinci kez tetiklemesin (GAP-04). Varsayilan kapali.
FOLLOW_EDITS = env("TELEGRAM_FOLLOW_EDITS", "0") not in ("0", "false", "no")
try:
OPENCODE_TIMEOUT = max(30, int(env("TELEGRAM_OPENCODE_TIMEOUT", "600")))
except ValueError:
OPENCODE_TIMEOUT = 600
try:
POLL_TIMEOUT = max(5, min(int(env("TELEGRAM_POLL_TIMEOUT", "30")), 50))
except ValueError:
POLL_TIMEOUT = 30
BOT_TOKEN = env("TELEGRAM_BOT_TOKEN", "")
_raw_allowed = env("TELEGRAM_ALLOWED_CHAT_IDS", "").replace(";", ",")
ALLOWED = {c.strip() for c in _raw_allowed.split(",") if c.strip()}
# Kullanici (user_id) izin listesi. Bos ise geriye donus: chat_id = user_id
# olan OZEL sohbetlerde eski davranis (user_id chat_id'ye bakilir).
#
# Canli inceleme bulgusu: yetki kontrolu tutarsizti. Metin mesajlari chat_id
# ile, inline butonlar user_id ile kontrol ediliyordu ve ikisi de ayni
# listeye bakiyordu. Grup sohbetinde metin gonderen HERKES makineyi
# kullanabiliyor, butonlar ise hic calismiyordu.
_raw_users = env("TELEGRAM_ALLOWED_USER_IDS", "").replace(";", ",")
ALLOWED_USERS = {c.strip() for c in _raw_users.split(",") if c.strip()}
DEFAULT_CHAT = env("TELEGRAM_DEFAULT_CHAT_ID", "").strip()
def _pkg_version(default="0.0.0"):
"""Tek surum kaynagi: paket kokundeki VERSION dosyasi (GAP-13)."""
p = os.path.normpath(os.path.join(BRIDGE_DIR, "..", "VERSION"))
try:
with open(p, encoding="utf-8") as f:
return f.read().strip() or default
except OSError:
return default
VERSION = _pkg_version()
# ---------- mailbox kuyruklari ----------
MAILBOX_DIR = os.environ.get("TEAM_MAILBOX_DIR") or os.path.join(PROJECT_DIR, ".opencode", "mailbox")
INBOX_FILE = os.path.join(MAILBOX_DIR, "telegram_inbox.json")
OUTBOX_FILE = os.path.join(MAILBOX_DIR, "telegram_outbox.json")
HEART_FILE = os.path.join(MAILBOX_DIR, "telegram_bridge.json")
def _load(path, default):
return _aj.load(path, default)
def _save(path, obj):
"""Atomik yazim (paylasilan `.tmp` yok). Oku-degistir-yaz icin `_aj.tx` kullan."""
_aj.save(path, obj)
def _now():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def log(*a):
"""Konsola yaz. Token maskesi buradan gecer.
Canli testte log dosyalari paylasiliyor; bir hata mesaji yanlislikla
bot token'ini icerse (orn. HTTP hata metni URL'den turesin) gizli kalmiyor.
"""
print("[%s]" % _now(), *[_outgoing(str(x)) for x in a], flush=True)
# ---------- Telegram API (stdlib) ----------
API = "https://api.telegram.org/bot%s/%s"
# Retry politikasi (GAP-06). Gecici sunucu hatalari (5xx) ve Telegram'in rate
# limit'i (429) gecicidir; 400/401/403/404/409 kalicidir ve TEKRARLANMAZ.
# 409 ozel: baska bir bridge ayni botta demektir, tekrar denemek durumu
# bulaniklastirir (main_loop bunu ozel mesajla yazar).
API_RETRY_STATUSES = (500, 502, 503, 504)
try:
API_MAX_RETRIES = max(0, int(env("TELEGRAM_API_MAX_RETRIES", "3")))
except ValueError:
API_MAX_RETRIES = 3
API_RETRY_BASE = 0.6
API_RETRY_CAP = 8.0
API_RETRY_AFTER_CAP = 30.0
def _read_error_body(e):
"""HTTPError govdesini JSON olarak oku (retry_after burada gelir).
Govde yalnizca BIR kez okunabilir; ayni istisna uzerinde birden fazla
karar verilirse (parse hatasi mi, not-modified mi) ilk okuma ikincisini
boslardi. Bu yuzden sonuc istisnaya cache'lenir.
"""
cached = getattr(e, "_tg_body", None)
if cached is not None:
return cached
body = {}
try:
raw = e.read()
except Exception:
raw = b""
if raw:
try:
d = json.loads(raw.decode("utf-8", "replace"))
body = d if isinstance(d, dict) else {}
except Exception:
body = {}
try:
e._tg_body = body
except Exception:
pass
return body
def _err_desc(e):
return str((_read_error_body(e) or {}).get("description") or "").lower()
def _retry_wait(code, body, attempt):
"""Tekrar bekleme suresi (sn). None = bu hata tekrar edilmemeli."""
if code == 429:
secs = 0.0
params = body.get("parameters")
if isinstance(params, dict):
try:
secs = float(params.get("retry_after") or 0)
except (TypeError, ValueError):
secs = 0.0
if secs <= 0:
m = re.search(r"retry after\s+(\d+)",
str(body.get("description") or ""), re.IGNORECASE)
secs = float(m.group(1)) if m else 0.0
return min(secs if secs > 0 else API_RETRY_BASE, API_RETRY_AFTER_CAP)
if code in API_RETRY_STATUSES:
return min(API_RETRY_BASE * (2 ** max(0, attempt)), API_RETRY_CAP)
return None
def api(method, payload=None, timeout=45, max_retries=None):
"""Telegram Bot API cagrisi. 429 ve gecici 5xx icin ustel backoff'lu
tekrar dener; 400/401/403/404/409 gibi kalici hatalarda tekrar etmez."""
url = API % (BOT_TOKEN, method)
data = json.dumps(payload or {}).encode("utf-8")
n = API_MAX_RETRIES if max_retries is None else max(0, int(max_retries))
attempt = 0
while True:
try:
req = urllib.request.Request(
url, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e: # 4xx/5xx (URLError'un alt sinifi)
if attempt >= n:
raise
wait = _retry_wait(e.code, _read_error_body(e), attempt)
if wait is None:
raise
attempt += 1
log("api %s -> HTTP %s, %.1f sn sonra tekrar (%d/%d)"
% (method, e.code, wait, attempt, n))
time.sleep(wait)
except OSError as e: # ag/soket hatasi: URLError, timeout, reset
if attempt >= n:
raise
attempt += 1
wait = min(API_RETRY_BASE * (2 ** (attempt - 1)), API_RETRY_CAP)
log("api %s ag hatasi: %s | %.1f sn sonra tekrar (%d/%d)"
% (method, str(e)[-120:], wait, attempt, n))
time.sleep(wait)
# ---------- opencode ciktisini coz ve temizle ----------
#
# Canli test bulgulari (2026-09-29, gercek Telegram):
# 1. Windows'ta opencode Turkce karakterleri Windows kodlamasiyla (cp1254)
# yaziyor. Salt UTF-8 ile okununca 'g' U+FFFD'ye duser ve cevapta
# "deste?Yini" gibi bozuk metin gorunuyordu (5 cevabin 5'i).
# 2. opencode renkli cikti uretiyor: ESC[0m, ESC[91m, ESC[1m... Bunlar ham
# gonderilince Telegram'da bozuk karakter olarak gorunuyordu.
# CSI (ESC[...son) + OSC (ESC]...son) + tek karakterli ESC dizileri
# SIRA ONEMLI: once OSC gelmeli. `[@-Z\\-_]` araligi `]` karakterini de
# icerdigi icin (backslash -> underscore araligi) once denirse ESC]'i
# yutar ve OSC govdesi artik eslesmez.
_ANSI_RE = re.compile(
r"\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)"
r"|[@-Z\\-_]"
r"|\[[0-?]*[ -/]*[@-~]"
r")")
try:
OUTPUT_ENCODINGS = [e.strip() for e in
env("TELEGRAM_OUTPUT_ENCODING",
"cp1254,cp1252,latin-1").split(",") if e.strip()]
except Exception: # noqa: BLE001
OUTPUT_ENCODINGS = ["cp1254", "cp1252", "latin-1"]
def decode_output(raw):
"""opencode bayt ciktisini metne cevir.
Once UTF-8 dener (dogru olan yol). Tutmazsa Turkce Windows kodlamasini
ve latin-1'i sirayla dener. Hicbiri tutmazsa cokertmeli coz - mesaj
kaybolmamali, en kotu birkac bozuk karakter gorunur.
"""
if not raw:
return ""
if isinstance(raw, str):
return raw
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
pass
for enc in OUTPUT_ENCODINGS:
try:
return raw.decode(enc)
except (UnicodeDecodeError, LookupError):
continue
return raw.decode("utf-8", "replace")
def clean_text(text):
"""Telegram'a gidecek metni hazirla: ANSI kontrol kodlarini at, NUL temizle."""
if not text:
return text
return _ANSI_RE.sub("", text).replace("\x00", "")
def _outgoing(text):
"""Telegram'a giden her metin bu darbogazdan gecer: temizle -> maskele."""
return _redact(clean_text(text))
# ---------- secret maskeleme (tum giden Telegram metni buradan gecer) ----------
_REDACT_PATTERNS = [
r"sk-[A-Za-z0-9\-_]{16,}",
r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}",
r"github_pat_[A-Za-z0-9_]{16,}",
r"AKIA[0-9A-Z]{16}",
r"xox[bpas]-[A-Za-z0-9\-]+",
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
]
def _redact(text):
if not text:
return text
out = text
for pat in _REDACT_PATTERNS:
out = re.sub(pat, "[REDACTED]", out)
tok = BOT_TOKEN.strip()
if tok and len(tok) > 8 and tok in out:
out = out.replace(tok, "[REDACTED-BOT-TOKEN]")
return out
# ---------- audit log (kim, ne istedi, ne oldu) ----------
AUDIT_FILE = os.path.join(MAILBOX_DIR, "telegram_audit.log")
AUDIT_MAX_BYTES = 100 * 1024 # PLAN.md TG1-2 ile ayni (GAP-14)
def _audit(chat_id, kind, summary, extra=None):
try:
os.makedirs(MAILBOX_DIR, exist_ok=True)
rec = {"ts": _now(), "chat": str(chat_id), "kind": kind,
"summary": _outgoing((summary or "")[:500])}
if extra:
rec.update(extra)
line = json.dumps(rec, ensure_ascii=False) + "\n"
if os.path.exists(AUDIT_FILE) and os.path.getsize(AUDIT_FILE) > AUDIT_MAX_BYTES:
with open(AUDIT_FILE, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
with open(AUDIT_FILE, "w", encoding="utf-8") as f:
f.writelines(lines[len(lines) // 2:])
with open(AUDIT_FILE, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass
def send_message_id(chat_id, text):
"""Ilk parcayi gonderip Telegram message_id doner (duzenleme/callback icin)."""
text = _outgoing(text)
first = (text[:4000] or "")
res = api("sendMessage", {"chat_id": chat_id, "text": first})
mid = ((res or {}).get("result") or {}).get("message_id")
rest = text[4000:]
for i in range(0, len(rest), 4000):
api("sendMessage", {"chat_id": chat_id, "text": rest[i:i + 4000]})
time.sleep(0.4)
return mid
def edit_message(chat_id, message_id, text):
try:
api("editMessageText", {"chat_id": chat_id, "message_id": message_id,
"text": _outgoing(text)[:4000]})
return True
except Exception:
return False
def answer_callback(callback_id, text=""):
try:
api("answerCallbackQuery", {"callback_query_id": callback_id, "text": text[:200]})
except Exception:
pass
CALLBACK_DATA_MAX = 64 # Telegram'in kesin limiti; asarsa mesaj gonderilmez
def _fit_callback(data):
"""callback_data 64 bayta sigmiyorsa kisalt (Telegram mesaji reddeder)."""
raw = (data or "").encode("utf-8")
if len(raw) <= CALLBACK_DATA_MAX:
return data or ""
return raw[:CALLBACK_DATA_MAX - 3].decode("utf-8", "ignore") + "..."
def button_grid(buttons, cols=2):
"""Butonlari `cols` sutunlu izarga olusturur; bosluk birakilmaz.
Onceki surumde butonlar tek satira diziliyordu: 8 secenekli bir soruda
Telegram 8 butonu bir satira sigdiromaya calisiyordu.
"""
rows, cur = [], []
for b in buttons:
cur.append({"text": b[0], "callback_data": _fit_callback(b[1])})
if len(cur) >= max(1, cols):
rows.append(cur)
cur = []
if cur:
rows.append(cur)
return rows
def send_buttons(chat_id, text, buttons, reply_to=None, cols=2, cancel=None):
"""Inline butonlu mesaj.
buttons: [(label, callback_data)]
cancel: (label, callback_data) -> en altta tek satira eklenir
Donus: message_id
"""
rows = button_grid(buttons, cols=cols)
if cancel:
rows.append([{"text": cancel[0], "callback_data": _fit_callback(cancel[1])}])
payload = {"chat_id": chat_id, "text": _outgoing(text)[:4000],
"reply_markup": {"inline_keyboard": rows}}
if reply_to is not None:
payload["reply_to_message_id"] = reply_to
res = api("sendMessage", payload)
return ((res or {}).get("result") or {}).get("message_id")
pending_perms = {}
pending_forms = {}
_perm_seq = [0]
def _prune_pending():
now = time.time()
for store in (pending_perms, pending_forms):
for k in [k for k, v in store.items() if now - v.get("ts", 0) > 1800]:
store.pop(k, None)
def send_message(chat_id, text, reply_to=None):
"""4000'luk parcalarla gonderir. Donus: gonderilen parca sayisi."""
text = _outgoing(text)
chunks = [text[i:i + 4000] for i in range(0, len(text), 4000)] or [""]
n = 0
for ch in chunks:
payload = {"chat_id": chat_id, "text": ch}
if reply_to is not None and n == 0:
payload["reply_to_message_id"] = reply_to
api("sendMessage", payload)
n += 1
if len(chunks) > 1:
time.sleep(0.4)
return n
def send_action(chat_id):
try:
api("sendChatAction", {"chat_id": chat_id, "action": "typing"}, timeout=10)
except Exception:
pass
# ---------- Markdown gonderimi (bicimleme + geri dusus zinciri) ----------
# opencode Markdown dondurur; duz metin gonderilince isaretler ham gorunur.
# 0 ile tamamen kapatilabilir (bicimleme hatasi sorunu olursa ilk bakacak yer).
MARKDOWN_ON = env("TELEGRAM_MARKDOWN", "1") not in ("0", "false", "no")
_PARSE_ERR_MARKERS = (
"can't parse", "can't find end of the entity", "entity beginning",
"entity url", "wrong http url", "url host is empty", "unsupported start tag",
)
def _is_parse_error(exc):
"""Telegram bicimlemeyi mi reddetti? (400 + parse aciklamasi)"""
if getattr(exc, "code", None) != 400:
return False
desc = _err_desc(exc)
return any(m in desc for m in _PARSE_ERR_MARKERS)
def _md_pieces(text, limit=3900):
"""(metin, parse_mode) listesi.
Markdown kapaliysa duz parcalama. Aciksa blok bazli paketleme: kod
blogu asla karakter sayisiyla kesilmez (bkz. md2.pack).
"""
if not text:
return []
if not MARKDOWN_ON:
return [(text[i:i + limit], None) for i in range(0, len(text), limit)]
return [(c["text"], c["parse_mode"]) for c in _md2.pack(text, limit=limit)] or [(text, None)]
def _with_footer(text, parse_mode, footer):
"""`[TG-001]` gibi etiketi ekler.
MarkdownV2'de `[` `]` ayrac oldugu icin koseli parantez de kacislanir.
"""
if not footer:
return text
return (text + "\n\n" + _md2.render_text("[%s]" % footer)) if parse_mode \
else "%s\n\n[%s]" % (text, footer)
def _send_chunk(chat_id, text, parse_mode=None, reply_to=None):
"""Tek mesaj gonderir. Bicim reddedilirse duz metne duser.
Donu: message_id. Bu, bicimleme hatasinin mesaji KAYBETTIRMESINI saglar:
Telegram 400 + parse aciklamasi dondugunde ayni icerik parse_mode'suz
tekrar gonderilir.
"""
payload = {"chat_id": chat_id, "text": text}
if reply_to is not None:
payload["reply_to_message_id"] = reply_to
if parse_mode:
payload["parse_mode"] = parse_mode
try:
res = api("sendMessage", payload)
except urllib.error.HTTPError as e:
if not parse_mode or not _is_parse_error(e):
raise
log("markdown parse hatasi -> duz metne dusuluyor")
payload.pop("parse_mode")
payload["text"] = _md2.unescape(text)
res = api("sendMessage", payload)
return ((res or {}).get("result") or {}).get("message_id")
def _is_not_modified(exc):
"""Telegram ayni icerikle duzenlemeyi 400 ile reddeder.
Bu bir hata degil, hedef duruma ulasildi demektir. Yanlisca basarisiz
sayilirsa ayni mesaj ikinci kez gonderilir (mukerrer cevap).
"""
if getattr(exc, "code", None) != 400:
return False
return "message is not modified" in _err_desc(exc)
def _edit_chunk(chat_id, message_id, text, parse_mode=None):
"""`editMessageText` + ayni geri dusus zinciri."""
payload = {"chat_id": chat_id, "message_id": message_id, "text": text}
if parse_mode:
payload["parse_mode"] = parse_mode
try:
api("editMessageText", payload)
return True
except urllib.error.HTTPError as e:
if _is_not_modified(e):
return True
if not parse_mode or not _is_parse_error(e):
return False
payload.pop("parse_mode")
payload["text"] = _md2.unescape(text)
try:
api("editMessageText", payload)
return True
except urllib.error.HTTPError as e2:
return _is_not_modified(e2)
except Exception: # noqa: BLE001
return False
except Exception: # noqa: BLE001
return False
# ---------- alt klavye (reply keyboard) ----------
#
# Karar (2026-09-29): kalici klavye KAPALI baslar. Kalici klavye yazan alanin
# hemen ustune yerlesir ve "haricinde bir sey de yazabilir" degildir; normal
# sohbet gibi kullanmak isteyenleri engeller. `/klavye` ile acilir, kapatilir.
#
# Buton etiketleri tek yerde. TIKLANAN buton metni opencode'a gitmemeli,
# once komuta donusdurulur (KEYBOARD_ACTIONS).
KEYBOARD_ROWS = [
["Durum", "Model", "Oturum"],
["Kuyruk", "Yeni", "Durdur"],
]
KEYBOARD_ACTIONS = {
"Durum": "/durum",
"Model": "/model",
"Oturum": "/sessions",
"Kuyruk": "/kuyruk",
"Yeni": "/sessions yeni",
"Durdur": "/abort",
}
def keyboard_markup():
"""Kalici alt klavye (2 satir x 3 buton)."""
return {
"keyboard": [[{"text": lbl} for lbl in row] for row in KEYBOARD_ROWS],
"resize_keyboard": True,
"is_persistent": True,
"input_field_placeholder": "bir is yaz veya bir dugmeye dokun",
}
def keyboard_remove_markup():
return {"remove_keyboard": True}
def cmd_keyboard(text, chat_id):
"""`/klavye` — alt klavye ac / kapat. Donus: (cevap, calistirildi_mi)."""
arg = text.strip().split()[1].lower() if len(text.strip().split()) > 1 else ""
if arg in ("kapat", "off", "kapatis", "hayir", "no"):
try:
api("sendMessage", {"chat_id": str(chat_id), "text": "Alt klavye kapatildi.",
"reply_markup": keyboard_remove_markup()})
return "Alt klavye kapatildi.", False
except Exception as e: # noqa: BLE001
return "Kapatilamadi: %s" % str(e)[-150:], False
if arg in ("ac", "on", "acil", "evet", "yes", ""):
model = _chat_model(chat_id) or "(varsayilan)"
try:
api("sendMessage", {
"chat_id": str(chat_id),
"text": ("Alt klavye acik. Dugmeler sana hizli kisa yol verir; "
"yine de istedigin yere yazabilirsin.\n"
"Aktif model: %s" % model),
"reply_markup": keyboard_markup()})
return "Alt klavye acik.", False
except Exception as e: # noqa: BLE001
return "Acilamadi: %s" % str(e)[-150:], False
return ("Kullanim:\n"
" /klavye alt klavye ac\n"
" /klavye kapat alt klavye kapat\n"
" /kuyruk bekleyen mesajlar\n"
" /kuyruk temizle kuyrugu bosalt"), False
# ---------- mesaj kuyrugu (mesgulken gelen mesajlar) ----------
#
# Canli testte mesgulken gelen mesaj REDDEDILIYORDU; kullanici telifte
# gonderip unutuyordu. Artik kuyruga alinir, is bitince sirayla calisir ve
# `/kuyruk` ile gorulup temizlenebilir.
MAX_QUEUED = max(1, int(env("TELEGRAM_MAX_QUEUED", "5") or 5))
QUEUE_FILE = os.path.join(MAILBOX_DIR, "telegram_queue.json")
def _queue_all():
st = _load(QUEUE_FILE, {})
return st if isinstance(st, dict) else {}
def _queue_list(chat_id):
items = _queue_all().get(str(chat_id))
return list(items) if isinstance(items, list) else []
def _queue_len(chat_id):
return len(_queue_list(chat_id))
def _queue_push(chat_id, label, text, files=None):
"""Kuyruga ekle. Donus: (kabul_edildi, aciklama)."""
with _aj.tx(QUEUE_FILE):
st = _queue_all()
items = st.get(str(chat_id))
items = list(items) if isinstance(items, list) else []
if len(items) >= MAX_QUEUED:
st[str(chat_id)] = items
_save(QUEUE_FILE, st)
return False, ("Kuyruk dolu (%d mesaj). Bitmesini bekle ya da "
"/kuyruk temizle." % MAX_QUEUED)
items.append({"text": text, "from": label, "files": files or [],
"ts": time.time()})
st[str(chat_id)] = items
_save(QUEUE_FILE, st)
return True, "Kuyruga alindi (%d/%d)." % (len(items), MAX_QUEUED)
def _queue_drop(chat_id, text):
"""Kuyruktan tek bir mesaji cikar (kullanici geri aldi)."""
with _aj.tx(QUEUE_FILE):
st = _queue_all()
items = st.get(str(chat_id))
items = list(items) if isinstance(items, list) else []
items = [m for m in items if m.get("text") != text]
if items:
st[str(chat_id)] = items
else:
st.pop(str(chat_id), None)
_save(QUEUE_FILE, st)
return True
def _queue_clear(chat_id):
with _aj.tx(QUEUE_FILE):
st = _queue_all()
st.pop(str(chat_id), None)
_save(QUEUE_FILE, st)
def _queue_drain(chat_id, spawn):
"""Kuyrugu sirayla bosalt; spawn basarisiz olursa veya kuyruk degisirse DURUR.
`spawn(chat_id, label, text, files)` -> bool. Donus: baslatilan adet.
"""
n = 0
while True:
pending = _queue_list(chat_id)
if not pending:
return n
item = pending[0]
if not spawn(chat_id, item.get("from", "queue"),
item.get("text", ""), item.get("files")):
return n
head = _queue_list(chat_id)
if not head or head[0].get("text") != item.get("text"):
# Kuyrugun basi degisti: kullanici mesaji geri aldi ya da
# temizledi. Donguyu KIR - isleri arka arkaya calistirma.
return n
_queue_drop(chat_id, item.get("text", ""))
n += 1
def cmd_queue(text, chat_id):
"""`/kuyruk` — bekleyen mesajlari goster / temizle."""
arg = text.strip().split()[1].lower() if len(text.strip().split()) > 1 else ""
if arg in ("temizle", "sil", "bosalt", "clear", "iptal"):
n = _queue_len(chat_id)
_queue_clear(chat_id)
return ("Kuyruk temizlendi (%d mesaj silindi)." % n) if n else "Kuyruk zaten bos.", False
items = _queue_list(chat_id)
if not items:
return ("Kuyruk bos (%d/%d). Mesgulken gonderdigin mesajlar buraya "
"alnir ve sirayla calisir." % (0, MAX_QUEUED)), False
out = ["Kuyruk: %d/%d mesaj" % (len(items), MAX_QUEUED)]
for i, m in enumerate(items, 1):
t = (m.get("text") or "").replace("\n", " ")
out.append(" %d. (%s) %s" % (i, _tshort((m.get("ts") or 0) * 1000), t[:80]))
out.append("")
out.append("Is bitince sirayla calisir. Temizle: /kuyruk temizle")
return "\n".join(out), False
def is_allowed(chat_id, user_id=None):
"""Geriye donus uyumu: tek arguman gelirse chat_id kontrolu yapilir.
Tercih edilen yeni arayuz `is_allowed_chat` / `is_allowed_user`.
"""
return is_allowed_chat(chat_id, user_id)
def is_allowed_chat(chat_id, user_id=None):
"""Bu sohbetteki kullanicinin komut gonderme yetkisi var mi?
- Chat `ALLOWED` (chat id) listesinde olmali.
- `ALLOWED_USER_IDS` doluysa kullanicida o listede olmali. Boylece grup
sohbetinde listenin disindaki kisi makineyi kullanamaz.
- `ALLOWED_USER_IDS` bossa geriye donus: ozel sohbetlerde user_id == chat_id
oldugu icin onceki davranis korunur; grupta ise yabanci reddedilir.
"""
if str(chat_id) not in ALLOWED:
return False
if user_id is None:
return True
uid = str(user_id)
if ALLOWED_USERS:
return uid in ALLOWED_USERS
# Kullanici listesi tanimli degilse: ayni sohbetin uyesi olmak yeterli
# (ozel sohbet) ya da sohbet ID'si user_id ile ayni (geriye donus).
return uid == str(chat_id) or _group_member_ok(chat_id, uid)
def is_allowed_user(user_id):
"""Inline buton icin: kullanici yetkisi.
`ALLOWED_USER_IDS` doluysa sadece o kullanicilar. Bos ise geriye donus:
user_id, izinli chat id'leriyle eslesiyorsa (ozel sohbet) gecer.
"""
uid = str(user_id)
if ALLOWED_USERS:
return uid in ALLOWED_USERS
return uid in ALLOWED
def _group_member_ok(chat_id, user_id):
"""Grup sohbetinde `ALLOWED_USER_IDS` tanimli degilse kimse giremez.
Guvenli varsayilan: izin listesinde acikca yazilmayan kisi makineyi
calistiramaz. Grubu kullanmak icin `TELEGRAM_ALLOWED_USER_IDS` zorunludur.
"""
return False
# ---------- inbox/outbox ----------
def inbox_add(chat_id, from_label, text, handled_by="", response_preview=""):
with _aj.tx(INBOX_FILE):
st = _load(INBOX_FILE, {"seq": 0, "messages": []})
st["seq"] += 1
entry = {
"id": "TG-%03d" % st["seq"],
"ts": _now(),
"chat_id": str(chat_id),
"from": from_label,
"text": text[:4000],
"status": "READ" if handled_by else "UNREAD",
"handled_by": handled_by,
"response_preview": (response_preview or "")[:500],
}
st["messages"].append(entry)
st["messages"] = st["messages"][-300:]
_save(INBOX_FILE, st)
return entry["id"]
def _outbox_dests(m):
"""Kuyruk kaydinin gonderilecegi hedefler. Bos liste = teslim edilemez."""
to = str(m.get("to") or "default")
if to in ("default", ""):
return [DEFAULT_CHAT] if DEFAULT_CHAT else []
if to == "broadcast":
return sorted(ALLOWED) if ALLOWED else []
return [to]
def outbox_recover(max_age=300):
"""Crash/kill sonrasi SENDING'de kalan kayitlari QUEUED'a al.
Bridge tek ornek calistigi icin baslangicta SENDING gormesi = o surec
olurken takilip kalmis demektir. `max_age` ile yeni baslayan baska bir
surecin isini ezmeden korunur.
"""
now = time.time()
n = 0
with _aj.tx(OUTBOX_FILE):
st = _load(OUTBOX_FILE, {"seq": 0, "messages": []})
for m in st.get("messages", []):
if m.get("status") != "SENDING":
continue
if now - float(m.get("sending_since") or 0) < max_age:
continue
m["status"] = "QUEUED"
m.pop("sending_since", None)
m["attempts"] = int(m.get("attempts", 0)) + 1
m["next_try"] = now
n += 1
if n:
_save(OUTBOX_FILE, st)
return n
def outbox_flush():
"""QUEUED kayitlari gonderir; basarisizlar 4 denemeye kadar ertelenir.
Uc asamali: (1) `tx` icinde SENDING'e sahiplen, (2) ag islemini kilit
disinda yap, (3) sonucu yine `tx` icinde yaz. Boylece uc farkli yerden
(poll loop, mesaj sonrasi, worker) cagrildiginda ayni kayit iki kez
Telegram'a gitmez (GAP-03). Donus: (gonderilen, hata).
"""
now = time.time()
sent, failed = 0, 0
# --- asama 1: sahiplen (kilitli, ag yok) ---
claimed = []
with _aj.tx(OUTBOX_FILE):
st = _load(OUTBOX_FILE, {"seq": 0, "messages": []})
for m in st.get("messages", []):
if m.get("status") != "QUEUED" or m.get("next_try", 0) > now:
continue
if not _outbox_dests(m):
m["status"] = "FAILED"
m["error"] = "hedef yok (TELEGRAM_DEFAULT_CHAT_ID bos ya da allowlist bos)"
failed += 1
continue
if DIGEST_ON and str(m.get("to") or "default") in ("default", ""):
_digest_push(m)
m["status"] = "SENT"
m["error"] = "digest'e alindi"
sent += 1
continue
m["status"] = "SENDING"
m["sending_since"] = now
claimed.append({"id": m.get("id"), "to": m.get("to"),
"text": m.get("text", "")})
_save(OUTBOX_FILE, st)
# --- asama 2: gonder (kilit disinda) ---
results = {}
for c in claimed:
ok_all, err = True, ""
for d in _outbox_dests(c):
if d not in ALLOWED:
ok_all = False
err = "hedef allowlist disi: %s" % d
continue
try:
send_message(d, c["text"])
except Exception as e: # noqa: BLE001
ok_all = False
err = str(e)[-300:]
results[c["id"]] = (ok_all, err)
# --- asama 3: sonucu yaz (kilitli) ---
if results:
with _aj.tx(OUTBOX_FILE):
st = _load(OUTBOX_FILE, {"seq": 0, "messages": []})
for m in st.get("messages", []):
res = results.get(m.get("id"))
if res is None or m.get("status") != "SENDING":
continue
ok_all, err = res
m.pop("sending_since", None)
if ok_all:
m["status"] = "SENT"
m.pop("attempts", None)
m.pop("next_try", None)
m.pop("error", None)
sent += 1
continue
m["error"] = err
att = int(m.get("attempts", 0)) + 1
m["attempts"] = att
if att >= 4:
m["status"] = "FAILED"
failed += 1
else:
m["status"] = "QUEUED"
m["next_try"] = time.time() + 60 * att
_save(OUTBOX_FILE, st)
return sent, failed
def heartbeat(offset, extra=None):
hb = {"ts": _now(), "epoch": time.time(), "offset": offset,
"project_dir": PROJECT_DIR, "exec": BRIDGE_EXEC,
"agent": OPENCODE_AGENT, "session_prefix": SESSION_PREFIX}
if extra:
hb.update(extra)
_save(HEART_FILE, hb)
# ---------- dosya okuyucular (yerel komutlar) ----------
def _head(path, n=30):
try:
with open(path, "r", encoding="utf-8") as f:
return "".join(f.readlines()[:n])
except OSError:
return "(dosya yok: %s)" % os.path.basename(path)
def cmd_status():
s = _head(os.path.join(PROJECT_DIR, "SESSION_STATE.md"), 25).strip()
t = _head(os.path.join(PROJECT_DIR, "TODO.md"), 20).strip()
return "SESSION_STATE\n%s\n\nTODO (ilk 20 satir)\n%s" % (s, t)
def cmd_roster():
st = _load(os.path.join(MAILBOX_DIR, "inbox.json"), {"messages": [], "heart": {}})
heart = st.get("heart", {})
if not heart:
return "roster bos (henuz heartbeat yok)."
now = time.time()
lines = []