telepost-bot 2.78.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.
Files changed (165) hide show
  1. config/__init__.py +3 -0
  2. config/fly-telepost-policy.example.json +16 -0
  3. config/review_policy.md +64 -0
  4. config/settings.py +425 -0
  5. database/__init__.py +3 -0
  6. database/db_manager.py +1057 -0
  7. handlers/__init__.py +15 -0
  8. handlers/api_commands.py +73 -0
  9. handlers/automation.py +438 -0
  10. handlers/botconfig.py +255 -0
  11. handlers/callback_handlers.py +679 -0
  12. handlers/channel_listener.py +923 -0
  13. handlers/command_handlers.py +615 -0
  14. handlers/conversation.py +74 -0
  15. handlers/error_handler.py +99 -0
  16. handlers/index_handlers.py +201 -0
  17. handlers/mode_selection.py +116 -0
  18. handlers/moderation.py +145 -0
  19. handlers/preview_handlers.py +286 -0
  20. handlers/publish.py +1399 -0
  21. handlers/reaction_stats.py +240 -0
  22. handlers/recovery.py +89 -0
  23. handlers/review.py +2107 -0
  24. handlers/schedule_status.py +82 -0
  25. handlers/search_handlers.py +987 -0
  26. handlers/stats_handlers.py +439 -0
  27. handlers/upload.py +132 -0
  28. main.py +939 -0
  29. mcp_server/__init__.py +1 -0
  30. mcp_server/review_client.py +151 -0
  31. mcp_server/server.py +182 -0
  32. models/__init__.py +3 -0
  33. models/state.py +13 -0
  34. protocol/__init__.py +0 -0
  35. protocol/v1/SOURCES.sha256 +15 -0
  36. protocol/v1/__init__.py +0 -0
  37. protocol/v1/error-mapping.json +46 -0
  38. protocol/v1/fixtures/ackresult.candidate_search.json +6 -0
  39. protocol/v1/fixtures/capabilities.pixivflow.json +27 -0
  40. protocol/v1/fixtures/event.job.expired.json +13 -0
  41. protocol/v1/fixtures/event.job.succeeded.json +13 -0
  42. protocol/v1/fixtures/eventpage.candidate_search.json +26 -0
  43. protocol/v1/fixtures/job.cancelled.candidate_search.json +24 -0
  44. protocol/v1/fixtures/job.failed.no_candidate.json +30 -0
  45. protocol/v1/fixtures/job.queued.json +19 -0
  46. protocol/v1/fixtures/job.running.json +19 -0
  47. protocol/v1/fixtures/job.succeeded.candidate_search.json +54 -0
  48. protocol/v1/fixtures/jobpage.candidate_search.json +25 -0
  49. protocol/v1/fixtures/task.candidate_search.json +19 -0
  50. protocol/v1/fixtures/task.candidate_search.target_only.json +10 -0
  51. protocol/v1/protocol.schema.json +352 -0
  52. run.py +869 -0
  53. services/__init__.py +1 -0
  54. services/review_service.py +1120 -0
  55. telepost/__init__.py +0 -0
  56. telepost/api/__init__.py +0 -0
  57. telepost/application/__init__.py +0 -0
  58. telepost/application/admin_ops.py +333 -0
  59. telepost/application/automation.py +131 -0
  60. telepost/application/delivery_planner.py +245 -0
  61. telepost/application/editorial.py +200 -0
  62. telepost/application/hot.py +253 -0
  63. telepost/application/novel_fallback_card.py +145 -0
  64. telepost/application/novel_preview.py +289 -0
  65. telepost/application/pixivflow_jobs.py +1191 -0
  66. telepost/application/post_media.py +136 -0
  67. telepost/application/posts.py +118 -0
  68. telepost/application/publication.py +712 -0
  69. telepost/application/reaction_backfill.py +104 -0
  70. telepost/application/recovery.py +281 -0
  71. telepost/application/refetch.py +453 -0
  72. telepost/application/rerender.py +275 -0
  73. telepost/application/resubmit.py +252 -0
  74. telepost/application/review_queue.py +944 -0
  75. telepost/application/submitter_notify.py +337 -0
  76. telepost/application/telepress_provider.py +170 -0
  77. telepost/build_info.py +39 -0
  78. telepost/domain/__init__.py +0 -0
  79. telepost/domain/automation.py +168 -0
  80. telepost/domain/delivery.py +248 -0
  81. telepost/domain/editorial.py +196 -0
  82. telepost/domain/errors.py +74 -0
  83. telepost/domain/hot.py +140 -0
  84. telepost/domain/media.py +120 -0
  85. telepost/domain/navigation.py +99 -0
  86. telepost/domain/novel_preview.py +137 -0
  87. telepost/domain/packing.py +51 -0
  88. telepost/domain/presentation.py +161 -0
  89. telepost/domain/presentation_policy.py +175 -0
  90. telepost/domain/refetch_state.py +316 -0
  91. telepost/domain/review.py +75 -0
  92. telepost/domain/submission.py +118 -0
  93. telepost/miniapp/__init__.py +6 -0
  94. telepost/miniapp/auth.py +137 -0
  95. telepost/miniapp/rbac.py +102 -0
  96. telepost/miniapp/role_bindings.py +131 -0
  97. telepost/miniapp/session.py +181 -0
  98. telepost/observability/__init__.py +1 -0
  99. telepost/observability/audit.py +123 -0
  100. telepost/observability/cli.py +148 -0
  101. telepost/observability/doctor.py +1180 -0
  102. telepost/observability/errors.py +51 -0
  103. telepost/observability/redact.py +76 -0
  104. telepost/storage/__init__.py +0 -0
  105. telepost/storage/sqlite/__init__.py +0 -0
  106. telepost/storage/sqlite/automation.py +185 -0
  107. telepost/storage/sqlite/columns.py +18 -0
  108. telepost/storage/sqlite/editorial.py +226 -0
  109. telepost/storage/sqlite/ledger.py +150 -0
  110. telepost/storage/sqlite/media_assets.py +141 -0
  111. telepost/storage/sqlite/moderation.py +82 -0
  112. telepost/storage/sqlite/novel_preview.py +91 -0
  113. telepost/storage/sqlite/posts.py +68 -0
  114. telepost/storage/sqlite/protocol_events.py +192 -0
  115. telepost/storage/sqlite/recovery.py +81 -0
  116. telepost/storage/sqlite/refetch.py +797 -0
  117. telepost/storage/sqlite/reviews.py +802 -0
  118. telepost/storage/sqlite/submitter_notifications.py +189 -0
  119. telepost/telegram/__init__.py +0 -0
  120. telepost/telegram/capture.py +35 -0
  121. telepost/telegram/delivery/__init__.py +0 -0
  122. telepost/telegram/delivery/discussion.py +329 -0
  123. telepost/telegram/delivery/executor.py +369 -0
  124. telepost/telegram/delivery/gateway.py +107 -0
  125. telepost/telegram/delivery/legacy_runner.py +181 -0
  126. telepost/telegram/delivery/planner.py +120 -0
  127. telepost/telegram/delivery/preparation.py +1023 -0
  128. telepost/telegram/delivery/registry.py +112 -0
  129. telepost/telegram/delivery/sender.py +255 -0
  130. telepost/telegram/review_keyboard.py +266 -0
  131. telepost/telegram/review_stager.py +747 -0
  132. telepost_bot-2.78.0.dist-info/METADATA +164 -0
  133. telepost_bot-2.78.0.dist-info/RECORD +165 -0
  134. telepost_bot-2.78.0.dist-info/WHEEL +5 -0
  135. telepost_bot-2.78.0.dist-info/entry_points.txt +2 -0
  136. telepost_bot-2.78.0.dist-info/licenses/LICENSE +21 -0
  137. telepost_bot-2.78.0.dist-info/top_level.txt +13 -0
  138. ui/__init__.py +0 -0
  139. ui/keyboards.py +281 -0
  140. ui/messages.py +512 -0
  141. utils/__init__.py +3 -0
  142. utils/api_server.py +3110 -0
  143. utils/api_tokens.py +62 -0
  144. utils/blacklist.py +188 -0
  145. utils/cache.py +51 -0
  146. utils/config_wizard.py +92 -0
  147. utils/database.py +201 -0
  148. utils/file_validator.py +173 -0
  149. utils/heat_calculator.py +184 -0
  150. utils/helper_functions.py +662 -0
  151. utils/index_manager.py +477 -0
  152. utils/logging_config.py +124 -0
  153. utils/maintenance_jobs.py +71 -0
  154. utils/polling_server.py +85 -0
  155. utils/run_mode.py +36 -0
  156. utils/runtime_policy.py +86 -0
  157. utils/schedule_watchdog.py +77 -0
  158. utils/search_engine.py +515 -0
  159. utils/submission.py +105 -0
  160. utils/webhook_server.py +334 -0
  161. webapp/__init__.py +4 -0
  162. webapp/dist/assets/2d82b92e720462f8dd3b-Bh4Fahcw.svg +372 -0
  163. webapp/dist/assets/index-C-xxEN9W.js +110 -0
  164. webapp/dist/assets/index-DJgL81We.css +1 -0
  165. webapp/dist/index.html +15 -0
database/db_manager.py ADDED
@@ -0,0 +1,1057 @@
1
+ """
2
+ 数据库管理模块
3
+ """
4
+ import logging
5
+ import os
6
+ from datetime import datetime
7
+ from contextlib import asynccontextmanager
8
+ import aiosqlite
9
+
10
+ from config.settings import DB_PATH, TIMEOUT, DB_CACHE_KB
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ @asynccontextmanager
15
+ async def get_db():
16
+ """
17
+ 数据库连接上下文管理器
18
+
19
+ Yields:
20
+ aiosqlite.Connection: 数据库连接对象
21
+ """
22
+ conn = await aiosqlite.connect(DB_PATH)
23
+ conn.row_factory = aiosqlite.Row
24
+ # 优化 SQLite 运行参数,降低 I/O 延迟
25
+ try:
26
+ await conn.execute("PRAGMA journal_mode=WAL;")
27
+ await conn.execute("PRAGMA synchronous=NORMAL;")
28
+ await conn.execute("PRAGMA temp_store=MEMORY;")
29
+ # Writers serialize in WAL; wait up to 5s instead of throwing
30
+ # "database is locked" to concurrent conditional UPDATEs (claims).
31
+ await conn.execute("PRAGMA busy_timeout=5000;")
32
+ # 通过负值设置 KB 为单位的 page cache 大小(默认为 4MB,可通过 DB_CACHE_KB 配置)
33
+ await conn.execute(f"PRAGMA cache_size={-int(DB_CACHE_KB)};")
34
+ except Exception:
35
+ pass
36
+ try:
37
+ yield conn
38
+ await conn.commit()
39
+ except Exception as e:
40
+ await conn.rollback()
41
+ raise e
42
+ finally:
43
+ await conn.close()
44
+
45
+ async def init_db():
46
+ """
47
+ 初始化数据库
48
+ """
49
+ try:
50
+ async with get_db() as conn:
51
+ # 临时投稿数据表
52
+ await conn.execute('''
53
+ CREATE TABLE IF NOT EXISTS submissions (
54
+ user_id INTEGER PRIMARY KEY,
55
+ timestamp REAL,
56
+ mode TEXT,
57
+ image_id TEXT,
58
+ document_id TEXT,
59
+ tags TEXT,
60
+ link TEXT,
61
+ title TEXT,
62
+ note TEXT,
63
+ spoiler TEXT,
64
+ anonymous TEXT DEFAULT 'false',
65
+ username TEXT
66
+ )
67
+ ''')
68
+
69
+ # 匿名投稿字段(存量表补列)
70
+ try:
71
+ await conn.execute("ALTER TABLE submissions ADD COLUMN anonymous TEXT DEFAULT 'false'")
72
+ logger.info("已添加 anonymous 字段到 submissions 表")
73
+ except Exception:
74
+ pass # 字段已存在
75
+
76
+ # API 访问令牌(用于 /api/v1 自动化投稿,仅存哈希不存明文)
77
+ await conn.execute('''
78
+ CREATE TABLE IF NOT EXISTS api_tokens (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ token_hash TEXT UNIQUE NOT NULL,
81
+ telegram_user_id INTEGER NOT NULL,
82
+ name TEXT DEFAULT '',
83
+ created_at REAL,
84
+ revoked INTEGER DEFAULT 0
85
+ )
86
+ ''')
87
+
88
+ # API/聊天投稿审核队列。媒体先上传到私有审核群,此处只保存
89
+ # Telegram file_id 与审核状态,避免 Fly 重启后丢失待审核文件。
90
+ await conn.execute('''
91
+ CREATE TABLE IF NOT EXISTS pending_reviews (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ idempotency_key TEXT UNIQUE NOT NULL,
94
+ source TEXT NOT NULL DEFAULT 'api',
95
+ status TEXT NOT NULL DEFAULT 'pending',
96
+ user_id INTEGER NOT NULL,
97
+ username TEXT DEFAULT '',
98
+ title TEXT DEFAULT '',
99
+ tags TEXT DEFAULT '',
100
+ note TEXT DEFAULT '',
101
+ link TEXT DEFAULT '',
102
+ anonymous INTEGER DEFAULT 0,
103
+ spoiler INTEGER DEFAULT 0,
104
+ media_json TEXT NOT NULL DEFAULT '[]',
105
+ documents_json TEXT NOT NULL DEFAULT '[]',
106
+ review_chat_id TEXT NOT NULL,
107
+ review_message_ids TEXT NOT NULL DEFAULT '[]',
108
+ review_message_specs TEXT NOT NULL DEFAULT '[]',
109
+ control_message_id INTEGER,
110
+ created_at REAL NOT NULL,
111
+ updated_at REAL NOT NULL,
112
+ decided_at REAL,
113
+ decided_by INTEGER,
114
+ published_message_id INTEGER,
115
+ target_id TEXT NOT NULL DEFAULT '',
116
+ source_label TEXT NOT NULL DEFAULT '',
117
+ source_ref TEXT NOT NULL DEFAULT '',
118
+ scheduled_at TEXT NOT NULL DEFAULT '',
119
+ error TEXT DEFAULT ''
120
+ )
121
+ ''')
122
+ try:
123
+ await conn.execute(
124
+ "ALTER TABLE pending_reviews "
125
+ "ADD COLUMN source TEXT NOT NULL DEFAULT 'api'"
126
+ )
127
+ logger.info("已添加 source 字段到 pending_reviews 表")
128
+ except Exception:
129
+ pass # 字段已存在
130
+ try:
131
+ await conn.execute(
132
+ "ALTER TABLE pending_reviews "
133
+ "ADD COLUMN target_id TEXT NOT NULL DEFAULT ''"
134
+ )
135
+ logger.info("已添加 target_id 字段到 pending_reviews 表")
136
+ except Exception:
137
+ pass # 字段已存在
138
+ # Generic, bounded provenance for API submissions (any client).
139
+ # TelePost only stores/displays these; it never interprets them.
140
+ for column, ddl in (
141
+ ("source_label", "TEXT NOT NULL DEFAULT ''"),
142
+ ("source_ref", "TEXT NOT NULL DEFAULT ''"),
143
+ ("scheduled_at", "TEXT NOT NULL DEFAULT ''"),
144
+ ):
145
+ try:
146
+ await conn.execute(
147
+ f"ALTER TABLE pending_reviews ADD COLUMN {column} {ddl}"
148
+ )
149
+ except Exception:
150
+ pass # 字段已存在
151
+ await conn.execute(
152
+ 'CREATE INDEX IF NOT EXISTS idx_pending_reviews_status_created '
153
+ 'ON pending_reviews(status, created_at DESC)'
154
+ )
155
+
156
+ # Downstream reconciliation keys (PixivFlow ledger / historical dedupe).
157
+ # Additive, nullable; older rows simply do not participate.
158
+ for column, ddl in (
159
+ ("pixiv_id", "TEXT NOT NULL DEFAULT ''"),
160
+ ("work_type", "TEXT NOT NULL DEFAULT ''"),
161
+ ("delivery_target", "TEXT NOT NULL DEFAULT ''"),
162
+ ):
163
+ try:
164
+ await conn.execute(
165
+ f"ALTER TABLE pending_reviews ADD COLUMN {column} {ddl}"
166
+ )
167
+ except Exception:
168
+ pass # column already exists
169
+ await conn.execute(
170
+ 'CREATE INDEX IF NOT EXISTS idx_pending_reviews_dedupe '
171
+ "ON pending_reviews(delivery_target, work_type, pixiv_id) "
172
+ "WHERE pixiv_id <> ''"
173
+ )
174
+
175
+ # Review lineage for repeatable "重抓/换一张":
176
+ # - review_chain_id: stable identity of one logical review, shared
177
+ # by A → B → C replacement generations of the same review thread.
178
+ # - generation: 0 for the original, +1 per successful replacement.
179
+ # - supersedes_review_id: the review this one replaced (NULL/link
180
+ # to 0 for originals). Guards stale-button clicks and keeps the
181
+ # chain walkable without parsing Telegram captions.
182
+ # - refetch_request_id: request UUID of the refetch attempt that
183
+ # produced THIS review (empty for scheduled/original submissions).
184
+ # PixivFlow carries it in the submission payload; on arrival the
185
+ # old review is superseded only after this row is durably created
186
+ # (commit-after-success).
187
+ for column, ddl in (
188
+ ("review_chain_id", "TEXT NOT NULL DEFAULT ''"),
189
+ ("generation", "INTEGER NOT NULL DEFAULT 0"),
190
+ ("supersedes_review_id", "INTEGER"),
191
+ ("refetch_request_id", "TEXT NOT NULL DEFAULT ''"),
192
+ ):
193
+ try:
194
+ await conn.execute(
195
+ f"ALTER TABLE pending_reviews ADD COLUMN {column} {ddl}"
196
+ )
197
+ except Exception:
198
+ pass # column already exists
199
+ await conn.execute(
200
+ 'CREATE INDEX IF NOT EXISTS idx_pending_reviews_chain '
201
+ 'ON pending_reviews(review_chain_id, generation)'
202
+ )
203
+ await conn.execute(
204
+ 'CREATE INDEX IF NOT EXISTS idx_pending_reviews_supersedes '
205
+ 'ON pending_reviews(supersedes_review_id) WHERE supersedes_review_id IS NOT NULL'
206
+ )
207
+
208
+ # Identity / provenance model (§identity): the review's OWNER is
209
+ # independent of the request ACTOR and the transport SOURCE.
210
+ # submitter_user_id / submitter_username: the verified HUMAN owner
211
+ # (NULL for service/automatic submissions), i.e. the only rows
212
+ # that may appear under /me/submissions.
213
+ # actor_kind / actor_subject: WHO/WHAT executed the request
214
+ # ('user' for Telegram/Mini App principals, 'service' for API
215
+ # tokens / automatic delivery), never used for ownership.
216
+ # legacy user_id/username keep the request identity for display and
217
+ # old idempotency normalization; service rows must NOT copy it into
218
+ # submitter_user_id.
219
+ for column, ddl in (
220
+ ("submitter_user_id", "INTEGER"),
221
+ ("submitter_username", "TEXT"),
222
+ ("submitter_display_name", "TEXT"),
223
+ ("actor_kind", "TEXT NOT NULL DEFAULT 'user'"),
224
+ ("actor_subject", "TEXT NOT NULL DEFAULT ''"),
225
+ ):
226
+ try:
227
+ await conn.execute(
228
+ f"ALTER TABLE pending_reviews ADD COLUMN {column} {ddl}"
229
+ )
230
+ except Exception:
231
+ pass # column already exists
232
+ await conn.execute(
233
+ 'CREATE INDEX IF NOT EXISTS idx_pending_reviews_submitter '
234
+ 'ON pending_reviews(submitter_user_id, created_at DESC, id DESC) '
235
+ 'WHERE submitter_user_id IS NOT NULL'
236
+ )
237
+ # Data-class marker (§data-class): explicit, durable classification
238
+ # of whether a review row carries REAL production data ('real',
239
+ # default) or TEST/fixture data ('test'). The marker is the ONLY
240
+ # criterion the test-data purge may use — never a username pattern
241
+ # (the fixc-verify LIKE cleanup deleted the real review #140 during
242
+ # the 2026-09-28 incident). A cascade/cleanup must also assert a
243
+ # chain carries no real row before deleting (see purge_test_data).
244
+ for column, ddl in (("data_class", "TEXT NOT NULL DEFAULT 'real'"),):
245
+ try:
246
+ await conn.execute(
247
+ f"ALTER TABLE pending_reviews ADD COLUMN {column} {ddl}"
248
+ )
249
+ except Exception:
250
+ pass # column already exists
251
+ await conn.execute(
252
+ 'CREATE INDEX IF NOT EXISTS idx_pending_reviews_data_class '
253
+ 'ON pending_reviews(data_class, created_at DESC)'
254
+ )
255
+ # Per-message media specs (aligned with review_message_ids so the
256
+ # spoiler toggle can re-edit each already-sent preview message).
257
+ for column, ddl in (
258
+ ("review_message_specs", "TEXT NOT NULL DEFAULT '[]'"),
259
+ ):
260
+ try:
261
+ await conn.execute(
262
+ f"ALTER TABLE pending_reviews ADD COLUMN {column} {ddl}"
263
+ )
264
+ except Exception:
265
+ pass # column already exists
266
+
267
+ # User-facing history soft-delete (§my-submissions-delete): 1 hides
268
+ # the whole review chain from the owner's /me history without
269
+ # touching the review queue, published channel messages, or the
270
+ # audit trail. Only terminal rows may be hidden (enforced in repo).
271
+ try:
272
+ await conn.execute(
273
+ "ALTER TABLE pending_reviews "
274
+ "ADD COLUMN hidden_from_submitter INTEGER NOT NULL DEFAULT 0"
275
+ )
276
+ except Exception:
277
+ pass # column already exists
278
+ await conn.execute(
279
+ 'CREATE INDEX IF NOT EXISTS idx_pending_reviews_chain_hidden '
280
+ 'ON pending_reviews(review_chain_id, hidden_from_submitter)'
281
+ )
282
+
283
+ # Bootstrap lineage for EVERY existing review: a chain is anchored at
284
+ # the first review we know about ("chain-<id>"), so old pending rows
285
+ # stay refetchable with no re-submission. New replacements inherit
286
+ # their source's chain id, so the original anchor never changes.
287
+ await conn.execute(
288
+ "UPDATE pending_reviews SET review_chain_id = 'chain-' || id "
289
+ "WHERE review_chain_id = '' OR review_chain_id IS NULL"
290
+ )
291
+
292
+ # Durable refetch attempts: one row per user click / transport retry.
293
+ # state: the CANONICAL job state machine
294
+ # (telepost/domain/refetch_state.py):
295
+ # requested → searching → filtering → candidate_found
296
+ # → replaced, with terminal failed | timeout | no_candidate
297
+ # | cancelled. Rows written by TelePost <= 2.68.x use the
298
+ # legacy vocabulary (requested/admitted/replaced/
299
+ # no_alternative/failed/obsolete) and are normalized below;
300
+ # the mapping is the single source of truth in that module.
301
+ # request_id = durable idempotency key sent to PixivFlow; the same
302
+ # id always maps to the same attempt (same slot).
303
+ # callback_key = Telegram callback_query.id (or a persisted UUID
304
+ # fallback) so webhook redelivery of the SAME click
305
+ # reuses the attempt instead of starting a second one.
306
+ # updated_at / last_remote_state / notify_count / terminal_reason /
307
+ # result_review_id / operation_id are the progress
308
+ # projection + timeline inputs (P0 slice).
309
+ # The partial UNIQUE index is the DATA-LAYER one-active-per-chain
310
+ # guard: only one non-terminal attempt may exist per review chain.
311
+ await conn.execute('''
312
+ CREATE TABLE IF NOT EXISTS refetch_attempts (
313
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
314
+ callback_key TEXT NOT NULL UNIQUE,
315
+ request_id TEXT NOT NULL UNIQUE,
316
+ review_chain_id TEXT NOT NULL,
317
+ generation INTEGER NOT NULL DEFAULT 0,
318
+ source_review_id INTEGER NOT NULL,
319
+ source_candidate_id TEXT NOT NULL DEFAULT '',
320
+ result_candidate_id TEXT NOT NULL DEFAULT '',
321
+ state TEXT NOT NULL DEFAULT 'requested',
322
+ slot_id TEXT NOT NULL DEFAULT '',
323
+ failure_code TEXT NOT NULL DEFAULT '',
324
+ scanned INTEGER NOT NULL DEFAULT 0,
325
+ skipped_duplicate INTEGER NOT NULL DEFAULT 0,
326
+ skipped_invalid INTEGER NOT NULL DEFAULT 0,
327
+ skipped_unavailable INTEGER NOT NULL DEFAULT 0,
328
+ created_at REAL NOT NULL,
329
+ started_at REAL,
330
+ finished_at REAL,
331
+ last_progress_notified_at REAL,
332
+ updated_at REAL,
333
+ last_remote_state TEXT NOT NULL DEFAULT '',
334
+ notify_count INTEGER NOT NULL DEFAULT 0,
335
+ terminal_reason TEXT NOT NULL DEFAULT '',
336
+ result_review_id INTEGER,
337
+ operation_id TEXT NOT NULL DEFAULT '',
338
+ terminal_notified_at REAL,
339
+ heartbeat_at REAL,
340
+ heartbeat_count INTEGER NOT NULL DEFAULT 0,
341
+ remote_heartbeat_at REAL,
342
+ remote_state_at REAL,
343
+ next_poll_at REAL,
344
+ poll_failures INTEGER NOT NULL DEFAULT 0
345
+ )
346
+ ''')
347
+ # Older TelePost databases: add every column the CREATE TABLE above
348
+ # carries, idempotently (one try/except per column, same pattern as
349
+ # pending_reviews).
350
+ for column, ddl in (
351
+ ("last_progress_notified_at", "REAL"),
352
+ ("updated_at", "REAL"),
353
+ ("last_remote_state", "TEXT NOT NULL DEFAULT ''"),
354
+ ("notify_count", "INTEGER NOT NULL DEFAULT 0"),
355
+ ("terminal_reason", "TEXT NOT NULL DEFAULT ''"),
356
+ ("result_review_id", "INTEGER"),
357
+ ("operation_id", "TEXT NOT NULL DEFAULT ''"),
358
+ # §refetch-terminal-notify: the TERMINAL-notification clock.
359
+ # Separate from last_progress_notified_at on purpose — a
360
+ # progress reminder must never masquerade as the terminal
361
+ # notice (that was a silent-terminal path), and the "exactly
362
+ # one terminal message" guarantee stays idempotent per attempt.
363
+ ("terminal_notified_at", "REAL"),
364
+ # §refetch-lifecycle job heartbeat: the 30-second poller owns
365
+ # these clocks. ``heartbeat_at`` proves OUR watchdog is alive;
366
+ # the ``remote_*`` clocks prove the REMOTE slot is alive. A
367
+ # stall is only "no state change AND no fresh heartbeat".
368
+ ("heartbeat_at", "REAL"),
369
+ ("heartbeat_count", "INTEGER NOT NULL DEFAULT 0"),
370
+ ("remote_heartbeat_at", "REAL"),
371
+ ("remote_state_at", "REAL"),
372
+ ("next_poll_at", "REAL"),
373
+ ("poll_failures", "INTEGER NOT NULL DEFAULT 0"),
374
+ ):
375
+ try:
376
+ await conn.execute(
377
+ f"ALTER TABLE refetch_attempts ADD COLUMN {column} {ddl}"
378
+ )
379
+ except Exception:
380
+ pass # column already exists
381
+ # Rows that already carried a notification keep their one-shot claim
382
+ # (a redeploy must never re-message an old card). Rows with
383
+ # notify_count = 0 are left NULL: they are historical silent
384
+ # terminals the doctor reports as legacy WARN, never re-notified.
385
+ await conn.execute(
386
+ "UPDATE refetch_attempts SET terminal_notified_at = "
387
+ "COALESCE(finished_at, updated_at, created_at) "
388
+ "WHERE terminal_notified_at IS NULL "
389
+ "AND state IN ('replaced','failed','timeout','no_candidate',"
390
+ "'cancelled','no_alternative','obsolete') "
391
+ "AND COALESCE(notify_count, 0) > 0"
392
+ )
393
+ # Backfill the progress clock for rows written before it existed.
394
+ await conn.execute(
395
+ "UPDATE refetch_attempts SET updated_at = "
396
+ "COALESCE(updated_at, finished_at, started_at, created_at) "
397
+ "WHERE updated_at IS NULL"
398
+ )
399
+ # Normalize legacy states in place (idempotent). Keep in sync with
400
+ # telepost/domain/refetch_state.py::_FROM_LEGACY.
401
+ for legacy, canonical in (
402
+ ("admitted", "searching"),
403
+ ("no_alternative", "no_candidate"),
404
+ ("obsolete", "cancelled"),
405
+ ):
406
+ await conn.execute(
407
+ "UPDATE refetch_attempts SET state=? WHERE state=?",
408
+ (canonical, legacy),
409
+ )
410
+ # The one-active-per-chain guard must cover the CANONICAL active set.
411
+ # The WHERE clause of an index cannot be altered, so rebuild it.
412
+ # Safe: normalization is cardinality-preserving (requested/admitted
413
+ # → requested/searching), so the old index already guaranteed ≤1.
414
+ await conn.execute('DROP INDEX IF EXISTS idx_refetch_one_active')
415
+ await conn.execute(
416
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_refetch_one_active '
417
+ "ON refetch_attempts(review_chain_id) "
418
+ "WHERE state IN ('requested','searching','filtering','candidate_found')"
419
+ )
420
+ await conn.execute(
421
+ 'CREATE INDEX IF NOT EXISTS idx_refetch_attempts_chain '
422
+ 'ON refetch_attempts(review_chain_id, created_at DESC)'
423
+ )
424
+
425
+ # Per-attempt timeline: every state change is durable and queryable.
426
+ # Before this table the only trace was audit_events, which cannot be
427
+ # sliced per attempt — so "where did it get stuck" was unanswerable.
428
+ # Written by RefetchRepository._apply_transition() on the SAME
429
+ # connection/transaction as the state change.
430
+ await conn.execute('''
431
+ CREATE TABLE IF NOT EXISTS refetch_events (
432
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
433
+ request_id TEXT NOT NULL,
434
+ review_id INTEGER,
435
+ review_chain_id TEXT NOT NULL DEFAULT '',
436
+ from_state TEXT NOT NULL DEFAULT '',
437
+ to_state TEXT NOT NULL,
438
+ reason TEXT NOT NULL DEFAULT '',
439
+ actor TEXT NOT NULL DEFAULT '',
440
+ remote_state TEXT NOT NULL DEFAULT '',
441
+ created_at REAL NOT NULL
442
+ )
443
+ ''')
444
+ await conn.execute(
445
+ 'CREATE INDEX IF NOT EXISTS idx_refetch_events_request '
446
+ 'ON refetch_events(request_id, created_at ASC)'
447
+ )
448
+
449
+ # Candidate history per review chain: every work already shown to
450
+ # reviewers for this chain. (review_chain_id, candidate_id) is the
451
+ # semantic unique key; candidate identity is the canonical Pixiv work
452
+ # id (pending_reviews.pixiv_id), never parsed from captions.
453
+ # The attempt/outcome columns turn the flat seen-set into the
454
+ # candidate lifecycle: who proposed this candidate, what happened to
455
+ # it and what replaced it.
456
+ await conn.execute('''
457
+ CREATE TABLE IF NOT EXISTS refetch_seen_candidates (
458
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
459
+ review_chain_id TEXT NOT NULL,
460
+ candidate_id TEXT NOT NULL,
461
+ source TEXT NOT NULL DEFAULT 'original',
462
+ generation INTEGER NOT NULL DEFAULT 0,
463
+ created_at REAL NOT NULL,
464
+ request_id TEXT NOT NULL DEFAULT '',
465
+ outcome TEXT NOT NULL DEFAULT '',
466
+ reason TEXT NOT NULL DEFAULT '',
467
+ decided_at REAL,
468
+ replaced_by TEXT NOT NULL DEFAULT '',
469
+ UNIQUE(review_chain_id, candidate_id)
470
+ )
471
+ ''')
472
+ for column, ddl in (
473
+ ("request_id", "TEXT NOT NULL DEFAULT ''"),
474
+ ("outcome", "TEXT NOT NULL DEFAULT ''"),
475
+ ("reason", "TEXT NOT NULL DEFAULT ''"),
476
+ ("decided_at", "REAL"),
477
+ ("replaced_by", "TEXT NOT NULL DEFAULT ''"),
478
+ ):
479
+ try:
480
+ await conn.execute(
481
+ f"ALTER TABLE refetch_seen_candidates ADD COLUMN {column} {ddl}"
482
+ )
483
+ except Exception:
484
+ pass # column already exists
485
+ # Durable manual RECOVERY attempts (§manual-recovery): re-running a
486
+ # FAILED schedule target under a server-defined policy preset.
487
+ # callback_key UNIQUE makes button-click redelivery idempotent; the
488
+ # partial UNIQUE index makes one-active-recovery-per-target a data
489
+ # layer guarantee.
490
+ await conn.execute('''
491
+ CREATE TABLE IF NOT EXISTS recovery_attempts (
492
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
493
+ callback_key TEXT NOT NULL UNIQUE,
494
+ request_id TEXT NOT NULL UNIQUE,
495
+ slot_id TEXT NOT NULL DEFAULT '',
496
+ schedule_id TEXT NOT NULL DEFAULT '',
497
+ target_id TEXT NOT NULL,
498
+ retry_mode TEXT NOT NULL DEFAULT 'normal',
499
+ state TEXT NOT NULL DEFAULT 'requested',
500
+ failure_code TEXT NOT NULL DEFAULT '',
501
+ created_at REAL NOT NULL,
502
+ finished_at REAL
503
+ )
504
+ ''')
505
+ await conn.execute(
506
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_recovery_one_active '
507
+ "ON recovery_attempts(target_id) "
508
+ "WHERE state IN ('requested','accepted')"
509
+ )
510
+ await conn.execute(
511
+ 'CREATE INDEX IF NOT EXISTS idx_recovery_target '
512
+ 'ON recovery_attempts(target_id, created_at DESC)'
513
+ )
514
+ # Durable dedup for terminal SCHEDULE outcome notifications
515
+ # (§schedule-notify): one terminal summary per slot is ever sent to
516
+ # the review/admin group, even when the same external clock replays
517
+ # the slot or PixivFlow redelivers the outcome outbox intent.
518
+ await conn.execute('''
519
+ CREATE TABLE IF NOT EXISTS schedule_outcome_notifications (
520
+ slot_id TEXT PRIMARY KEY,
521
+ sent_at REAL NOT NULL,
522
+ status TEXT NOT NULL DEFAULT ''
523
+ )
524
+ ''')
525
+
526
+ # Bootstrap seen-history for existing reviews (current candidate only;
527
+ # older chain history is not reconstructible and is documented as the
528
+ # migration boundary). INSERT OR IGNORE keeps this idempotent.
529
+ await conn.execute(
530
+ "INSERT OR IGNORE INTO refetch_seen_candidates "
531
+ "(review_chain_id, candidate_id, source, generation, created_at) "
532
+ "SELECT review_chain_id, pixiv_id, 'original', generation, created_at "
533
+ "FROM pending_reviews WHERE pixiv_id <> ''"
534
+ )
535
+
536
+ # ---- Editorial Revision (§editorial) -------------------------------
537
+ # Reviewer edits never mutate the ORIGINAL pending_reviews row: each
538
+ # revision stores its own immutable base/edited snapshots, a structured
539
+ # change set and a human summary. The Review FSM stays the sole owner
540
+ # of "may this publish?"; revisions only answer "which version".
541
+ await conn.execute('''
542
+ CREATE TABLE IF NOT EXISTS editorial_revisions (
543
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
544
+ review_id INTEGER NOT NULL,
545
+ review_chain_id TEXT NOT NULL DEFAULT '',
546
+ revision_number INTEGER NOT NULL,
547
+ status TEXT NOT NULL DEFAULT 'draft',
548
+ base_snapshot TEXT NOT NULL DEFAULT '{}',
549
+ edited_snapshot TEXT NOT NULL DEFAULT '{}',
550
+ change_set TEXT NOT NULL DEFAULT '{}',
551
+ summary TEXT NOT NULL DEFAULT '',
552
+ severity TEXT NOT NULL DEFAULT 'minor',
553
+ editor_user_id INTEGER,
554
+ editor_username TEXT NOT NULL DEFAULT '',
555
+ editor_display_name TEXT NOT NULL DEFAULT '',
556
+ version INTEGER NOT NULL DEFAULT 1,
557
+ published_message_id INTEGER,
558
+ published_snapshot TEXT NOT NULL DEFAULT '{}',
559
+ created_at REAL NOT NULL,
560
+ updated_at REAL NOT NULL,
561
+ finalized_at REAL,
562
+ published_at REAL,
563
+ UNIQUE(review_id, revision_number)
564
+ )
565
+ ''')
566
+ await conn.execute(
567
+ 'CREATE INDEX IF NOT EXISTS idx_editorial_revisions_review '
568
+ 'ON editorial_revisions(review_id, revision_number)'
569
+ )
570
+ await conn.execute(
571
+ 'CREATE INDEX IF NOT EXISTS idx_editorial_revisions_chain '
572
+ 'ON editorial_revisions(review_chain_id, status)'
573
+ )
574
+ # Publication linkage: which revision (if any) produced the confirmed
575
+ # channel post. NULL = original direct publish (backward-compatible).
576
+ try:
577
+ await conn.execute(
578
+ 'ALTER TABLE pending_reviews ADD COLUMN '
579
+ 'published_source_revision_id INTEGER'
580
+ )
581
+ except Exception:
582
+ pass # column already exists
583
+
584
+ # ---- Submitter publication notification (durable + idempotent) -----
585
+ # Enqueued at publish time, delivered by the periodic worker. One row
586
+ # per (review, submitter): replay of the same publish can never send a
587
+ # second Telegram message. Failures retry; the publication stands
588
+ # regardless (§41-§42).
589
+ await conn.execute('''
590
+ CREATE TABLE IF NOT EXISTS submitter_notifications (
591
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
592
+ -- NULL for DIRECT_PUBLISH (no review row exists): the
593
+ -- notification is keyed by the PUBLICATION, not the review.
594
+ review_id INTEGER,
595
+ revision_id INTEGER,
596
+ telegram_user_id INTEGER NOT NULL,
597
+ idempotency_key TEXT NOT NULL UNIQUE,
598
+ kind TEXT NOT NULL DEFAULT 'published',
599
+ state TEXT NOT NULL DEFAULT 'pending',
600
+ attempts INTEGER NOT NULL DEFAULT 0,
601
+ last_error TEXT NOT NULL DEFAULT '',
602
+ message_id INTEGER,
603
+ payload TEXT NOT NULL DEFAULT '{}',
604
+ created_at REAL NOT NULL,
605
+ updated_at REAL NOT NULL,
606
+ sent_at REAL
607
+ )
608
+ ''')
609
+ await conn.execute(
610
+ 'CREATE INDEX IF NOT EXISTS idx_submitter_notifications_state '
611
+ 'ON submitter_notifications(state, updated_at)'
612
+ )
613
+
614
+ # API 运维通知的持久幂等记录。不同 token 所绑定的用户可以复用同一业务键;
615
+ # 同一用户在 Bot 重启后仍不会重复发送同一条通知。
616
+ await conn.execute('''
617
+ CREATE TABLE IF NOT EXISTS api_notifications (
618
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
619
+ telegram_user_id INTEGER NOT NULL,
620
+ idempotency_key TEXT NOT NULL,
621
+ status TEXT NOT NULL DEFAULT 'pending',
622
+ message_id INTEGER,
623
+ created_at REAL NOT NULL,
624
+ updated_at REAL NOT NULL,
625
+ UNIQUE(telegram_user_id, idempotency_key)
626
+ )
627
+ ''')
628
+ await conn.execute(
629
+ 'CREATE INDEX IF NOT EXISTS idx_api_notifications_updated '
630
+ 'ON api_notifications(updated_at)'
631
+ )
632
+
633
+ # 已发布帖子表(用于热度统计和搜索)
634
+ await conn.execute('''
635
+ CREATE TABLE IF NOT EXISTS published_posts (
636
+ message_id INTEGER PRIMARY KEY,
637
+ user_id INTEGER,
638
+ username TEXT,
639
+ title TEXT,
640
+ tags TEXT,
641
+ link TEXT,
642
+ note TEXT,
643
+ content_type TEXT,
644
+ file_ids TEXT,
645
+ caption TEXT,
646
+ filename TEXT,
647
+ publish_time REAL,
648
+ views INTEGER DEFAULT 0,
649
+ forwards INTEGER DEFAULT 0,
650
+ reactions INTEGER DEFAULT 0,
651
+ heat_score REAL DEFAULT 0,
652
+ last_update REAL,
653
+ related_message_ids TEXT,
654
+ is_deleted INTEGER DEFAULT 0
655
+ )
656
+ ''')
657
+
658
+ # 添加 is_deleted 字段(如果表已存在但没有该字段)
659
+ try:
660
+ await conn.execute('ALTER TABLE published_posts ADD COLUMN is_deleted INTEGER DEFAULT 0')
661
+ logger.info("已添加 is_deleted 字段到 published_posts 表")
662
+ except Exception:
663
+ # 字段已存在,忽略错误
664
+ pass
665
+
666
+ # 创建索引以提升查询性能
667
+ await conn.execute('CREATE INDEX IF NOT EXISTS idx_heat_score ON published_posts(heat_score DESC)')
668
+ await conn.execute('CREATE INDEX IF NOT EXISTS idx_publish_time ON published_posts(publish_time DESC)')
669
+ await conn.execute('CREATE INDEX IF NOT EXISTS idx_user_id ON published_posts(user_id)')
670
+ await conn.execute('CREATE INDEX IF NOT EXISTS idx_tags ON published_posts(tags)')
671
+ await conn.execute('CREATE INDEX IF NOT EXISTS idx_is_deleted ON published_posts(is_deleted)')
672
+
673
+ # 组合索引:覆盖 /hot、/myposts 等高频查询的
674
+ # "WHERE is_deleted = 0 [+ user_id] ORDER BY ..." 热路径,
675
+ # 避免在 is_deleted 单列索引上回表后再排序
676
+ await conn.execute('CREATE INDEX IF NOT EXISTS idx_deleted_heat ON published_posts(is_deleted, heat_score DESC)')
677
+ await conn.execute('CREATE INDEX IF NOT EXISTS idx_deleted_publish_time ON published_posts(is_deleted, publish_time DESC)')
678
+ await conn.execute('CREATE INDEX IF NOT EXISTS idx_user_deleted ON published_posts(user_id, is_deleted)')
679
+
680
+ # Per-message Telegram reaction counts (Bot API message_reaction_count).
681
+ # published_posts.reactions / heat_score are aggregates recomputed from
682
+ # this table, so reactions on any album member are counted exactly once.
683
+ await conn.execute('''
684
+ CREATE TABLE IF NOT EXISTS message_reaction_counts (
685
+ message_id INTEGER PRIMARY KEY,
686
+ total_count INTEGER DEFAULT 0,
687
+ updated_at REAL
688
+ )
689
+ ''')
690
+ await conn.execute('CREATE INDEX IF NOT EXISTS idx_message_reaction_updated '
691
+ 'ON message_reaction_counts(updated_at)')
692
+
693
+ # Direct-publish delivery ledger (API_REVIEW_REQUIRED=false). The review
694
+ # path already dedupes on pending_reviews.idempotency_key; direct publish had
695
+ # NO idempotency, so an ACK loss / client retry re-posted to the channel.
696
+ # One row per confirmed channel post, keyed by the caller's idempotency key.
697
+ await conn.execute('''
698
+ CREATE TABLE IF NOT EXISTS delivery_ledger (
699
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
700
+ idempotency_key TEXT UNIQUE NOT NULL,
701
+ target_id TEXT NOT NULL DEFAULT '',
702
+ pixiv_id TEXT NOT NULL DEFAULT '',
703
+ work_type TEXT NOT NULL DEFAULT '',
704
+ status TEXT NOT NULL DEFAULT 'published',
705
+ message_id INTEGER,
706
+ related_message_ids TEXT NOT NULL DEFAULT '[]',
707
+ progress_json TEXT NOT NULL DEFAULT '[]',
708
+ user_id INTEGER,
709
+ created_at REAL NOT NULL
710
+ )
711
+ ''')
712
+ try:
713
+ await conn.execute(
714
+ "ALTER TABLE delivery_ledger ADD COLUMN "
715
+ "progress_json TEXT NOT NULL DEFAULT '[]'"
716
+ )
717
+ except Exception:
718
+ pass
719
+ await conn.execute(
720
+ 'CREATE INDEX IF NOT EXISTS idx_delivery_ledger_dedupe '
721
+ "ON delivery_ledger(target_id, work_type, pixiv_id) "
722
+ "WHERE pixiv_id <> ''"
723
+ )
724
+
725
+ # Optional publication enrichment: one durable row per PUBLICATION
726
+ # (the delivery idempotency key), so a Telegram delivery retry
727
+ # reuses an existing Telegraph preview instead of creating a second
728
+ # page. A failed/unavailable preview never blocks the TXT publish.
729
+ await conn.execute('''
730
+ CREATE TABLE IF NOT EXISTS publication_previews (
731
+ publication_key TEXT PRIMARY KEY,
732
+ provider TEXT NOT NULL DEFAULT '',
733
+ status TEXT NOT NULL DEFAULT '',
734
+ url TEXT NOT NULL DEFAULT '',
735
+ title TEXT NOT NULL DEFAULT '',
736
+ created_at REAL NOT NULL,
737
+ updated_at REAL NOT NULL
738
+ )
739
+ ''')
740
+
741
+ # Append-only audit/observability log. pending_reviews remains the
742
+ # source of truth; these rows are evidence only and are pruned by
743
+ # cleanup_old_data (never while the referenced review is still open).
744
+ await conn.execute('''
745
+ CREATE TABLE IF NOT EXISTS audit_events (
746
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
747
+ ts REAL NOT NULL,
748
+ component TEXT DEFAULT 'telepost',
749
+ event TEXT NOT NULL,
750
+ review_id INTEGER,
751
+ pixiv_id TEXT,
752
+ work_type TEXT,
753
+ target_id TEXT,
754
+ idempotency_key TEXT,
755
+ execution_id TEXT,
756
+ actor TEXT,
757
+ error_class TEXT,
758
+ detail TEXT
759
+ )
760
+ ''')
761
+ await conn.execute(
762
+ 'CREATE INDEX IF NOT EXISTS idx_audit_events_review '
763
+ 'ON audit_events(review_id)'
764
+ )
765
+ await conn.execute(
766
+ 'CREATE INDEX IF NOT EXISTS idx_audit_events_execution '
767
+ 'ON audit_events(execution_id)'
768
+ )
769
+ await conn.execute(
770
+ 'CREATE INDEX IF NOT EXISTS idx_audit_events_ts '
771
+ 'ON audit_events(ts)'
772
+ )
773
+
774
+ # Moderation blocks (operator deny list). A block never deletes
775
+ # history; it only stops NEW submissions from that subject.
776
+ # subject is the canonical actor identity ('user:<id>' | 'api:<id>').
777
+ await conn.execute("""
778
+ CREATE TABLE IF NOT EXISTS moderation_blocks (
779
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
780
+ subject TEXT NOT NULL UNIQUE,
781
+ reason TEXT NOT NULL DEFAULT '',
782
+ created_by INTEGER NOT NULL,
783
+ created_at REAL NOT NULL
784
+ )
785
+ """)
786
+ await conn.execute(
787
+ 'CREATE INDEX IF NOT EXISTS idx_moderation_blocks_subject '
788
+ 'ON moderation_blocks(subject)'
789
+ )
790
+
791
+ # Durable Role Bindings (Admin Control Plane RBAC). Grants reviewer
792
+ # / admin to a Telegram user on top of the OWNER_ID/ADMIN_IDS env
793
+ # baseline; OWNER_ID stays break-glass root regardless of bindings.
794
+ await conn.execute("""
795
+ CREATE TABLE IF NOT EXISTS role_bindings (
796
+ principal_kind TEXT NOT NULL,
797
+ principal_id INTEGER NOT NULL,
798
+ role TEXT NOT NULL,
799
+ created_by TEXT NOT NULL DEFAULT '',
800
+ created_at REAL NOT NULL,
801
+ PRIMARY KEY (principal_kind, principal_id, role)
802
+ )
803
+ """)
804
+
805
+ # Delivery Asset Contract (Step 10): canonical media references on a
806
+ # review chain so TelePost never has to swallow every PixivFlow domain
807
+ # field. asset_id/source_url is the minimal contract; local artifacts
808
+ # stay the file_id/media_json path unchanged.
809
+ await conn.execute("""
810
+ CREATE TABLE IF NOT EXISTS media_asset_refs (
811
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
812
+ review_chain_id TEXT NOT NULL,
813
+ asset_id TEXT NOT NULL,
814
+ kind TEXT NOT NULL DEFAULT 'image',
815
+ source_url TEXT NOT NULL,
816
+ mime_type TEXT NOT NULL DEFAULT '',
817
+ file_id TEXT NOT NULL DEFAULT '',
818
+ file_unique_id TEXT NOT NULL DEFAULT '',
819
+ created_at REAL NOT NULL,
820
+ UNIQUE(review_chain_id, asset_id)
821
+ )
822
+ """)
823
+ await conn.execute(
824
+ 'CREATE INDEX IF NOT EXISTS idx_media_asset_refs_chain '
825
+ 'ON media_asset_refs(review_chain_id)'
826
+ )
827
+ # Step 12 TelegramMediaCache: canonical Telegram delivery facts are
828
+ # carried on the SAME asset row instead of a separate cache table.
829
+ # Existing deployments get the columns via idempotent ADD COLUMN.
830
+ for _column, _ddl in (
831
+ ("file_id", "ALTER TABLE media_asset_refs ADD COLUMN file_id TEXT NOT NULL DEFAULT ''"),
832
+ ("file_unique_id", "ALTER TABLE media_asset_refs ADD COLUMN file_unique_id TEXT NOT NULL DEFAULT ''"),
833
+ ):
834
+ try:
835
+ await conn.execute(_ddl)
836
+ except Exception:
837
+ pass # column already exists
838
+
839
+ # Workflow Protocol v1 §events: TelePost is the CONSUMER, so both a
840
+ # producer callback (POST /api/botN/v1/jobs/events) and the reconcile
841
+ # pull persist incoming Event documents here. The UNIQUE index on
842
+ # event_id is the consumer's dedupe guarantee — an at-least-once
843
+ # producer replay (callback redelivery OR a reconcile re-pull) is a
844
+ # no-op, never a second business effect. Only the raw Event plus a
845
+ # few queryable projections are kept; business handling is driven
846
+ # from ``raw`` so a protocol decode upgrade never needs a migration.
847
+ # ``received_at`` is when WE learned the event (epoch seconds);
848
+ # ``at`` is the protocol event timestamp coerced to epoch seconds.
849
+ await conn.execute('''
850
+ CREATE TABLE IF NOT EXISTS protocol_job_events (
851
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
852
+ event_id TEXT NOT NULL UNIQUE,
853
+ job_id TEXT NOT NULL,
854
+ event_type TEXT NOT NULL,
855
+ correlation_id TEXT NOT NULL DEFAULT '',
856
+ at REAL,
857
+ raw TEXT NOT NULL,
858
+ received_at REAL NOT NULL
859
+ )
860
+ ''')
861
+ await conn.execute(
862
+ 'CREATE INDEX IF NOT EXISTS idx_protocol_job_events_job '
863
+ 'ON protocol_job_events(job_id, at ASC)'
864
+ )
865
+ await conn.execute(
866
+ 'CREATE INDEX IF NOT EXISTS idx_protocol_job_events_type '
867
+ 'ON protocol_job_events(event_type, at ASC)'
868
+ )
869
+ # Per-job monotonic ack cursor for reconcile: the newest event_id this
870
+ # consumer durably persisted (and acknowledged), passed back as
871
+ # ``?after=…`` on the next pull and as ``ack_through`` on the ack.
872
+ await conn.execute('''
873
+ CREATE TABLE IF NOT EXISTS protocol_job_cursors (
874
+ job_id TEXT PRIMARY KEY,
875
+ ack_through TEXT NOT NULL DEFAULT '',
876
+ updated_at REAL NOT NULL
877
+ )
878
+ ''')
879
+
880
+ # published_posts 是频道现状,pending_reviews 是审核审计。频道消息被软删除时
881
+ # 同步把对应审核记录从“曾发布”推进到“已删除”,避免把历史终态误当成
882
+ # 当前仍在线的发布。触发器覆盖项目内所有软删除入口。
883
+ await conn.execute('''
884
+ CREATE TRIGGER IF NOT EXISTS trg_published_post_review_deleted
885
+ AFTER UPDATE OF is_deleted ON published_posts
886
+ WHEN NEW.is_deleted = 1 AND OLD.is_deleted IS NOT 1
887
+ BEGIN
888
+ UPDATE pending_reviews
889
+ SET status = 'deleted',
890
+ updated_at = CAST(strftime('%s', 'now') AS REAL),
891
+ error = CASE
892
+ WHEN COALESCE(error, '') = '' THEN 'published message deleted'
893
+ ELSE error
894
+ END
895
+ WHERE published_message_id = NEW.message_id
896
+ AND status = 'published';
897
+ END
898
+ ''')
899
+
900
+ # 一次性修正触发器上线前已经出现的错位记录。保留 decided_at/decided_by,
901
+ # 因为它们仍表示当时的批准操作;只更新当前生命周期状态。
902
+ cursor = await conn.execute('''
903
+ UPDATE pending_reviews
904
+ SET status = 'deleted',
905
+ updated_at = CAST(strftime('%s', 'now') AS REAL),
906
+ error = CASE
907
+ WHEN COALESCE(error, '') = '' THEN 'published message deleted'
908
+ ELSE error
909
+ END
910
+ WHERE status = 'published'
911
+ AND published_message_id IN (
912
+ SELECT message_id FROM published_posts WHERE is_deleted = 1
913
+ )
914
+ ''')
915
+ if cursor.rowcount:
916
+ logger.info("已同步 %d 条已删除发布的审核状态", cursor.rowcount)
917
+
918
+ await conn.commit()
919
+ logger.info("数据库初始化完成")
920
+ except Exception as e:
921
+ logger.error(f"初始化数据库时出错: {e}")
922
+ raise
923
+
924
+ async def cleanup_old_data():
925
+ """
926
+ 清理过期的会话数据与已决审核记录
927
+ """
928
+ try:
929
+ # 首先检查表是否存在
930
+ async with aiosqlite.connect(DB_PATH) as conn:
931
+ c = await conn.cursor()
932
+ await c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='submissions'")
933
+ table_exists = await c.fetchone()
934
+
935
+ if not table_exists:
936
+ logger.warning("submissions 表不存在,跳过清理")
937
+ return
938
+
939
+ # 如果表存在,执行清理
940
+ async with get_db() as conn:
941
+ c = await conn.cursor()
942
+ cutoff = datetime.now().timestamp() - TIMEOUT
943
+ await c.execute("DELETE FROM submissions WHERE timestamp < ?", (cutoff,))
944
+
945
+ # 清理终态(拒绝/发布/已删除/失败/过期)且超过保留期的审核记录,避免表无限增长。
946
+ # 只清理终态记录:pending 的投稿绝不能动。保留期默认 30 天。
947
+ review_retention_days = int(os.getenv("REVIEW_RETENTION_DAYS", "30"))
948
+ if review_retention_days > 0:
949
+ async with aiosqlite.connect(DB_PATH) as conn:
950
+ c = await conn.cursor()
951
+ await c.execute(
952
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='pending_reviews'"
953
+ )
954
+ has_reviews = await c.fetchone()
955
+ if has_reviews:
956
+ review_cutoff = datetime.now().timestamp() - review_retention_days * 86400
957
+ async with get_db() as conn:
958
+ c = await conn.cursor()
959
+ await c.execute(
960
+ "DELETE FROM pending_reviews "
961
+ "WHERE status IN ('rejected','published','deleted','failed','expired') AND updated_at < ?",
962
+ (review_cutoff,),
963
+ )
964
+ logger.info("已清理过期审核记录(保留 %d 天)", review_retention_days)
965
+
966
+ async with get_db() as conn:
967
+ await conn.execute(
968
+ "DELETE FROM api_notifications WHERE updated_at < ?",
969
+ (review_cutoff,),
970
+ )
971
+ logger.info("已清理过期 API 通知幂等记录(保留 %d 天)", review_retention_days)
972
+
973
+ # Append-only audit: prune old rows, but keep every event attached to a
974
+ # still-open review (preparing/pending/publishing/failed) regardless of
975
+ # age. Unattached events (review_id IS NULL) follow the age cutoff.
976
+ audit_retention_days = int(os.getenv("AUDIT_RETENTION_DAYS", "30"))
977
+ if audit_retention_days > 0:
978
+ audit_cutoff = datetime.now().timestamp() - audit_retention_days * 86400
979
+ async with aiosqlite.connect(DB_PATH) as conn:
980
+ c = await conn.cursor()
981
+ await c.execute(
982
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='audit_events'"
983
+ )
984
+ has_audit = await c.fetchone()
985
+ if has_audit:
986
+ async with get_db() as conn:
987
+ cursor = await conn.execute(
988
+ "DELETE FROM audit_events WHERE ts < ? AND ("
989
+ "review_id IS NULL OR review_id NOT IN ("
990
+ "SELECT id FROM pending_reviews WHERE status IN "
991
+ "('preparing', 'pending', 'publishing', 'failed')"
992
+ "))",
993
+ (audit_cutoff,),
994
+ )
995
+ logger.debug("已清理过期审计事件 %d 条(保留 %d 天)",
996
+ cursor.rowcount, audit_retention_days)
997
+ except Exception as e:
998
+ logger.error(f"清理过期数据失败: {e}")
999
+
1000
+
1001
+ async def claim_api_notification(telegram_user_id: int, idempotency_key: str) -> bool:
1002
+ """Atomically claim a notification key, surviving process restarts.
1003
+
1004
+ A stale pending claim can be reclaimed after five minutes. This covers a
1005
+ process crash between the database claim and the Telegram request while
1006
+ suppressing ordinary concurrent duplicates.
1007
+ """
1008
+ now = datetime.now().timestamp()
1009
+ async with get_db() as conn:
1010
+ cursor = await conn.execute(
1011
+ """
1012
+ INSERT OR IGNORE INTO api_notifications (
1013
+ telegram_user_id, idempotency_key, status, created_at, updated_at
1014
+ ) VALUES (?, ?, 'pending', ?, ?)
1015
+ """,
1016
+ (telegram_user_id, idempotency_key, now, now),
1017
+ )
1018
+ if cursor.rowcount == 1:
1019
+ return True
1020
+ cursor = await conn.execute(
1021
+ """
1022
+ UPDATE api_notifications
1023
+ SET updated_at = ?
1024
+ WHERE telegram_user_id = ? AND idempotency_key = ?
1025
+ AND status = 'pending' AND updated_at < ?
1026
+ """,
1027
+ (now, telegram_user_id, idempotency_key, now - 300),
1028
+ )
1029
+ return cursor.rowcount == 1
1030
+
1031
+
1032
+ async def mark_api_notification_sent(
1033
+ telegram_user_id: int,
1034
+ idempotency_key: str,
1035
+ message_id: int,
1036
+ ) -> None:
1037
+ async with get_db() as conn:
1038
+ await conn.execute(
1039
+ """
1040
+ UPDATE api_notifications
1041
+ SET status = 'sent', message_id = ?, updated_at = ?
1042
+ WHERE telegram_user_id = ? AND idempotency_key = ?
1043
+ """,
1044
+ (message_id, datetime.now().timestamp(), telegram_user_id, idempotency_key),
1045
+ )
1046
+
1047
+
1048
+ async def release_api_notification(telegram_user_id: int, idempotency_key: str) -> None:
1049
+ """Release a failed pre-send claim so the caller's durable retry can proceed."""
1050
+ async with get_db() as conn:
1051
+ await conn.execute(
1052
+ """
1053
+ DELETE FROM api_notifications
1054
+ WHERE telegram_user_id = ? AND idempotency_key = ? AND status = 'pending'
1055
+ """,
1056
+ (telegram_user_id, idempotency_key),
1057
+ )