tele-mess-core 0.3.0__py3-none-any.whl

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,3 @@
1
+ """Telegram message archive core."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -0,0 +1,4 @@
1
+ from .store import ArchiveStore
2
+
3
+ __all__ = ["ArchiveStore"]
4
+
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from typing import Callable
5
+
6
+
7
+ Migration = Callable[[sqlite3.Connection], None]
8
+
9
+
10
+ def apply_migrations(connection: sqlite3.Connection, current_version: int, target_version: int) -> None:
11
+ if current_version > target_version:
12
+ raise RuntimeError(
13
+ f"Database schema version {current_version} is newer than supported version {target_version}"
14
+ )
15
+ for version in range(current_version + 1, target_version + 1):
16
+ migration = MIGRATIONS.get(version)
17
+ if migration is None:
18
+ raise RuntimeError(f"Missing database migration for version {version}")
19
+ connection.execute("BEGIN IMMEDIATE")
20
+ try:
21
+ migration(connection)
22
+ connection.execute(f"PRAGMA user_version = {version}")
23
+ connection.execute(
24
+ "INSERT INTO meta(key, value) VALUES('schema_version', ?) "
25
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
26
+ (str(version),),
27
+ )
28
+ connection.commit()
29
+ except Exception:
30
+ connection.rollback()
31
+ raise
32
+
33
+
34
+ def _migration_13(connection: sqlite3.Connection) -> None:
35
+ _ensure_column(connection, "daily_summary_jobs", "request_json", "TEXT")
36
+ _ensure_column(connection, "daily_summary_jobs", "dedupe_key", "TEXT")
37
+ _ensure_column(connection, "daily_summary_jobs", "worker_id", "TEXT")
38
+ _ensure_column(connection, "daily_summary_jobs", "lease_until", "TEXT")
39
+ _ensure_column(connection, "daily_summary_jobs", "heartbeat_at", "TEXT")
40
+ _ensure_column(connection, "daily_summary_jobs", "attempt", "INTEGER NOT NULL DEFAULT 0")
41
+ connection.execute(
42
+ """
43
+ CREATE TABLE IF NOT EXISTS delivery_outbox (
44
+ outbox_id TEXT PRIMARY KEY,
45
+ summary_run_id TEXT NOT NULL,
46
+ job_id TEXT,
47
+ account_id TEXT NOT NULL,
48
+ origin_id INTEGER NOT NULL,
49
+ topic_id INTEGER NOT NULL DEFAULT 0,
50
+ chunk_index INTEGER NOT NULL,
51
+ chunk_count INTEGER NOT NULL,
52
+ content TEXT NOT NULL,
53
+ status TEXT NOT NULL DEFAULT 'pending',
54
+ attempts INTEGER NOT NULL DEFAULT 0,
55
+ message_id INTEGER,
56
+ last_error TEXT,
57
+ next_attempt_at TEXT,
58
+ created_at TEXT NOT NULL,
59
+ updated_at TEXT NOT NULL,
60
+ sent_at TEXT,
61
+ UNIQUE(summary_run_id, account_id, origin_id, topic_id, chunk_index)
62
+ )
63
+ """
64
+ )
65
+ connection.execute(
66
+ """
67
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_summary_jobs_active_dedupe
68
+ ON daily_summary_jobs(dedupe_key)
69
+ WHERE dedupe_key IS NOT NULL
70
+ AND status IN ('queued', 'running', 'cancel_requested')
71
+ """
72
+ )
73
+ connection.execute(
74
+ "CREATE INDEX IF NOT EXISTS idx_daily_summary_jobs_claim ON daily_summary_jobs(status, lease_until, started_at)"
75
+ )
76
+ connection.execute(
77
+ "CREATE INDEX IF NOT EXISTS idx_delivery_outbox_pending ON delivery_outbox(status, next_attempt_at, created_at)"
78
+ )
79
+
80
+
81
+ def _migration_14(connection: sqlite3.Connection) -> None:
82
+ statements = (
83
+ """
84
+ CREATE TRIGGER IF NOT EXISTS daily_summary_jobs_validate_insert
85
+ BEFORE INSERT ON daily_summary_jobs
86
+ WHEN NEW.status NOT IN ('queued', 'running', 'cancel_requested', 'completed', 'failed', 'canceled')
87
+ OR NEW.progress_total < 0
88
+ OR NEW.progress_current < 0
89
+ OR (NEW.progress_total > 0 AND NEW.progress_current > NEW.progress_total)
90
+ OR NEW.attempt < 0
91
+ BEGIN
92
+ SELECT RAISE(ABORT, 'invalid daily summary job state');
93
+ END
94
+ """,
95
+ """
96
+ CREATE TRIGGER IF NOT EXISTS daily_summary_jobs_validate_update
97
+ BEFORE UPDATE ON daily_summary_jobs
98
+ WHEN NEW.status NOT IN ('queued', 'running', 'cancel_requested', 'completed', 'failed', 'canceled')
99
+ OR NEW.progress_total < 0
100
+ OR NEW.progress_current < 0
101
+ OR (NEW.progress_total > 0 AND NEW.progress_current > NEW.progress_total)
102
+ OR NEW.attempt < 0
103
+ BEGIN
104
+ SELECT RAISE(ABORT, 'invalid daily summary job state');
105
+ END
106
+ """,
107
+ """
108
+ CREATE TRIGGER IF NOT EXISTS delivery_outbox_validate_insert
109
+ BEFORE INSERT ON delivery_outbox
110
+ WHEN NEW.status NOT IN ('pending', 'sending', 'retry', 'sent')
111
+ OR NEW.chunk_index <= 0
112
+ OR NEW.chunk_count <= 0
113
+ OR NEW.chunk_index > NEW.chunk_count
114
+ OR NEW.attempts < 0
115
+ BEGIN
116
+ SELECT RAISE(ABORT, 'invalid delivery outbox state');
117
+ END
118
+ """,
119
+ """
120
+ CREATE TRIGGER IF NOT EXISTS delivery_outbox_validate_update
121
+ BEFORE UPDATE ON delivery_outbox
122
+ WHEN NEW.status NOT IN ('pending', 'sending', 'retry', 'sent')
123
+ OR NEW.chunk_index <= 0
124
+ OR NEW.chunk_count <= 0
125
+ OR NEW.chunk_index > NEW.chunk_count
126
+ OR NEW.attempts < 0
127
+ BEGIN
128
+ SELECT RAISE(ABORT, 'invalid delivery outbox state');
129
+ END
130
+ """,
131
+ )
132
+ for statement in statements:
133
+ connection.execute(statement)
134
+
135
+
136
+ def _migration_15(connection: sqlite3.Connection) -> None:
137
+ connection.execute(
138
+ """
139
+ CREATE TABLE IF NOT EXISTS daily_summary_delivery (
140
+ id INTEGER PRIMARY KEY CHECK (id = 1),
141
+ enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
142
+ account_id TEXT,
143
+ origin_id INTEGER,
144
+ topic_id INTEGER NOT NULL DEFAULT 0,
145
+ updated_at TEXT NOT NULL,
146
+ CHECK (enabled = 0 OR (account_id IS NOT NULL AND account_id != '' AND origin_id IS NOT NULL))
147
+ )
148
+ """
149
+ )
150
+
151
+
152
+ def _migration_16(connection: sqlite3.Connection) -> None:
153
+ _ensure_column(connection, "daily_summary_records", "record_type", "TEXT NOT NULL DEFAULT 'summary'")
154
+ connection.execute(
155
+ """
156
+ UPDATE daily_summary_records
157
+ SET record_type = CASE
158
+ WHEN content_json IS NOT NULL
159
+ AND content_json != ''
160
+ AND json_valid(content_json)
161
+ THEN COALESCE(CAST(json_extract(content_json, '$.record_type') AS TEXT), 'summary')
162
+ ELSE 'summary'
163
+ END
164
+ WHERE record_type = 'summary'
165
+ """
166
+ )
167
+ connection.execute(
168
+ """
169
+ CREATE TABLE IF NOT EXISTS daily_message_points (
170
+ point_id TEXT PRIMARY KEY,
171
+ run_id TEXT NOT NULL,
172
+ package_run_id TEXT NOT NULL,
173
+ date TEXT NOT NULL,
174
+ timezone TEXT NOT NULL,
175
+ source TEXT NOT NULL DEFAULT 'telegram',
176
+ account_id TEXT NOT NULL,
177
+ origin_id INTEGER NOT NULL,
178
+ topic_id INTEGER NOT NULL DEFAULT 0,
179
+ origin_title TEXT,
180
+ message_id INTEGER,
181
+ occurred_at TEXT NOT NULL,
182
+ tags_json TEXT NOT NULL DEFAULT '[]',
183
+ tags_csv TEXT,
184
+ content TEXT NOT NULL CHECK (length(trim(content)) > 0),
185
+ telegram_deeplink TEXT,
186
+ permalink TEXT,
187
+ importance_score INTEGER NOT NULL DEFAULT 3 CHECK (importance_score BETWEEN 1 AND 5),
188
+ importance_reason TEXT,
189
+ origin_important INTEGER NOT NULL DEFAULT 0 CHECK (origin_important IN (0, 1)),
190
+ source_refs_json TEXT NOT NULL DEFAULT '[]',
191
+ provider TEXT,
192
+ created_at TEXT NOT NULL,
193
+ updated_at TEXT NOT NULL
194
+ )
195
+ """
196
+ )
197
+ connection.execute(
198
+ "CREATE INDEX IF NOT EXISTS idx_daily_summary_records_type_date "
199
+ "ON daily_summary_records(record_type, date, created_at)"
200
+ )
201
+ connection.execute(
202
+ "CREATE INDEX IF NOT EXISTS idx_daily_message_points_run "
203
+ "ON daily_message_points(run_id, occurred_at, point_id)"
204
+ )
205
+ connection.execute(
206
+ "CREATE INDEX IF NOT EXISTS idx_daily_message_points_lookup "
207
+ "ON daily_message_points(date, source, account_id, origin_id, topic_id, occurred_at)"
208
+ )
209
+ connection.execute(
210
+ "CREATE INDEX IF NOT EXISTS idx_daily_message_points_importance "
211
+ "ON daily_message_points(date, importance_score, occurred_at)"
212
+ )
213
+
214
+
215
+ def _migration_17(connection: sqlite3.Connection) -> None:
216
+ _ensure_column(connection, "daily_summary_jobs", "retry_at", "TEXT")
217
+ _ensure_column(connection, "daily_summary_jobs", "retry_count", "INTEGER NOT NULL DEFAULT 0")
218
+ for table in ("daily_summary_jobs", "daily_summary_runs"):
219
+ columns = {str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})").fetchall()}
220
+ if not {"error", "progress_json"}.issubset(columns):
221
+ continue
222
+ connection.execute(
223
+ f"""
224
+ UPDATE {table}
225
+ SET error = 'Codex usage limit reached',
226
+ progress_json = CASE
227
+ WHEN progress_json IS NOT NULL AND json_valid(progress_json)
228
+ THEN json_set(
229
+ progress_json,
230
+ '$.error', 'Codex usage limit reached',
231
+ '$.error_kind', 'codex_usage_limit',
232
+ '$.retryable', 1
233
+ )
234
+ ELSE progress_json
235
+ END
236
+ WHERE error LIKE '%chatgpt.com/codex/settings/usage%'
237
+ OR error LIKE '%You''ve hit your usage limit%'
238
+ """
239
+ )
240
+ connection.execute("DROP INDEX IF EXISTS idx_daily_summary_jobs_claim")
241
+ connection.execute(
242
+ "CREATE INDEX idx_daily_summary_jobs_claim "
243
+ "ON daily_summary_jobs(status, retry_at, lease_until, started_at)"
244
+ )
245
+
246
+
247
+ def _migration_18(connection: sqlite3.Connection) -> None:
248
+ table = connection.execute(
249
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'capture_cursors'"
250
+ ).fetchone()
251
+ if table is None:
252
+ return
253
+ _ensure_column(
254
+ connection,
255
+ "capture_cursors",
256
+ "history_scanned_through_id",
257
+ "INTEGER NOT NULL DEFAULT 0",
258
+ )
259
+ _ensure_column(
260
+ connection,
261
+ "capture_cursors",
262
+ "observed_max_message_id",
263
+ "INTEGER NOT NULL DEFAULT 0",
264
+ )
265
+ _ensure_column(connection, "capture_cursors", "backfill_head_message_id", "INTEGER")
266
+ _ensure_column(connection, "capture_cursors", "backfill_status", "TEXT")
267
+ _ensure_column(connection, "capture_cursors", "backfill_error", "TEXT")
268
+ _ensure_column(connection, "capture_cursors", "backfill_count", "INTEGER")
269
+ connection.execute(
270
+ """
271
+ UPDATE capture_cursors
272
+ SET history_scanned_through_id = 0,
273
+ observed_max_message_id = MAX(
274
+ COALESCE(observed_max_message_id, 0),
275
+ COALESCE(last_message_id, 0)
276
+ ),
277
+ last_message_id = MAX(
278
+ COALESCE(last_message_id, 0),
279
+ COALESCE(observed_max_message_id, 0)
280
+ ),
281
+ backfill_status = CASE
282
+ WHEN COALESCE(last_message_id, 0) > 0 THEN 'migration_rescan'
283
+ ELSE backfill_status
284
+ END
285
+ """
286
+ )
287
+
288
+
289
+ def _ensure_column(connection: sqlite3.Connection, table: str, column: str, definition: str) -> None:
290
+ rows = connection.execute(f"PRAGMA table_info({table})").fetchall()
291
+ names = {str(row[1]) for row in rows}
292
+ if column not in names:
293
+ connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
294
+
295
+
296
+ MIGRATIONS: dict[int, Migration] = {
297
+ 13: _migration_13,
298
+ 14: _migration_14,
299
+ 15: _migration_15,
300
+ 16: _migration_16,
301
+ 17: _migration_17,
302
+ 18: _migration_18,
303
+ }