@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.
- package/EXCLUDED.md +42 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/SECURITY.md +23 -0
- package/SOURCES.md +45 -0
- package/bin/skills.mjs +241 -0
- package/package.json +38 -0
- package/skills/1password/SKILL.md +94 -0
- package/skills/1password/agents/openai.yaml +4 -0
- package/skills/1password/references/item-management.md +80 -0
- package/skills/1password/references/op-cli.md +107 -0
- package/skills/apple-calendar-event/SKILL.md +81 -0
- package/skills/apple-calendar-event/agents/openai.yaml +4 -0
- package/skills/apple-calendar-event/scripts/calendar_audit.py +201 -0
- package/skills/apple-calendar-event/scripts/calendar_event.py +164 -0
- package/skills/bro-browser/SKILL.md +118 -0
- package/skills/bro-browser/agents/openai.yaml +4 -0
- package/skills/bro-browser/references/tool-map.md +102 -0
- package/skills/bro-browser/references/workflows.md +146 -0
- package/skills/bro-browser/scripts/bro-call.mjs +189 -0
- package/skills/calendar/SKILL.md +182 -0
- package/skills/calendar/agents/openai.yaml +4 -0
- package/skills/calendar/references/operations.md +255 -0
- package/skills/calendar/scripts/calendar_list_review.py +157 -0
- package/skills/calendar/scripts/event_dedupe_preview.py +155 -0
- package/skills/canvas/SKILL.md +70 -0
- package/skills/canvas/agents/openai.yaml +4 -0
- package/skills/canvas/references/canvas-api.md +76 -0
- package/skills/course-exam-review-planner/SKILL.md +127 -0
- package/skills/cx/SKILL.md +25 -0
- package/skills/gh-fix-ci/LICENSE.txt +201 -0
- package/skills/gh-fix-ci/SKILL.md +81 -0
- package/skills/gh-fix-ci/agents/openai.yaml +6 -0
- package/skills/gh-fix-ci/assets/github-small.svg +3 -0
- package/skills/gh-fix-ci/assets/github.png +0 -0
- package/skills/gh-fix-ci/scripts/inspect_pr_checks.py +509 -0
- package/skills/gh-review-workflow/SKILL.md +61 -0
- package/skills/gh-review-workflow/agents/openai.yaml +4 -0
- package/skills/gh-review-workflow/references/workflow.md +48 -0
- package/skills/gh-review-workflow/scripts/fetch_review_state.py +222 -0
- package/skills/gh-review-workflow/scripts/resolve_review_threads.py +83 -0
- package/skills/github/SKILL.md +74 -0
- package/skills/github/agents/openai.yaml +6 -0
- package/skills/github/assets/github-small.svg +3 -0
- package/skills/github/assets/github.png +0 -0
- package/skills/gws-calendar/SKILL.md +126 -0
- package/skills/gws-calendar-agenda/SKILL.md +52 -0
- package/skills/gws-calendar-insert/SKILL.md +66 -0
- package/skills/gws-docs/SKILL.md +48 -0
- package/skills/gws-docs-write/SKILL.md +49 -0
- package/skills/gws-drive/SKILL.md +137 -0
- package/skills/gws-drive-upload/SKILL.md +52 -0
- package/skills/gws-gmail/SKILL.md +62 -0
- package/skills/gws-gmail-forward/SKILL.md +55 -0
- package/skills/gws-gmail-reply/SKILL.md +58 -0
- package/skills/gws-gmail-reply-all/SKILL.md +62 -0
- package/skills/gws-gmail-send/SKILL.md +57 -0
- package/skills/gws-gmail-triage/SKILL.md +50 -0
- package/skills/gws-gmail-watch/SKILL.md +58 -0
- package/skills/gws-shared/SKILL.md +27 -0
- package/skills/helium-browser-mcp/SKILL.md +137 -0
- package/skills/helium-browser-mcp/agents/openai.yaml +4 -0
- package/skills/helium-browser-mcp/scripts/obmcp.mjs +92 -0
- package/skills/helium-browser-mcp/scripts/openbrowsermcp-stdio-proxy.mjs +170 -0
- package/skills/learn/SKILL.md +122 -0
- package/skills/learn/agents/openai.yaml +7 -0
- package/skills/learn/assets/AGENTS.template.md +33 -0
- package/skills/learn/assets/errorlog.template.typ +61 -0
- package/skills/learn/assets/reading-sequence.template.md +23 -0
- package/skills/learn/assets/source-index.template.md +17 -0
- package/skills/learn/assets/tasklog.template.typ +57 -0
- package/skills/learn/assets/workbook.template.typ +60 -0
- package/skills/learn/references/learning-science.md +103 -0
- package/skills/learn/scripts/init_learning_workspace.py +70 -0
- package/skills/macos-messages/SKILL.md +258 -0
- package/skills/memory/SKILL.md +33 -0
- package/skills/memory/codex.md +186 -0
- package/skills/memory/opencode.md +164 -0
- package/skills/mimestreamctl/SKILL.md +170 -0
- package/skills/mimestreamctl/agents/openai.yaml +4 -0
- package/skills/mimestreamctl/scripts/mimestreamctl +33 -0
- package/skills/mon/SKILL.md +51 -0
- package/skills/mon/scripts/mon_spend_review.py +458 -0
- package/skills/ocr/SKILL.md +136 -0
- package/skills/ocr/agents/openai.yaml +4 -0
- package/skills/ocr/references/local-ocr-best-practices.md +297 -0
- package/skills/ocr/references/mineru-api.md +159 -0
- package/skills/ocr/scripts/ocr-router +22 -0
- package/skills/ocr/scripts/ocr_router.py +741 -0
- package/skills/panopto-mp4-bulk-download/SKILL.md +57 -0
- package/skills/panopto-mp4-bulk-download/agents/openai.yaml +4 -0
- package/skills/panopto-mp4-bulk-download/references/url-patterns.md +26 -0
- package/skills/panopto-mp4-bulk-download/scripts/panopto_bulk_mp4.sh +213 -0
- package/skills/rust-systems-style/SKILL.md +109 -0
- package/skills/rust-systems-style/agents/openai.yaml +4 -0
- package/skills/rust-systems-style/references/rust-review-checklist.md +77 -0
- package/skills/rust-systems-style/references/style-sources.md +68 -0
- package/skills/ship-ai-native-cli/SKILL.md +76 -0
- package/skills/ship-ai-native-cli/agents/openai.yaml +4 -0
- package/skills/ship-ai-native-cli/references/case-notes.md +83 -0
- package/skills/ship-ai-native-cli/references/product-method.md +82 -0
- package/skills/ship-ai-native-cli/references/release-checklist.md +147 -0
- package/skills/ship-ai-native-cli/references/rust-cli-shape.md +111 -0
- package/skills/telegram-mtproto-session/SKILL.md +125 -0
- package/skills/telegram-mtproto-session/agents/openai.yaml +4 -0
- package/skills/telegram-mtproto-session/scripts/telegram_session.py +687 -0
- package/skills/tg/SKILL.md +173 -0
- package/skills/things3-manager/SKILL.md +116 -0
- package/skills/things3-manager/scripts/things +42 -0
- package/skills/things3-manager/scripts/things_cli.py +514 -0
- package/skills/web-artifacts-builder/LICENSE.txt +202 -0
- package/skills/web-artifacts-builder/SKILL.md +74 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +54 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +379 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/yeet/LICENSE.txt +201 -0
- package/skills/yeet/SKILL.md +71 -0
- package/skills/yeet/agents/openai.yaml +6 -0
- package/skills/yeet/assets/yeet-small.svg +3 -0
- 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())
|