@xiaotianxt/skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/EXCLUDED.md +42 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -0
  4. package/SECURITY.md +23 -0
  5. package/SOURCES.md +45 -0
  6. package/bin/skills.mjs +241 -0
  7. package/package.json +38 -0
  8. package/skills/1password/SKILL.md +94 -0
  9. package/skills/1password/agents/openai.yaml +4 -0
  10. package/skills/1password/references/item-management.md +80 -0
  11. package/skills/1password/references/op-cli.md +107 -0
  12. package/skills/apple-calendar-event/SKILL.md +81 -0
  13. package/skills/apple-calendar-event/agents/openai.yaml +4 -0
  14. package/skills/apple-calendar-event/scripts/calendar_audit.py +201 -0
  15. package/skills/apple-calendar-event/scripts/calendar_event.py +164 -0
  16. package/skills/bro-browser/SKILL.md +118 -0
  17. package/skills/bro-browser/agents/openai.yaml +4 -0
  18. package/skills/bro-browser/references/tool-map.md +102 -0
  19. package/skills/bro-browser/references/workflows.md +146 -0
  20. package/skills/bro-browser/scripts/bro-call.mjs +189 -0
  21. package/skills/calendar/SKILL.md +182 -0
  22. package/skills/calendar/agents/openai.yaml +4 -0
  23. package/skills/calendar/references/operations.md +255 -0
  24. package/skills/calendar/scripts/calendar_list_review.py +157 -0
  25. package/skills/calendar/scripts/event_dedupe_preview.py +155 -0
  26. package/skills/canvas/SKILL.md +70 -0
  27. package/skills/canvas/agents/openai.yaml +4 -0
  28. package/skills/canvas/references/canvas-api.md +76 -0
  29. package/skills/course-exam-review-planner/SKILL.md +127 -0
  30. package/skills/cx/SKILL.md +25 -0
  31. package/skills/gh-fix-ci/LICENSE.txt +201 -0
  32. package/skills/gh-fix-ci/SKILL.md +81 -0
  33. package/skills/gh-fix-ci/agents/openai.yaml +6 -0
  34. package/skills/gh-fix-ci/assets/github-small.svg +3 -0
  35. package/skills/gh-fix-ci/assets/github.png +0 -0
  36. package/skills/gh-fix-ci/scripts/inspect_pr_checks.py +509 -0
  37. package/skills/gh-review-workflow/SKILL.md +61 -0
  38. package/skills/gh-review-workflow/agents/openai.yaml +4 -0
  39. package/skills/gh-review-workflow/references/workflow.md +48 -0
  40. package/skills/gh-review-workflow/scripts/fetch_review_state.py +222 -0
  41. package/skills/gh-review-workflow/scripts/resolve_review_threads.py +83 -0
  42. package/skills/github/SKILL.md +74 -0
  43. package/skills/github/agents/openai.yaml +6 -0
  44. package/skills/github/assets/github-small.svg +3 -0
  45. package/skills/github/assets/github.png +0 -0
  46. package/skills/gws-calendar/SKILL.md +126 -0
  47. package/skills/gws-calendar-agenda/SKILL.md +52 -0
  48. package/skills/gws-calendar-insert/SKILL.md +66 -0
  49. package/skills/gws-docs/SKILL.md +48 -0
  50. package/skills/gws-docs-write/SKILL.md +49 -0
  51. package/skills/gws-drive/SKILL.md +137 -0
  52. package/skills/gws-drive-upload/SKILL.md +52 -0
  53. package/skills/gws-gmail/SKILL.md +62 -0
  54. package/skills/gws-gmail-forward/SKILL.md +55 -0
  55. package/skills/gws-gmail-reply/SKILL.md +58 -0
  56. package/skills/gws-gmail-reply-all/SKILL.md +62 -0
  57. package/skills/gws-gmail-send/SKILL.md +57 -0
  58. package/skills/gws-gmail-triage/SKILL.md +50 -0
  59. package/skills/gws-gmail-watch/SKILL.md +58 -0
  60. package/skills/gws-shared/SKILL.md +27 -0
  61. package/skills/helium-browser-mcp/SKILL.md +137 -0
  62. package/skills/helium-browser-mcp/agents/openai.yaml +4 -0
  63. package/skills/helium-browser-mcp/scripts/obmcp.mjs +92 -0
  64. package/skills/helium-browser-mcp/scripts/openbrowsermcp-stdio-proxy.mjs +170 -0
  65. package/skills/learn/SKILL.md +122 -0
  66. package/skills/learn/agents/openai.yaml +7 -0
  67. package/skills/learn/assets/AGENTS.template.md +33 -0
  68. package/skills/learn/assets/errorlog.template.typ +61 -0
  69. package/skills/learn/assets/reading-sequence.template.md +23 -0
  70. package/skills/learn/assets/source-index.template.md +17 -0
  71. package/skills/learn/assets/tasklog.template.typ +57 -0
  72. package/skills/learn/assets/workbook.template.typ +60 -0
  73. package/skills/learn/references/learning-science.md +103 -0
  74. package/skills/learn/scripts/init_learning_workspace.py +70 -0
  75. package/skills/macos-messages/SKILL.md +258 -0
  76. package/skills/memory/SKILL.md +33 -0
  77. package/skills/memory/codex.md +186 -0
  78. package/skills/memory/opencode.md +164 -0
  79. package/skills/mimestreamctl/SKILL.md +170 -0
  80. package/skills/mimestreamctl/agents/openai.yaml +4 -0
  81. package/skills/mimestreamctl/scripts/mimestreamctl +33 -0
  82. package/skills/mon/SKILL.md +51 -0
  83. package/skills/mon/scripts/mon_spend_review.py +458 -0
  84. package/skills/ocr/SKILL.md +136 -0
  85. package/skills/ocr/agents/openai.yaml +4 -0
  86. package/skills/ocr/references/local-ocr-best-practices.md +297 -0
  87. package/skills/ocr/references/mineru-api.md +159 -0
  88. package/skills/ocr/scripts/ocr-router +22 -0
  89. package/skills/ocr/scripts/ocr_router.py +741 -0
  90. package/skills/panopto-mp4-bulk-download/SKILL.md +57 -0
  91. package/skills/panopto-mp4-bulk-download/agents/openai.yaml +4 -0
  92. package/skills/panopto-mp4-bulk-download/references/url-patterns.md +26 -0
  93. package/skills/panopto-mp4-bulk-download/scripts/panopto_bulk_mp4.sh +213 -0
  94. package/skills/rust-systems-style/SKILL.md +109 -0
  95. package/skills/rust-systems-style/agents/openai.yaml +4 -0
  96. package/skills/rust-systems-style/references/rust-review-checklist.md +77 -0
  97. package/skills/rust-systems-style/references/style-sources.md +68 -0
  98. package/skills/ship-ai-native-cli/SKILL.md +76 -0
  99. package/skills/ship-ai-native-cli/agents/openai.yaml +4 -0
  100. package/skills/ship-ai-native-cli/references/case-notes.md +83 -0
  101. package/skills/ship-ai-native-cli/references/product-method.md +82 -0
  102. package/skills/ship-ai-native-cli/references/release-checklist.md +147 -0
  103. package/skills/ship-ai-native-cli/references/rust-cli-shape.md +111 -0
  104. package/skills/telegram-mtproto-session/SKILL.md +125 -0
  105. package/skills/telegram-mtproto-session/agents/openai.yaml +4 -0
  106. package/skills/telegram-mtproto-session/scripts/telegram_session.py +687 -0
  107. package/skills/tg/SKILL.md +173 -0
  108. package/skills/things3-manager/SKILL.md +116 -0
  109. package/skills/things3-manager/scripts/things +42 -0
  110. package/skills/things3-manager/scripts/things_cli.py +514 -0
  111. package/skills/web-artifacts-builder/LICENSE.txt +202 -0
  112. package/skills/web-artifacts-builder/SKILL.md +74 -0
  113. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +54 -0
  114. package/skills/web-artifacts-builder/scripts/init-artifact.sh +379 -0
  115. package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  116. package/skills/yeet/LICENSE.txt +201 -0
  117. package/skills/yeet/SKILL.md +71 -0
  118. package/skills/yeet/agents/openai.yaml +6 -0
  119. package/skills/yeet/assets/yeet-small.svg +3 -0
  120. package/skills/yeet/assets/yeet.png +0 -0
@@ -0,0 +1,687 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import asyncio
4
+ import getpass
5
+ import json
6
+ import os
7
+ import re
8
+ import sqlite3
9
+ import sys
10
+ from datetime import datetime, time, timedelta, timezone
11
+ from pathlib import Path
12
+ from zoneinfo import ZoneInfo
13
+
14
+ from telethon import TelegramClient, functions, types
15
+ from telethon.errors import SessionPasswordNeededError
16
+
17
+
18
+ DEFAULT_SESSION = "~/.local/share/codex-telegram/telegram"
19
+ DEFAULT_HISTORY_DB = "~/.local/share/codex-telegram/history.sqlite3"
20
+ DEFAULT_TIMEZONE = os.environ.get("TZ") or "America/Los_Angeles"
21
+
22
+
23
+ def require_env(name: str) -> str:
24
+ value = os.environ.get(name)
25
+ if not value:
26
+ raise SystemExit(f"missing required env: {name}")
27
+ return value
28
+
29
+
30
+ def clean(text: str, limit: int = 700) -> str:
31
+ text = re.sub(r"\s+", " ", text or "").strip()
32
+ if len(text) > limit:
33
+ return text[: limit - 1] + "..."
34
+ return text
35
+
36
+
37
+ def utc_text(dt) -> str:
38
+ if not dt:
39
+ return ""
40
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
41
+
42
+
43
+ def parse_boundary(value: str, tz_name: str, *, until: bool = False) -> datetime:
44
+ tz = ZoneInfo(tz_name)
45
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
46
+ day = datetime.strptime(value, "%Y-%m-%d").date()
47
+ local = datetime.combine(day, time.min, tzinfo=tz)
48
+ if until:
49
+ local += timedelta(days=1)
50
+ return local.astimezone(timezone.utc)
51
+
52
+ normalized = value.strip()
53
+ if normalized.endswith("Z"):
54
+ normalized = normalized[:-1] + "+00:00"
55
+ dt = datetime.fromisoformat(normalized)
56
+ if dt.tzinfo is None:
57
+ dt = dt.replace(tzinfo=tz)
58
+ return dt.astimezone(timezone.utc)
59
+
60
+
61
+ def date_bounds(args) -> tuple[datetime, datetime]:
62
+ if getattr(args, "day", None):
63
+ since = parse_boundary(args.day, args.timezone)
64
+ until = parse_boundary(args.day, args.timezone, until=True)
65
+ return since, until
66
+
67
+ if not args.since:
68
+ raise SystemExit("missing --since")
69
+ since = parse_boundary(args.since, args.timezone)
70
+ until_value = args.until
71
+ if until_value:
72
+ until = parse_boundary(until_value, args.timezone, until=True)
73
+ else:
74
+ until = datetime.now(timezone.utc)
75
+ if until <= since:
76
+ raise SystemExit("--until must be after --since")
77
+ return since, until
78
+
79
+
80
+ def today_bounds(tz_name: str) -> tuple[str, datetime, datetime]:
81
+ tz = ZoneInfo(tz_name)
82
+ day = datetime.now(tz).date().isoformat()
83
+ since = parse_boundary(day, tz_name)
84
+ until = parse_boundary(day, tz_name, until=True)
85
+ return day, since, until
86
+
87
+
88
+ def display_time(utc_value: str, tz_name: str) -> str:
89
+ if not utc_value:
90
+ return ""
91
+ dt = datetime.fromisoformat(utc_value.replace("Z", "+00:00"))
92
+ return dt.astimezone(ZoneInfo(tz_name)).strftime("%Y-%m-%d %H:%M:%S %Z")
93
+
94
+
95
+ def display_username(entity) -> str:
96
+ username = getattr(entity, "username", None)
97
+ return f"@{username}" if username else ""
98
+
99
+
100
+ def normalized_chat_key(value: str) -> str:
101
+ value = value.strip()
102
+ if value.startswith("@"):
103
+ return value.lower()
104
+ if re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{3,}", value):
105
+ return f"@{value.lower()}"
106
+ return value.lower()
107
+
108
+
109
+ def entity_chat_key(entity, fallback: str) -> str:
110
+ username = getattr(entity, "username", None)
111
+ if username:
112
+ return f"@{username.lower()}"
113
+ entity_id = getattr(entity, "id", None)
114
+ if entity_id is not None:
115
+ return f"{entity_kind(entity)}:{entity_id}"
116
+ return normalized_chat_key(fallback)
117
+
118
+
119
+ def entity_kind(entity) -> str:
120
+ if isinstance(entity, types.Channel):
121
+ if getattr(entity, "megagroup", False):
122
+ return "megagroup"
123
+ if getattr(entity, "broadcast", False):
124
+ return "channel"
125
+ return "channel-like"
126
+ if isinstance(entity, types.Chat):
127
+ return "chat"
128
+ if isinstance(entity, types.User):
129
+ return "bot" if getattr(entity, "bot", False) else "user"
130
+ return type(entity).__name__
131
+
132
+
133
+ def session_base(path: str) -> str:
134
+ expanded = Path(path).expanduser()
135
+ expanded.parent.mkdir(parents=True, exist_ok=True)
136
+ return str(expanded)
137
+
138
+
139
+ def session_file(path: str) -> Path:
140
+ expanded = Path(path).expanduser()
141
+ if expanded.suffix == ".session":
142
+ return expanded
143
+ return Path(str(expanded) + ".session")
144
+
145
+
146
+ def history_db_file(path: str, *, create_parent: bool = True) -> Path:
147
+ expanded = Path(path).expanduser()
148
+ if create_parent:
149
+ expanded.parent.mkdir(parents=True, exist_ok=True)
150
+ return expanded
151
+
152
+
153
+ def connect_history_db(path: str, *, readonly: bool = False) -> sqlite3.Connection:
154
+ db_path = history_db_file(path, create_parent=not readonly)
155
+ if readonly:
156
+ conn = sqlite3.connect(f"{db_path.as_uri()}?mode=ro", uri=True)
157
+ conn.row_factory = sqlite3.Row
158
+ return conn
159
+
160
+ conn = sqlite3.connect(db_path)
161
+ conn.row_factory = sqlite3.Row
162
+ conn.execute("PRAGMA journal_mode=WAL")
163
+ conn.execute("PRAGMA synchronous=NORMAL")
164
+ conn.execute("PRAGMA temp_store=MEMORY")
165
+ conn.execute("PRAGMA foreign_keys=ON")
166
+ ensure_history_schema(conn)
167
+ return conn
168
+
169
+
170
+ def ensure_history_schema(conn: sqlite3.Connection) -> None:
171
+ conn.executescript(
172
+ """
173
+ CREATE TABLE IF NOT EXISTS chats (
174
+ chat_key TEXT PRIMARY KEY,
175
+ peer_id INTEGER,
176
+ title TEXT,
177
+ username TEXT,
178
+ kind TEXT,
179
+ last_sync_utc TEXT
180
+ );
181
+
182
+ CREATE TABLE IF NOT EXISTS messages (
183
+ id INTEGER PRIMARY KEY,
184
+ chat_key TEXT NOT NULL,
185
+ message_id INTEGER NOT NULL,
186
+ date_utc TEXT NOT NULL,
187
+ sender_id INTEGER,
188
+ text TEXT NOT NULL,
189
+ views INTEGER,
190
+ forwards INTEGER,
191
+ reply_to_msg_id INTEGER,
192
+ grouped_id INTEGER,
193
+ edit_date_utc TEXT,
194
+ UNIQUE(chat_key, message_id),
195
+ FOREIGN KEY(chat_key) REFERENCES chats(chat_key) ON DELETE CASCADE
196
+ );
197
+
198
+ CREATE INDEX IF NOT EXISTS messages_chat_date_idx
199
+ ON messages(chat_key, date_utc);
200
+ CREATE INDEX IF NOT EXISTS messages_date_idx
201
+ ON messages(date_utc);
202
+ CREATE INDEX IF NOT EXISTS messages_sender_idx
203
+ ON messages(sender_id);
204
+
205
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
206
+ USING fts5(text, content='messages', content_rowid='id');
207
+
208
+ CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
209
+ INSERT INTO messages_fts(rowid, text) VALUES (new.id, new.text);
210
+ END;
211
+ CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
212
+ INSERT INTO messages_fts(messages_fts, rowid, text)
213
+ VALUES('delete', old.id, old.text);
214
+ END;
215
+ CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
216
+ INSERT INTO messages_fts(messages_fts, rowid, text)
217
+ VALUES('delete', old.id, old.text);
218
+ INSERT INTO messages_fts(rowid, text) VALUES (new.id, new.text);
219
+ END;
220
+ """
221
+ )
222
+
223
+
224
+ def make_client(args) -> TelegramClient:
225
+ api_id = int(require_env("TELEGRAM_API_ID"))
226
+ api_hash = require_env("TELEGRAM_API_HASH")
227
+ return TelegramClient(session_base(args.session), api_id, api_hash)
228
+
229
+
230
+ async def require_authorized_client(args) -> TelegramClient:
231
+ client = make_client(args)
232
+ await client.connect()
233
+ if not await client.is_user_authorized():
234
+ await client.disconnect()
235
+ print("authorized=false", file=sys.stderr)
236
+ raise SystemExit(3)
237
+ return client
238
+
239
+
240
+ def chat_row(entity, chat_key: str) -> dict:
241
+ return {
242
+ "chat_key": chat_key,
243
+ "peer_id": getattr(entity, "id", None),
244
+ "title": getattr(entity, "title", None)
245
+ or " ".join(
246
+ part
247
+ for part in [getattr(entity, "first_name", None), getattr(entity, "last_name", None)]
248
+ if part
249
+ ),
250
+ "username": getattr(entity, "username", None),
251
+ "kind": entity_kind(entity),
252
+ "last_sync_utc": utc_text(datetime.now(timezone.utc)),
253
+ }
254
+
255
+
256
+ def message_row(chat_key: str, msg) -> dict:
257
+ reply_to = getattr(msg, "reply_to", None)
258
+ return {
259
+ "chat_key": chat_key,
260
+ "message_id": msg.id,
261
+ "date_utc": utc_text(msg.date),
262
+ "sender_id": getattr(msg, "sender_id", None),
263
+ "text": msg.raw_text or "",
264
+ "views": getattr(msg, "views", None),
265
+ "forwards": getattr(msg, "forwards", None),
266
+ "reply_to_msg_id": getattr(reply_to, "reply_to_msg_id", None),
267
+ "grouped_id": getattr(msg, "grouped_id", None),
268
+ "edit_date_utc": utc_text(getattr(msg, "edit_date", None)),
269
+ }
270
+
271
+
272
+ async def fetch_message_rows(client: TelegramClient, entity, chat_key: str, since: datetime, until: datetime, limit: int | None):
273
+ rows = []
274
+ async for msg in client.iter_messages(entity, limit=limit, offset_date=until):
275
+ if not msg.date:
276
+ continue
277
+ msg_dt = msg.date.astimezone(timezone.utc)
278
+ if msg_dt < since:
279
+ break
280
+ if msg_dt >= until:
281
+ continue
282
+ rows.append(message_row(chat_key, msg))
283
+ rows.reverse()
284
+ return rows
285
+
286
+
287
+ def upsert_chat(conn: sqlite3.Connection, row: dict) -> None:
288
+ conn.execute(
289
+ """
290
+ INSERT INTO chats(chat_key, peer_id, title, username, kind, last_sync_utc)
291
+ VALUES(:chat_key, :peer_id, :title, :username, :kind, :last_sync_utc)
292
+ ON CONFLICT(chat_key) DO UPDATE SET
293
+ peer_id=excluded.peer_id,
294
+ title=excluded.title,
295
+ username=excluded.username,
296
+ kind=excluded.kind,
297
+ last_sync_utc=excluded.last_sync_utc
298
+ """,
299
+ row,
300
+ )
301
+
302
+
303
+ def upsert_messages(conn: sqlite3.Connection, rows: list[dict]) -> tuple[int, int]:
304
+ inserted = 0
305
+ updated = 0
306
+ for row in rows:
307
+ existed = conn.execute(
308
+ "SELECT 1 FROM messages WHERE chat_key = ? AND message_id = ?",
309
+ (row["chat_key"], row["message_id"]),
310
+ ).fetchone()
311
+ conn.execute(
312
+ """
313
+ INSERT INTO messages(
314
+ chat_key, message_id, date_utc, sender_id, text, views, forwards,
315
+ reply_to_msg_id, grouped_id, edit_date_utc
316
+ )
317
+ VALUES(
318
+ :chat_key, :message_id, :date_utc, :sender_id, :text, :views,
319
+ :forwards, :reply_to_msg_id, :grouped_id, :edit_date_utc
320
+ )
321
+ ON CONFLICT(chat_key, message_id) DO UPDATE SET
322
+ date_utc=excluded.date_utc,
323
+ sender_id=excluded.sender_id,
324
+ text=excluded.text,
325
+ views=excluded.views,
326
+ forwards=excluded.forwards,
327
+ reply_to_msg_id=excluded.reply_to_msg_id,
328
+ grouped_id=excluded.grouped_id,
329
+ edit_date_utc=excluded.edit_date_utc
330
+ """,
331
+ row,
332
+ )
333
+ if existed:
334
+ updated += 1
335
+ else:
336
+ inserted += 1
337
+ return inserted, updated
338
+
339
+
340
+ def query_history_rows(args):
341
+ conn = connect_history_db(args.db, readonly=True)
342
+ try:
343
+ clauses = []
344
+ params = []
345
+ if args.entity:
346
+ clauses.append("m.chat_key = ?")
347
+ params.append(normalized_chat_key(args.entity))
348
+ if getattr(args, "since", None):
349
+ clauses.append("m.date_utc >= ?")
350
+ params.append(utc_text(parse_boundary(args.since, args.timezone)))
351
+ if getattr(args, "until", None):
352
+ clauses.append("m.date_utc < ?")
353
+ params.append(utc_text(parse_boundary(args.until, args.timezone, until=True)))
354
+
355
+ match = getattr(args, "match", None)
356
+ if match:
357
+ from_sql = "messages m JOIN messages_fts f ON f.rowid = m.id"
358
+ clauses.append("messages_fts MATCH ?")
359
+ params.append(match)
360
+ else:
361
+ from_sql = "messages m"
362
+
363
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
364
+ limit = "" if args.limit is None else "LIMIT ?"
365
+ if args.limit is not None:
366
+ params.append(args.limit)
367
+ return conn.execute(
368
+ f"""
369
+ SELECT m.chat_key, m.message_id, m.date_utc, m.sender_id, m.text,
370
+ m.views, m.forwards, m.reply_to_msg_id, m.grouped_id, m.edit_date_utc
371
+ FROM {from_sql}
372
+ {where}
373
+ ORDER BY m.date_utc ASC, m.message_id ASC
374
+ {limit}
375
+ """,
376
+ params,
377
+ ).fetchall()
378
+ finally:
379
+ conn.close()
380
+
381
+
382
+ def print_history_rows(rows, args) -> None:
383
+ if args.format == "jsonl":
384
+ for row in rows:
385
+ print(json.dumps(dict(row), ensure_ascii=False, separators=(",", ":")))
386
+ return
387
+
388
+ for row in rows:
389
+ text = clean(row["text"], args.max_text)
390
+ print(
391
+ f"[{display_time(row['date_utc'], args.timezone)}] "
392
+ f"id={row['message_id']} sender={row['sender_id']} text={text}"
393
+ )
394
+
395
+
396
+ async def login(args) -> int:
397
+ phone = args.phone or os.environ.get("TELEGRAM_PHONE")
398
+ if not phone:
399
+ raise SystemExit("missing phone: pass --phone or TELEGRAM_PHONE")
400
+
401
+ client = make_client(args)
402
+ await client.connect()
403
+ try:
404
+ if await client.is_user_authorized():
405
+ me = await client.get_me()
406
+ print(f"already_authorized user_id={me.id} username={display_username(me)}")
407
+ print(f"session={session_file(args.session)}")
408
+ return 0
409
+
410
+ await client.send_code_request(phone)
411
+ print("code_sent=true")
412
+
413
+ code = args.code or os.environ.get("TELEGRAM_CODE")
414
+ if not code:
415
+ code = input("Telegram login code: ").strip()
416
+
417
+ try:
418
+ await client.sign_in(phone=phone, code=code)
419
+ except SessionPasswordNeededError:
420
+ password = args.password or os.environ.get("TELEGRAM_PASSWORD")
421
+ if not password:
422
+ password = getpass.getpass("Telegram 2FA password: ")
423
+ await client.sign_in(password=password)
424
+
425
+ me = await client.get_me()
426
+ print(f"authorized=true user_id={me.id} username={display_username(me)}")
427
+ print(f"session={session_file(args.session)}")
428
+ return 0
429
+ finally:
430
+ await client.disconnect()
431
+
432
+
433
+ async def verify(args) -> int:
434
+ client = make_client(args)
435
+ await client.connect()
436
+ try:
437
+ authorized = await client.is_user_authorized()
438
+ print(f"authorized={authorized}")
439
+ print(f"session={session_file(args.session)}")
440
+ if authorized:
441
+ me = await client.get_me()
442
+ print(f"user_id={me.id} username={display_username(me)}")
443
+ return 0 if authorized else 3
444
+ finally:
445
+ await client.disconnect()
446
+
447
+
448
+ async def search(args) -> int:
449
+ client = await require_authorized_client(args)
450
+ try:
451
+ result = await client(functions.contacts.SearchRequest(q=args.query, limit=args.limit))
452
+ for chat in result.chats:
453
+ title = getattr(chat, "title", "")
454
+ participants = getattr(chat, "participants_count", None)
455
+ print(
456
+ f"{title}\t{display_username(chat)}\t{entity_kind(chat)}"
457
+ f"\tparticipants={participants}\tid={getattr(chat, 'id', '')}"
458
+ )
459
+ return 0
460
+ finally:
461
+ await client.disconnect()
462
+
463
+
464
+ async def dialogs(args) -> int:
465
+ pattern = re.compile(args.match, re.I) if args.match else None
466
+ client = await require_authorized_client(args)
467
+ try:
468
+ count = 0
469
+ async for dialog in client.iter_dialogs(limit=args.limit):
470
+ entity = dialog.entity
471
+ title = dialog.name or ""
472
+ username = getattr(entity, "username", "") or ""
473
+ haystack = f"{title} {username}"
474
+ if pattern and not pattern.search(haystack):
475
+ continue
476
+ count += 1
477
+ print(
478
+ f"{title}\t{display_username(entity)}\t{entity_kind(entity)}"
479
+ f"\tid={getattr(entity, 'id', '')}\tunread={dialog.unread_count}"
480
+ )
481
+ if count == 0:
482
+ print("no_dialogs_matched")
483
+ return 0
484
+ finally:
485
+ await client.disconnect()
486
+
487
+
488
+ async def inspect(args) -> int:
489
+ client = await require_authorized_client(args)
490
+ try:
491
+ entity = await client.get_entity(args.entity)
492
+ title = getattr(entity, "title", None) or " ".join(
493
+ part for part in [getattr(entity, "first_name", None), getattr(entity, "last_name", None)] if part
494
+ )
495
+ print(f"title={title}")
496
+ print(f"username={display_username(entity)}")
497
+ print(f"kind={entity_kind(entity)}")
498
+ print(f"id={getattr(entity, 'id', '')}")
499
+
500
+ if isinstance(entity, types.Channel):
501
+ try:
502
+ full = await client(functions.channels.GetFullChannelRequest(entity))
503
+ full_chat = full.full_chat
504
+ participants = getattr(full_chat, "participants_count", None)
505
+ if participants is not None:
506
+ print(f"participants={participants}")
507
+ about = clean(getattr(full_chat, "about", ""))
508
+ if about:
509
+ print(f"about={about}")
510
+ linked_chat_id = getattr(full_chat, "linked_chat_id", None)
511
+ if linked_chat_id:
512
+ print(f"linked_chat_id={linked_chat_id}")
513
+ for chat in getattr(full, "chats", []):
514
+ if getattr(chat, "id", None) == linked_chat_id:
515
+ print(f"linked_chat_title={getattr(chat, 'title', '')}")
516
+ username = display_username(chat)
517
+ if username:
518
+ print(f"linked_chat_username={username}")
519
+ print(f"linked_chat_kind={entity_kind(chat)}")
520
+ break
521
+ except Exception as exc:
522
+ print(f"full_info_error={type(exc).__name__}: {exc}")
523
+
524
+ print("recent_messages:")
525
+ async for msg in client.iter_messages(entity, limit=args.limit):
526
+ dt = msg.date.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") if msg.date else ""
527
+ reply_info = ""
528
+ if getattr(msg, "replies", None):
529
+ replies = msg.replies
530
+ reply_info = (
531
+ f" replies={getattr(replies, 'replies', '')}"
532
+ f" comments={getattr(replies, 'comments', '')}"
533
+ f" channel_id={getattr(replies, 'channel_id', '')}"
534
+ f" max_id={getattr(replies, 'max_id', '')}"
535
+ )
536
+ print(
537
+ f"- date={dt} id={msg.id} views={getattr(msg, 'views', None)}"
538
+ f" forwards={getattr(msg, 'forwards', None)}{reply_info} text={clean(msg.raw_text)}"
539
+ )
540
+ return 0
541
+ finally:
542
+ await client.disconnect()
543
+
544
+
545
+ async def sync_history(args) -> int:
546
+ since, until = date_bounds(args)
547
+ client = await require_authorized_client(args)
548
+ try:
549
+ entity = await client.get_entity(args.entity)
550
+ chat_key = entity_chat_key(entity, args.entity)
551
+ rows = await fetch_message_rows(client, entity, chat_key, since, until, args.limit)
552
+ finally:
553
+ await client.disconnect()
554
+
555
+ conn = connect_history_db(args.db)
556
+ try:
557
+ with conn:
558
+ upsert_chat(conn, chat_row(entity, chat_key))
559
+ inserted, updated = upsert_messages(conn, rows)
560
+ print(
561
+ f"synced chat_key={chat_key} fetched={len(rows)} inserted={inserted} "
562
+ f"updated={updated} since={utc_text(since)} until={utc_text(until)} "
563
+ f"db={history_db_file(args.db)}"
564
+ )
565
+ return 0
566
+ finally:
567
+ conn.close()
568
+
569
+
570
+ async def today(args) -> int:
571
+ if not args.day:
572
+ args.day, since, until = today_bounds(args.timezone)
573
+ else:
574
+ since, until = date_bounds(args)
575
+
576
+ client = await require_authorized_client(args)
577
+ try:
578
+ entity = await client.get_entity(args.entity)
579
+ chat_key = entity_chat_key(entity, args.entity)
580
+ rows = await fetch_message_rows(client, entity, chat_key, since, until, args.limit)
581
+ finally:
582
+ await client.disconnect()
583
+
584
+ conn = connect_history_db(args.db)
585
+ try:
586
+ with conn:
587
+ upsert_chat(conn, chat_row(entity, chat_key))
588
+ inserted, updated = upsert_messages(conn, rows)
589
+ print(
590
+ f"synced chat_key={chat_key} day={args.day} fetched={len(rows)} "
591
+ f"inserted={inserted} updated={updated} db={history_db_file(args.db)}"
592
+ )
593
+ finally:
594
+ conn.close()
595
+
596
+ query_args = argparse.Namespace(
597
+ db=args.db,
598
+ entity=chat_key,
599
+ since=args.day,
600
+ until=args.day,
601
+ timezone=args.timezone,
602
+ match=args.match,
603
+ limit=args.print_limit,
604
+ format=args.format,
605
+ max_text=args.max_text,
606
+ )
607
+ rows = query_history_rows(query_args)
608
+ print_history_rows(rows, query_args)
609
+ return 0
610
+
611
+
612
+ async def query_history(args) -> int:
613
+ rows = query_history_rows(args)
614
+ print_history_rows(rows, args)
615
+ return 0
616
+
617
+
618
+ def parse_args():
619
+ parser = argparse.ArgumentParser(description="Create, verify, and use a local Telegram MTProto session.")
620
+ parser.add_argument("--session", default=DEFAULT_SESSION, help=f"Telethon session base path. Default: {DEFAULT_SESSION}")
621
+ parser.add_argument("--db", default=DEFAULT_HISTORY_DB, help=f"SQLite history database. Default: {DEFAULT_HISTORY_DB}")
622
+ sub = parser.add_subparsers(dest="command", required=True)
623
+
624
+ p = sub.add_parser("login", help="Create or refresh the local Telegram session.")
625
+ p.add_argument("--phone", help="Phone number in international format. Defaults to TELEGRAM_PHONE.")
626
+ p.add_argument("--code", help="Login code. Defaults to TELEGRAM_CODE, otherwise prompts.")
627
+ p.add_argument("--password", help="2FA password. Defaults to TELEGRAM_PASSWORD, otherwise prompts if needed.")
628
+ p.set_defaults(func=login)
629
+
630
+ p = sub.add_parser("verify", help="Check whether a session is authorized.")
631
+ p.set_defaults(func=verify)
632
+
633
+ p = sub.add_parser("search", help="Search public Telegram chats/channels.")
634
+ p.add_argument("query")
635
+ p.add_argument("--limit", type=int, default=20)
636
+ p.set_defaults(func=search)
637
+
638
+ p = sub.add_parser("dialogs", help="List joined dialogs, optionally filtered by regex.")
639
+ p.add_argument("--match", help="Regex filter over title and username.")
640
+ p.add_argument("--limit", type=int, default=None)
641
+ p.set_defaults(func=dialogs)
642
+
643
+ p = sub.add_parser("inspect", help="Resolve an entity and print recent message summaries.")
644
+ p.add_argument("entity", help="Username, invite-resolved entity, or joined chat/channel.")
645
+ p.add_argument("--limit", type=int, default=5)
646
+ p.set_defaults(func=inspect)
647
+
648
+ p = sub.add_parser("sync", help="Fetch date-bounded Telegram history into local SQLite.")
649
+ p.add_argument("entity", help="Username, id, invite-resolved entity, or joined chat/channel.")
650
+ p.add_argument("--since", required=True, help="Inclusive local date/datetime, e.g. 2026-05-08.")
651
+ p.add_argument("--until", help="Exclusive local date/datetime. Date values mean the next midnight.")
652
+ p.add_argument("--timezone", default=DEFAULT_TIMEZONE)
653
+ p.add_argument("--limit", type=int, default=None, help="Optional maximum messages to fetch from Telegram.")
654
+ p.set_defaults(func=sync_history)
655
+
656
+ p = sub.add_parser("today", help="Sync and print one local day's Telegram history.")
657
+ p.add_argument("entity", help="Username, id, invite-resolved entity, or joined chat/channel.")
658
+ p.add_argument("--day", help="Local day to read, e.g. 2026-05-08. Defaults to today.")
659
+ p.add_argument("--timezone", default=DEFAULT_TIMEZONE)
660
+ p.add_argument("--limit", type=int, default=None, help="Optional maximum messages to fetch from Telegram.")
661
+ p.add_argument("--print-limit", type=int, default=None, help="Optional maximum rows to print after syncing.")
662
+ p.add_argument("--match", help="Optional FTS5 search expression over local message text.")
663
+ p.add_argument("--format", choices=["text", "jsonl"], default="text")
664
+ p.add_argument("--max-text", type=int, default=900)
665
+ p.set_defaults(func=today)
666
+
667
+ p = sub.add_parser("query", help="Read previously synced Telegram history from local SQLite.")
668
+ p.add_argument("entity", nargs="?", help="Optional chat key or username.")
669
+ p.add_argument("--since", help="Inclusive local date/datetime.")
670
+ p.add_argument("--until", help="Exclusive local date/datetime. Date values mean the next midnight.")
671
+ p.add_argument("--timezone", default=DEFAULT_TIMEZONE)
672
+ p.add_argument("--match", help="Optional FTS5 search expression over local message text.")
673
+ p.add_argument("--limit", type=int, default=100)
674
+ p.add_argument("--format", choices=["text", "jsonl"], default="text")
675
+ p.add_argument("--max-text", type=int, default=900)
676
+ p.set_defaults(func=query_history)
677
+
678
+ return parser.parse_args()
679
+
680
+
681
+ def main() -> int:
682
+ args = parse_args()
683
+ return asyncio.run(args.func(args))
684
+
685
+
686
+ if __name__ == "__main__":
687
+ raise SystemExit(main())