@alfe.ai/openclaw-telegram 0.0.1
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.
- package/README.md +41 -0
- package/bin/alfe-telegram.mjs +180 -0
- package/dist/index.cjs +4 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/plugin.cjs +503 -0
- package/dist/plugin.d.cts +58 -0
- package/dist/plugin.d.ts +58 -0
- package/dist/plugin.js +503 -0
- package/dist/telegram-bridge.cjs +300 -0
- package/dist/telegram-bridge.d.cts +72 -0
- package/dist/telegram-bridge.d.ts +72 -0
- package/dist/telegram-bridge.js +289 -0
- package/openclaw.plugin.json +21 -0
- package/package.json +65 -0
- package/python/bridge.py +633 -0
package/python/bridge.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local Telethon bridge for @alfe.ai/openclaw-telegram.
|
|
3
|
+
|
|
4
|
+
The process speaks bounded JSONL over stdin/stdout. Telegram credentials and
|
|
5
|
+
session data are read only from the owner-only state directory supplied by the
|
|
6
|
+
Node plugin. Never print the session, API hash, phone, code, or password.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import getpass
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
STATE_VERSION = 1
|
|
25
|
+
MAX_COMMAND_BYTES = 1024 * 1024
|
|
26
|
+
MAX_PENDING = 2_000
|
|
27
|
+
MAX_RECENT = 2_000
|
|
28
|
+
MAX_DIALOG_SCAN = 5_000
|
|
29
|
+
MAX_TEXT_CHARS = 32_000
|
|
30
|
+
API_HASH_RE = re.compile(r"^[0-9a-fA-F]{32}$")
|
|
31
|
+
SAFE_METHODS = {
|
|
32
|
+
"status",
|
|
33
|
+
"list_dialogs",
|
|
34
|
+
"list_subscriptions",
|
|
35
|
+
"subscribe",
|
|
36
|
+
"unsubscribe",
|
|
37
|
+
"ack",
|
|
38
|
+
"shutdown",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def emit(payload: dict[str, Any]) -> None:
|
|
43
|
+
sys.stdout.write(json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + "\n")
|
|
44
|
+
sys.stdout.flush()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fatal(code: str) -> None:
|
|
48
|
+
emit({"type": "fatal", "code": code})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure_private_dir(path: Path) -> None:
|
|
52
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
53
|
+
try:
|
|
54
|
+
path.chmod(0o700)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def atomic_json(path: Path, value: dict[str, Any]) -> None:
|
|
60
|
+
ensure_private_dir(path.parent)
|
|
61
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
62
|
+
try:
|
|
63
|
+
os.fchmod(descriptor, 0o600)
|
|
64
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
65
|
+
json.dump(value, handle, ensure_ascii=False, separators=(",", ":"))
|
|
66
|
+
handle.flush()
|
|
67
|
+
os.fsync(handle.fileno())
|
|
68
|
+
os.replace(temporary, path)
|
|
69
|
+
try:
|
|
70
|
+
path.chmod(0o600)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
except Exception:
|
|
74
|
+
try:
|
|
75
|
+
os.unlink(temporary)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
82
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
83
|
+
value = json.load(handle)
|
|
84
|
+
if not isinstance(value, dict):
|
|
85
|
+
raise ValueError("invalid_json_object")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_config(state_dir: Path) -> tuple[int, str]:
|
|
90
|
+
value = read_json(state_dir / "config.json")
|
|
91
|
+
api_id = value.get("api_id")
|
|
92
|
+
api_hash = value.get("api_hash")
|
|
93
|
+
if not isinstance(api_id, int) or api_id <= 0:
|
|
94
|
+
raise ValueError("invalid_config")
|
|
95
|
+
if not isinstance(api_hash, str) or API_HASH_RE.fullmatch(api_hash) is None:
|
|
96
|
+
raise ValueError("invalid_config")
|
|
97
|
+
return api_id, api_hash
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def default_state() -> dict[str, Any]:
|
|
101
|
+
return {
|
|
102
|
+
"version": STATE_VERSION,
|
|
103
|
+
"subscriptions": {},
|
|
104
|
+
"pending": [],
|
|
105
|
+
"recent": [],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def valid_chat_kind(value: Any) -> bool:
|
|
110
|
+
return value in {"private", "group", "channel"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def valid_pending_item(item: Any) -> bool:
|
|
114
|
+
if not isinstance(item, dict):
|
|
115
|
+
return False
|
|
116
|
+
required_strings = (
|
|
117
|
+
"deliveryId",
|
|
118
|
+
"messageId",
|
|
119
|
+
"chatId",
|
|
120
|
+
"chatTitle",
|
|
121
|
+
"chatKind",
|
|
122
|
+
"senderId",
|
|
123
|
+
"senderName",
|
|
124
|
+
"text",
|
|
125
|
+
"timestamp",
|
|
126
|
+
)
|
|
127
|
+
if any(not isinstance(item.get(key), str) for key in required_strings):
|
|
128
|
+
return False
|
|
129
|
+
if not valid_chat_kind(item.get("chatKind")):
|
|
130
|
+
return False
|
|
131
|
+
text = item.get("text")
|
|
132
|
+
if not text or len(text) > MAX_TEXT_CHARS:
|
|
133
|
+
return False
|
|
134
|
+
username = item.get("senderUsername")
|
|
135
|
+
return username is None or isinstance(username, str)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_state(path: Path) -> dict[str, Any]:
|
|
139
|
+
try:
|
|
140
|
+
value = read_json(path)
|
|
141
|
+
except FileNotFoundError:
|
|
142
|
+
return default_state()
|
|
143
|
+
if value.get("version") != STATE_VERSION:
|
|
144
|
+
raise ValueError("invalid_state")
|
|
145
|
+
subscriptions = value.get("subscriptions")
|
|
146
|
+
pending = value.get("pending")
|
|
147
|
+
recent = value.get("recent")
|
|
148
|
+
if not isinstance(subscriptions, dict) or not isinstance(pending, list) or not isinstance(recent, list):
|
|
149
|
+
raise ValueError("invalid_state")
|
|
150
|
+
for key, subscription in subscriptions.items():
|
|
151
|
+
if not isinstance(key, str) or not isinstance(subscription, dict):
|
|
152
|
+
raise ValueError("invalid_state")
|
|
153
|
+
if subscription.get("chat_id") != key or not isinstance(subscription.get("title"), str):
|
|
154
|
+
raise ValueError("invalid_state")
|
|
155
|
+
if not valid_chat_kind(subscription.get("kind")):
|
|
156
|
+
raise ValueError("invalid_state")
|
|
157
|
+
if len(pending) > MAX_PENDING or len(recent) > MAX_RECENT:
|
|
158
|
+
raise ValueError("invalid_state")
|
|
159
|
+
if any(not valid_pending_item(item) for item in pending):
|
|
160
|
+
raise ValueError("invalid_state")
|
|
161
|
+
if any(not isinstance(item, str) for item in recent):
|
|
162
|
+
raise ValueError("invalid_state")
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def entity_kind(dialog: Any) -> str:
|
|
167
|
+
if bool(getattr(dialog, "is_user", False)):
|
|
168
|
+
return "private"
|
|
169
|
+
if bool(getattr(dialog, "is_channel", False)) and not bool(getattr(dialog, "is_group", False)):
|
|
170
|
+
return "channel"
|
|
171
|
+
return "group"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def display_name(entity: Any, fallback: str) -> str:
|
|
175
|
+
first = getattr(entity, "first_name", None)
|
|
176
|
+
last = getattr(entity, "last_name", None)
|
|
177
|
+
title = " ".join(part for part in (first, last) if isinstance(part, str) and part.strip())
|
|
178
|
+
if title:
|
|
179
|
+
return title[:256]
|
|
180
|
+
name = getattr(entity, "title", None)
|
|
181
|
+
if isinstance(name, str) and name.strip():
|
|
182
|
+
return name[:256]
|
|
183
|
+
username = getattr(entity, "username", None)
|
|
184
|
+
if isinstance(username, str) and username.strip():
|
|
185
|
+
return username[:256]
|
|
186
|
+
return fallback[:256]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def iso_timestamp(value: Any) -> str:
|
|
190
|
+
if isinstance(value, datetime):
|
|
191
|
+
if value.tzinfo is None:
|
|
192
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
193
|
+
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
194
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Bridge:
|
|
198
|
+
def __init__(self, state_dir: Path, client: Any, me: Any) -> None:
|
|
199
|
+
self.state_dir = state_dir
|
|
200
|
+
self.state_path = state_dir / "state.json"
|
|
201
|
+
self.state = load_state(self.state_path)
|
|
202
|
+
self.client = client
|
|
203
|
+
self.me = me
|
|
204
|
+
self.shutting_down = False
|
|
205
|
+
self.enqueue_lock = asyncio.Lock()
|
|
206
|
+
self.queue_full_reported = False
|
|
207
|
+
|
|
208
|
+
def save(self) -> None:
|
|
209
|
+
atomic_json(self.state_path, self.state)
|
|
210
|
+
|
|
211
|
+
async def handle_message(self, event: Any) -> None:
|
|
212
|
+
if bool(getattr(event, "out", False)):
|
|
213
|
+
return
|
|
214
|
+
chat_id_raw = getattr(event, "chat_id", None)
|
|
215
|
+
if chat_id_raw is None:
|
|
216
|
+
return
|
|
217
|
+
chat_id = str(chat_id_raw)
|
|
218
|
+
subscription = self.state["subscriptions"].get(chat_id)
|
|
219
|
+
if not isinstance(subscription, dict):
|
|
220
|
+
return
|
|
221
|
+
raw_text = getattr(event, "raw_text", "")
|
|
222
|
+
if not isinstance(raw_text, str) or not raw_text.strip():
|
|
223
|
+
return
|
|
224
|
+
text = raw_text.strip()[:MAX_TEXT_CHARS]
|
|
225
|
+
message = getattr(event, "message", None)
|
|
226
|
+
message_id = str(getattr(message, "id", ""))
|
|
227
|
+
if not message_id:
|
|
228
|
+
return
|
|
229
|
+
delivery_id = f"{chat_id}:{message_id}"
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
sender = await event.get_sender()
|
|
233
|
+
except Exception:
|
|
234
|
+
sender = None
|
|
235
|
+
sender_id = str(getattr(event, "sender_id", "unknown"))
|
|
236
|
+
sender_name = display_name(sender, sender_id) if sender is not None else sender_id
|
|
237
|
+
sender_username = getattr(sender, "username", None) if sender is not None else None
|
|
238
|
+
item: dict[str, Any] = {
|
|
239
|
+
"deliveryId": delivery_id,
|
|
240
|
+
"messageId": message_id,
|
|
241
|
+
"chatId": chat_id,
|
|
242
|
+
"chatTitle": str(subscription["title"])[:256],
|
|
243
|
+
"chatKind": str(subscription["kind"]),
|
|
244
|
+
"senderId": sender_id[:128],
|
|
245
|
+
"senderName": sender_name,
|
|
246
|
+
"text": text,
|
|
247
|
+
"timestamp": iso_timestamp(getattr(message, "date", None)),
|
|
248
|
+
}
|
|
249
|
+
if isinstance(sender_username, str) and sender_username:
|
|
250
|
+
item["senderUsername"] = sender_username[:128]
|
|
251
|
+
async with self.enqueue_lock:
|
|
252
|
+
pending = self.state["pending"]
|
|
253
|
+
if len(pending) >= MAX_PENDING:
|
|
254
|
+
if not self.queue_full_reported:
|
|
255
|
+
emit({"type": "health", "code": "pending_queue_full"})
|
|
256
|
+
self.queue_full_reported = True
|
|
257
|
+
return
|
|
258
|
+
if self.shutting_down:
|
|
259
|
+
return
|
|
260
|
+
self.queue_full_reported = False
|
|
261
|
+
if chat_id not in self.state["subscriptions"]:
|
|
262
|
+
return
|
|
263
|
+
recent = self.state["recent"]
|
|
264
|
+
if delivery_id in recent or any(entry.get("deliveryId") == delivery_id for entry in pending):
|
|
265
|
+
return
|
|
266
|
+
pending.append(item)
|
|
267
|
+
self.save()
|
|
268
|
+
emit({"type": "event", "event": item})
|
|
269
|
+
|
|
270
|
+
async def list_dialogs(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
271
|
+
offset = bounded_int(params.get("offset"), 0, MAX_DIALOG_SCAN, 0)
|
|
272
|
+
limit = bounded_int(params.get("limit"), 1, 100, 50)
|
|
273
|
+
query_raw = params.get("query")
|
|
274
|
+
query = query_raw.strip().lower() if isinstance(query_raw, str) else ""
|
|
275
|
+
kinds_raw = params.get("kinds")
|
|
276
|
+
kinds = set(kinds_raw) if isinstance(kinds_raw, list) else set()
|
|
277
|
+
if any(not valid_chat_kind(kind) for kind in kinds):
|
|
278
|
+
raise ValueError("invalid_kinds")
|
|
279
|
+
|
|
280
|
+
matched = 0
|
|
281
|
+
scanned = 0
|
|
282
|
+
dialogs: list[dict[str, Any]] = []
|
|
283
|
+
async for dialog in self.client.iter_dialogs():
|
|
284
|
+
scanned += 1
|
|
285
|
+
if scanned > MAX_DIALOG_SCAN:
|
|
286
|
+
break
|
|
287
|
+
kind = entity_kind(dialog)
|
|
288
|
+
if kinds and kind not in kinds:
|
|
289
|
+
continue
|
|
290
|
+
title = str(getattr(dialog, "name", "") or display_name(dialog.entity, str(dialog.id)))[:256]
|
|
291
|
+
username = getattr(dialog.entity, "username", None)
|
|
292
|
+
haystack = f"{title} {username or ''}".lower()
|
|
293
|
+
if query and query not in haystack:
|
|
294
|
+
continue
|
|
295
|
+
if matched < offset:
|
|
296
|
+
matched += 1
|
|
297
|
+
continue
|
|
298
|
+
if len(dialogs) >= limit:
|
|
299
|
+
break
|
|
300
|
+
chat_id = str(dialog.id)
|
|
301
|
+
item: dict[str, Any] = {
|
|
302
|
+
"chatId": chat_id,
|
|
303
|
+
"title": title,
|
|
304
|
+
"kind": kind,
|
|
305
|
+
"subscribed": chat_id in self.state["subscriptions"],
|
|
306
|
+
"unreadCount": max(0, int(getattr(dialog, "unread_count", 0) or 0)),
|
|
307
|
+
}
|
|
308
|
+
if isinstance(username, str) and username:
|
|
309
|
+
item["username"] = username[:128]
|
|
310
|
+
dialogs.append(item)
|
|
311
|
+
matched += 1
|
|
312
|
+
return {
|
|
313
|
+
"dialogs": dialogs,
|
|
314
|
+
"nextOffset": offset + len(dialogs) if len(dialogs) == limit else None,
|
|
315
|
+
"scanBoundReached": scanned > MAX_DIALOG_SCAN,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async def subscribe(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
319
|
+
chat_id = required_chat_id(params.get("chatId"))
|
|
320
|
+
found = None
|
|
321
|
+
scanned = 0
|
|
322
|
+
async for dialog in self.client.iter_dialogs():
|
|
323
|
+
scanned += 1
|
|
324
|
+
if scanned > MAX_DIALOG_SCAN:
|
|
325
|
+
break
|
|
326
|
+
if str(dialog.id) == chat_id:
|
|
327
|
+
found = dialog
|
|
328
|
+
break
|
|
329
|
+
if found is None:
|
|
330
|
+
raise ValueError("chat_not_found")
|
|
331
|
+
title = str(getattr(found, "name", "") or display_name(found.entity, chat_id))[:256]
|
|
332
|
+
username = getattr(found.entity, "username", None)
|
|
333
|
+
subscription: dict[str, Any] = {
|
|
334
|
+
"chat_id": chat_id,
|
|
335
|
+
"title": title,
|
|
336
|
+
"kind": entity_kind(found),
|
|
337
|
+
}
|
|
338
|
+
if isinstance(username, str) and username:
|
|
339
|
+
subscription["username"] = username[:128]
|
|
340
|
+
self.state["subscriptions"][chat_id] = subscription
|
|
341
|
+
self.save()
|
|
342
|
+
return public_subscription(subscription)
|
|
343
|
+
|
|
344
|
+
def unsubscribe(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
345
|
+
chat_id = required_chat_id(params.get("chatId"))
|
|
346
|
+
removed = self.state["subscriptions"].pop(chat_id, None)
|
|
347
|
+
pending = self.state["pending"]
|
|
348
|
+
retained = [item for item in pending if item.get("chatId") != chat_id]
|
|
349
|
+
dropped = len(retained) != len(pending)
|
|
350
|
+
if dropped:
|
|
351
|
+
self.state["pending"] = retained
|
|
352
|
+
if removed is not None or dropped:
|
|
353
|
+
self.save()
|
|
354
|
+
return {"chatId": chat_id, "removed": removed is not None}
|
|
355
|
+
|
|
356
|
+
def list_subscriptions(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
357
|
+
offset = bounded_int(params.get("offset"), 0, MAX_DIALOG_SCAN, 0)
|
|
358
|
+
limit = bounded_int(params.get("limit"), 1, 100, 50)
|
|
359
|
+
subscriptions = [public_subscription(item) for item in self.state["subscriptions"].values()]
|
|
360
|
+
subscriptions.sort(key=lambda item: (item["title"].lower(), item["chatId"]))
|
|
361
|
+
page = subscriptions[offset:offset + limit]
|
|
362
|
+
next_offset = offset + len(page) if offset + len(page) < len(subscriptions) else None
|
|
363
|
+
return {
|
|
364
|
+
"subscriptions": page,
|
|
365
|
+
"nextOffset": next_offset,
|
|
366
|
+
"total": len(subscriptions),
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
def ack(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
370
|
+
delivery_id = params.get("deliveryId")
|
|
371
|
+
if not isinstance(delivery_id, str) or not delivery_id or len(delivery_id) > 256:
|
|
372
|
+
raise ValueError("invalid_delivery_id")
|
|
373
|
+
pending = self.state["pending"]
|
|
374
|
+
next_pending = [item for item in pending if item.get("deliveryId") != delivery_id]
|
|
375
|
+
removed = len(next_pending) != len(pending)
|
|
376
|
+
if removed:
|
|
377
|
+
self.state["pending"] = next_pending
|
|
378
|
+
recent = self.state["recent"]
|
|
379
|
+
recent.append(delivery_id)
|
|
380
|
+
self.state["recent"] = recent[-MAX_RECENT:]
|
|
381
|
+
self.save()
|
|
382
|
+
return {"deliveryId": delivery_id, "acknowledged": removed}
|
|
383
|
+
|
|
384
|
+
def status(self) -> dict[str, Any]:
|
|
385
|
+
username = getattr(self.me, "username", None)
|
|
386
|
+
result: dict[str, Any] = {
|
|
387
|
+
"authorized": True,
|
|
388
|
+
"displayName": display_name(self.me, "Telegram user"),
|
|
389
|
+
"subscriptionCount": len(self.state["subscriptions"]),
|
|
390
|
+
"pendingCount": len(self.state["pending"]),
|
|
391
|
+
"deliveryMode": "read_only",
|
|
392
|
+
}
|
|
393
|
+
if isinstance(username, str) and username:
|
|
394
|
+
result["username"] = username[:128]
|
|
395
|
+
return result
|
|
396
|
+
|
|
397
|
+
async def command(self, method: str, params: dict[str, Any]) -> Any:
|
|
398
|
+
if method == "status":
|
|
399
|
+
return self.status()
|
|
400
|
+
if method == "list_dialogs":
|
|
401
|
+
return await self.list_dialogs(params)
|
|
402
|
+
if method == "list_subscriptions":
|
|
403
|
+
return self.list_subscriptions(params)
|
|
404
|
+
if method == "subscribe":
|
|
405
|
+
return await self.subscribe(params)
|
|
406
|
+
if method == "unsubscribe":
|
|
407
|
+
return self.unsubscribe(params)
|
|
408
|
+
if method == "ack":
|
|
409
|
+
return self.ack(params)
|
|
410
|
+
if method == "shutdown":
|
|
411
|
+
self.shutting_down = True
|
|
412
|
+
return {"stopping": True}
|
|
413
|
+
raise ValueError("unsupported_method")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def public_subscription(value: dict[str, Any]) -> dict[str, Any]:
|
|
417
|
+
result = {
|
|
418
|
+
"chatId": str(value["chat_id"]),
|
|
419
|
+
"title": str(value["title"]),
|
|
420
|
+
"kind": str(value["kind"]),
|
|
421
|
+
}
|
|
422
|
+
username = value.get("username")
|
|
423
|
+
if isinstance(username, str) and username:
|
|
424
|
+
result["username"] = username
|
|
425
|
+
return result
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def bounded_int(value: Any, minimum: int, maximum: int, default: int) -> int:
|
|
429
|
+
if value is None:
|
|
430
|
+
return default
|
|
431
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum:
|
|
432
|
+
raise ValueError("invalid_number")
|
|
433
|
+
return value
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def required_chat_id(value: Any) -> str:
|
|
437
|
+
if not isinstance(value, str) or not value or len(value) > 128:
|
|
438
|
+
raise ValueError("invalid_chat_id")
|
|
439
|
+
if re.fullmatch(r"-?[0-9]+", value) is None:
|
|
440
|
+
raise ValueError("invalid_chat_id")
|
|
441
|
+
return value
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def login(state_dir: Path) -> int:
|
|
445
|
+
try:
|
|
446
|
+
from telethon import TelegramClient
|
|
447
|
+
except ImportError:
|
|
448
|
+
print("Telethon runtime is missing. Run through the alfe-telegram command.", file=sys.stderr)
|
|
449
|
+
return 2
|
|
450
|
+
|
|
451
|
+
ensure_private_dir(state_dir)
|
|
452
|
+
try:
|
|
453
|
+
api_id = int(input("Telegram API ID (from my.telegram.org): ").strip())
|
|
454
|
+
except ValueError:
|
|
455
|
+
print("Invalid API ID.", file=sys.stderr)
|
|
456
|
+
return 2
|
|
457
|
+
api_hash = getpass.getpass("Telegram API hash: ").strip()
|
|
458
|
+
if api_id <= 0 or API_HASH_RE.fullmatch(api_hash) is None:
|
|
459
|
+
print("Invalid API ID or hash.", file=sys.stderr)
|
|
460
|
+
return 2
|
|
461
|
+
phone = input("Telegram phone number (international format): ").strip()
|
|
462
|
+
if not phone or len(phone) > 32:
|
|
463
|
+
print("Invalid phone number.", file=sys.stderr)
|
|
464
|
+
return 2
|
|
465
|
+
|
|
466
|
+
session_base = state_dir / "user"
|
|
467
|
+
client = TelegramClient(str(session_base), api_id, api_hash)
|
|
468
|
+
try:
|
|
469
|
+
await client.start(
|
|
470
|
+
phone=phone,
|
|
471
|
+
code_callback=lambda: getpass.getpass("Telegram login code: ").strip(),
|
|
472
|
+
password=lambda: getpass.getpass("Telegram 2FA password (if requested): "),
|
|
473
|
+
)
|
|
474
|
+
me = await client.get_me()
|
|
475
|
+
if me is None:
|
|
476
|
+
raise RuntimeError("authorization_failed")
|
|
477
|
+
atomic_json(state_dir / "config.json", {
|
|
478
|
+
"version": STATE_VERSION,
|
|
479
|
+
"api_id": api_id,
|
|
480
|
+
"api_hash": api_hash,
|
|
481
|
+
})
|
|
482
|
+
session_file = state_dir / "user.session"
|
|
483
|
+
if session_file.exists():
|
|
484
|
+
try:
|
|
485
|
+
session_file.chmod(0o600)
|
|
486
|
+
except OSError:
|
|
487
|
+
pass
|
|
488
|
+
print(f"Telegram login complete for {display_name(me, 'Telegram user')}.")
|
|
489
|
+
print("The session is stored only on this machine.")
|
|
490
|
+
return 0
|
|
491
|
+
except Exception:
|
|
492
|
+
print("Telegram login failed. Check the code, 2FA password, and API credentials.", file=sys.stderr)
|
|
493
|
+
return 1
|
|
494
|
+
finally:
|
|
495
|
+
await client.disconnect()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
async def logout(state_dir: Path) -> int:
|
|
499
|
+
try:
|
|
500
|
+
from telethon import TelegramClient
|
|
501
|
+
except ImportError:
|
|
502
|
+
print("Telethon runtime is missing. Run through the alfe-telegram command.", file=sys.stderr)
|
|
503
|
+
return 2
|
|
504
|
+
try:
|
|
505
|
+
api_id, api_hash = load_config(state_dir)
|
|
506
|
+
except FileNotFoundError:
|
|
507
|
+
print("No local Telegram login is configured.")
|
|
508
|
+
return 0
|
|
509
|
+
except Exception:
|
|
510
|
+
print("The local Telegram configuration is invalid.", file=sys.stderr)
|
|
511
|
+
return 2
|
|
512
|
+
|
|
513
|
+
client = TelegramClient(str(state_dir / "user"), api_id, api_hash)
|
|
514
|
+
try:
|
|
515
|
+
await client.connect()
|
|
516
|
+
if await client.is_user_authorized() and not await client.log_out():
|
|
517
|
+
raise RuntimeError("logout_failed")
|
|
518
|
+
for name in ("config.json", "state.json", "user.session", "user.session-journal"):
|
|
519
|
+
try:
|
|
520
|
+
(state_dir / name).unlink()
|
|
521
|
+
except FileNotFoundError:
|
|
522
|
+
pass
|
|
523
|
+
print("Telegram device session revoked and local connection state removed.")
|
|
524
|
+
return 0
|
|
525
|
+
except Exception:
|
|
526
|
+
print(
|
|
527
|
+
"Telegram logout failed. Retry with network access or revoke the session from Telegram Devices.",
|
|
528
|
+
file=sys.stderr,
|
|
529
|
+
)
|
|
530
|
+
return 1
|
|
531
|
+
finally:
|
|
532
|
+
if client.is_connected():
|
|
533
|
+
await client.disconnect()
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
async def run_bridge(state_dir: Path) -> int:
|
|
537
|
+
try:
|
|
538
|
+
from telethon import TelegramClient, events
|
|
539
|
+
except ImportError:
|
|
540
|
+
fatal("runtime_missing")
|
|
541
|
+
return 2
|
|
542
|
+
try:
|
|
543
|
+
api_id, api_hash = load_config(state_dir)
|
|
544
|
+
except FileNotFoundError:
|
|
545
|
+
fatal("not_configured")
|
|
546
|
+
return 2
|
|
547
|
+
except Exception:
|
|
548
|
+
fatal("invalid_config")
|
|
549
|
+
return 2
|
|
550
|
+
|
|
551
|
+
client = TelegramClient(str(state_dir / "user"), api_id, api_hash)
|
|
552
|
+
try:
|
|
553
|
+
await client.connect()
|
|
554
|
+
if not await client.is_user_authorized():
|
|
555
|
+
fatal("not_authorized")
|
|
556
|
+
return 2
|
|
557
|
+
me = await client.get_me()
|
|
558
|
+
if me is None:
|
|
559
|
+
fatal("not_authorized")
|
|
560
|
+
return 2
|
|
561
|
+
bridge = Bridge(state_dir, client, me)
|
|
562
|
+
except ValueError:
|
|
563
|
+
fatal("invalid_state")
|
|
564
|
+
await client.disconnect()
|
|
565
|
+
return 2
|
|
566
|
+
except Exception:
|
|
567
|
+
fatal("connect_failed")
|
|
568
|
+
await client.disconnect()
|
|
569
|
+
return 1
|
|
570
|
+
|
|
571
|
+
client.add_event_handler(bridge.handle_message, events.NewMessage(incoming=True))
|
|
572
|
+
emit({"type": "ready"})
|
|
573
|
+
for item in list(bridge.state["pending"]):
|
|
574
|
+
emit({"type": "event", "event": item})
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
while not bridge.shutting_down:
|
|
578
|
+
raw = await asyncio.to_thread(sys.stdin.buffer.readline, MAX_COMMAND_BYTES + 1)
|
|
579
|
+
if not raw:
|
|
580
|
+
break
|
|
581
|
+
if len(raw) > MAX_COMMAND_BYTES or not raw.endswith(b"\n"):
|
|
582
|
+
fatal("command_too_large")
|
|
583
|
+
break
|
|
584
|
+
request_id: Any = None
|
|
585
|
+
try:
|
|
586
|
+
decoded = json.loads(raw.decode("utf-8"))
|
|
587
|
+
if not isinstance(decoded, dict):
|
|
588
|
+
raise ValueError("invalid_request")
|
|
589
|
+
request_id = decoded.get("id")
|
|
590
|
+
method = decoded.get("method")
|
|
591
|
+
params = decoded.get("params", {})
|
|
592
|
+
if not isinstance(request_id, str) or len(request_id) > 128:
|
|
593
|
+
raise ValueError("invalid_request")
|
|
594
|
+
if not isinstance(method, str) or method not in SAFE_METHODS:
|
|
595
|
+
raise ValueError("unsupported_method")
|
|
596
|
+
if not isinstance(params, dict):
|
|
597
|
+
raise ValueError("invalid_params")
|
|
598
|
+
result = await bridge.command(method, params)
|
|
599
|
+
emit({"type": "response", "id": request_id, "ok": True, "result": result})
|
|
600
|
+
except ValueError as error:
|
|
601
|
+
code = str(error)
|
|
602
|
+
if re.fullmatch(r"[a-z0-9_]{1,64}", code) is None:
|
|
603
|
+
code = "invalid_request"
|
|
604
|
+
if isinstance(request_id, str):
|
|
605
|
+
emit({"type": "response", "id": request_id, "ok": False, "code": code})
|
|
606
|
+
except Exception:
|
|
607
|
+
if isinstance(request_id, str):
|
|
608
|
+
emit({"type": "response", "id": request_id, "ok": False, "code": "command_failed"})
|
|
609
|
+
finally:
|
|
610
|
+
await client.disconnect()
|
|
611
|
+
return 0
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def parse_args() -> argparse.Namespace:
|
|
615
|
+
parser = argparse.ArgumentParser(add_help=True)
|
|
616
|
+
parser.add_argument("command", choices=["login", "logout", "run"])
|
|
617
|
+
parser.add_argument("--state-dir", required=True)
|
|
618
|
+
return parser.parse_args()
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def main() -> int:
|
|
622
|
+
args = parse_args()
|
|
623
|
+
state_dir = Path(args.state_dir).expanduser().resolve()
|
|
624
|
+
ensure_private_dir(state_dir)
|
|
625
|
+
if args.command == "login":
|
|
626
|
+
return asyncio.run(login(state_dir))
|
|
627
|
+
if args.command == "logout":
|
|
628
|
+
return asyncio.run(logout(state_dir))
|
|
629
|
+
return asyncio.run(run_bridge(state_dir))
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
if __name__ == "__main__":
|
|
633
|
+
raise SystemExit(main())
|