@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.
@@ -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())