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
@@ -0,0 +1,51 @@
1
+ """Map exceptions (and optional HTTP statuses) to stable error class strings.
2
+
3
+ Specific exception types win over a coarse HTTP status, so an upstream 502
4
+ wrapping a timeout keeps the true cause.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+
11
+ def classify(exc: Optional[BaseException], status: Optional[int] = None) -> str:
12
+ if exc is not None:
13
+ module = type(exc).__module__ or ""
14
+ name = type(exc).__name__ or ""
15
+
16
+ if module.startswith("telegram.error") or module.startswith("telegram."):
17
+ if name in ("TimedOut",):
18
+ return "network_timeout"
19
+ if name in ("RetryAfter", "TelegramRetryAfter"):
20
+ return "rate_limited"
21
+ if name == "BadRequest":
22
+ return "telegram_send_failed"
23
+
24
+ try:
25
+ import aiosqlite
26
+ if isinstance(exc, aiosqlite.Error):
27
+ return "database_failed"
28
+ except ImportError: # pragma: no cover
29
+ pass
30
+
31
+ code = str(getattr(exc, "code", "") or "")
32
+ if code == "duplicate_publication":
33
+ return "duplicate"
34
+ text = f"{name} {exc}".lower()
35
+ if "decode" in text and "budget" in text:
36
+ return "media_decode_budget_exceeded"
37
+ if module.startswith("PIL.") or name in (
38
+ "UnidentifiedImageError", "DecompressionBombError"
39
+ ):
40
+ return "media_processing_failed"
41
+
42
+ if isinstance(status, int):
43
+ if status == 409:
44
+ return "idempotency_conflict"
45
+ if status == 429:
46
+ return "rate_limited"
47
+ if 500 <= status < 600:
48
+ return "remote_5xx"
49
+ if 400 <= status < 500:
50
+ return "remote_4xx"
51
+ return "internal_error"
@@ -0,0 +1,76 @@
1
+ """Best-effort secret redaction for logs and audit details.
2
+
3
+ Stdlib only. The rules deliberately stay simple: miss an obscure leak rather
4
+ than build a secret-detection engine.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Any
10
+
11
+ _MASK = "***"
12
+
13
+ # Telegram bot tokens look like 123456789:AA... (8-10 digit bot id, colon,
14
+ # 35-ish base64url-ish chars).
15
+ _BOT_TOKEN_RE = re.compile(r"\d{6,}:[A-Za-z0-9_-]{20,}")
16
+ _BEARER_RE = re.compile(r"(?i:\bBearer\s+)[A-Za-z0-9._\-]+")
17
+ # token=/secret=/password= URL/form params.
18
+ _PARAM_RE = re.compile(
19
+ r"(?i:((?:^|[?;&\s])(?:token|secret|password|api[_-]?key)\s*=\s*))[^&\s;\"']+"
20
+ )
21
+
22
+ _SENSITIVE_HEADERS = {
23
+ "authorization",
24
+ "cookie",
25
+ "x-telegram-bot-api-secret-token",
26
+ }
27
+ _SENSITIVE_KEY_PARTS = ("token", "secret", "password", "api_key", "apikey")
28
+
29
+
30
+ def redact_text(value: str) -> str:
31
+ """Mask bot tokens, Bearer credentials and secret-ish params in a string."""
32
+ if not isinstance(value, str):
33
+ value = str(value)
34
+ value = _BOT_TOKEN_RE.sub(_MASK, value)
35
+ value = _BEARER_RE.sub(lambda m: m.group(0).split()[0] + " " + _MASK, value)
36
+ value = _PARAM_RE.sub(lambda m: m.group(1) + _MASK, value)
37
+ return value
38
+
39
+
40
+ def redact_headers(headers: Any) -> dict:
41
+ """Return a copy of a headers mapping with sensitive values masked."""
42
+ try:
43
+ items = headers.items()
44
+ except AttributeError:
45
+ return {}
46
+ out = {}
47
+ for key, value in items:
48
+ lname = str(key).lower()
49
+ if lname in _SENSITIVE_HEADERS or any(
50
+ part in lname for part in _SENSITIVE_KEY_PARTS
51
+ ):
52
+ out[key] = _MASK
53
+ else:
54
+ out[key] = redact_text(str(value))
55
+ return out
56
+
57
+
58
+ def sanitize_detail(obj: Any) -> Any:
59
+ """Recursively mask sensitive keys and redact string values."""
60
+ if isinstance(obj, dict):
61
+ return {
62
+ key: _MASK if _is_sensitive_key(key) else sanitize_detail(value)
63
+ for key, value in obj.items()
64
+ }
65
+ if isinstance(obj, (list, tuple)):
66
+ return [sanitize_detail(value) for value in obj]
67
+ if isinstance(obj, str):
68
+ return redact_text(obj)
69
+ return obj
70
+
71
+
72
+ def _is_sensitive_key(key: Any) -> bool:
73
+ lname = str(key).lower()
74
+ return lname in _SENSITIVE_HEADERS or any(
75
+ part in lname for part in _SENSITIVE_KEY_PARTS
76
+ )
File without changes
File without changes
@@ -0,0 +1,185 @@
1
+ """Automation task storage: SQLite CRUD and durable run ledger.
2
+
3
+ Two tables:
4
+ - ``automation_tasks``: persistent rule SSOT. JobQueue is a runtime projection.
5
+ - ``automation_runs``: (task_id, occurrence_at) UNIQUE — one send per occurrence.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import time
12
+ from typing import List, Optional
13
+
14
+ from database.db_manager import get_db
15
+ from telepost.domain.automation import AutomationTask, parse_automation_payload
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _SCHEMA = """
20
+ CREATE TABLE IF NOT EXISTS automation_tasks (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ name TEXT NOT NULL,
23
+ enabled INTEGER NOT NULL DEFAULT 1,
24
+ schedule_type TEXT NOT NULL DEFAULT 'weekly',
25
+ schedule_day INTEGER NOT NULL DEFAULT 6,
26
+ schedule_hour INTEGER NOT NULL DEFAULT 20,
27
+ schedule_minute INTEGER NOT NULL DEFAULT 0,
28
+ action_type TEXT NOT NULL DEFAULT 'hot',
29
+ action_payload TEXT NOT NULL DEFAULT '{}',
30
+ target_chat_id TEXT NOT NULL DEFAULT '',
31
+ timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai',
32
+ created_by INTEGER NOT NULL DEFAULT 0,
33
+ created_at REAL NOT NULL DEFAULT 0,
34
+ updated_at REAL NOT NULL DEFAULT 0,
35
+ last_run_at REAL,
36
+ last_run_status TEXT,
37
+ last_error TEXT
38
+ );
39
+ CREATE TABLE IF NOT EXISTS automation_runs (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ task_id INTEGER NOT NULL REFERENCES automation_tasks(id),
42
+ occurrence_at REAL NOT NULL,
43
+ trigger TEXT NOT NULL DEFAULT 'scheduled',
44
+ status TEXT NOT NULL DEFAULT 'running',
45
+ error TEXT,
46
+ started_at REAL NOT NULL DEFAULT 0,
47
+ finished_at REAL,
48
+ UNIQUE(task_id, occurrence_at)
49
+ );
50
+ """
51
+
52
+
53
+ async def ensure_tables() -> None:
54
+ async with get_db() as conn:
55
+ await conn.executescript(_SCHEMA)
56
+ await conn.commit()
57
+
58
+
59
+ def _row_to_task(row) -> AutomationTask:
60
+ return AutomationTask(
61
+ id=row["id"],
62
+ name=row["name"],
63
+ enabled=bool(row["enabled"]),
64
+ schedule_type=row["schedule_type"],
65
+ schedule_day=row["schedule_day"],
66
+ schedule_hour=row["schedule_hour"],
67
+ schedule_minute=row["schedule_minute"],
68
+ action_type=row["action_type"],
69
+ action_payload=parse_automation_payload(row["action_payload"]),
70
+ target_chat_id=row["target_chat_id"],
71
+ timezone=row["timezone"],
72
+ created_by=row["created_by"],
73
+ created_at=row["created_at"],
74
+ updated_at=row["updated_at"],
75
+ last_run_at=row["last_run_at"],
76
+ last_run_status=row["last_run_status"],
77
+ last_error=row["last_error"],
78
+ )
79
+
80
+
81
+ async def create_task(task: AutomationTask) -> AutomationTask:
82
+ now = time.time()
83
+ async with get_db() as conn:
84
+ cursor = await conn.execute(
85
+ """INSERT INTO automation_tasks
86
+ (name, enabled, schedule_type, schedule_day, schedule_hour,
87
+ schedule_minute, action_type, action_payload, target_chat_id,
88
+ timezone, created_by, created_at, updated_at)
89
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
90
+ (
91
+ task.name, int(task.enabled), task.schedule_type,
92
+ task.schedule_day, task.schedule_hour, task.schedule_minute,
93
+ task.action_type, json.dumps(task.action_payload),
94
+ task.target_chat_id, task.timezone, task.created_by,
95
+ now, now,
96
+ ),
97
+ )
98
+ task_id = cursor.lastrowid
99
+ await conn.commit()
100
+ task.id = task_id
101
+ task.created_at = now
102
+ task.updated_at = now
103
+ return task
104
+
105
+
106
+ async def list_tasks() -> List[AutomationTask]:
107
+ async with get_db() as conn:
108
+ cursor = await conn.execute(
109
+ "SELECT * FROM automation_tasks ORDER BY id"
110
+ )
111
+ rows = await cursor.fetchall()
112
+ return [_row_to_task(r) for r in rows]
113
+
114
+
115
+ async def get_task(task_id: int) -> Optional[AutomationTask]:
116
+ async with get_db() as conn:
117
+ cursor = await conn.execute(
118
+ "SELECT * FROM automation_tasks WHERE id = ?", (task_id,)
119
+ )
120
+ row = await cursor.fetchone()
121
+ return _row_to_task(row) if row else None
122
+
123
+
124
+ async def set_enabled(task_id: int, enabled: bool) -> bool:
125
+ async with get_db() as conn:
126
+ cursor = await conn.execute(
127
+ "UPDATE automation_tasks SET enabled = ?, updated_at = ? WHERE id = ?",
128
+ (int(enabled), time.time(), task_id),
129
+ )
130
+ await conn.commit()
131
+ return cursor.rowcount > 0
132
+
133
+
134
+ async def delete_task(task_id: int) -> bool:
135
+ async with get_db() as conn:
136
+ cursor = await conn.execute(
137
+ "DELETE FROM automation_tasks WHERE id = ?", (task_id,)
138
+ )
139
+ await conn.commit()
140
+ return cursor.rowcount > 0
141
+
142
+
143
+ async def update_run_result(
144
+ task_id: int, occurrence_at: float, status: str, error: Optional[str] = None
145
+ ) -> None:
146
+ now = time.time()
147
+ async with get_db() as conn:
148
+ await conn.execute(
149
+ """UPDATE automation_tasks
150
+ SET last_run_at = ?, last_run_status = ?, last_error = ?, updated_at = ?
151
+ WHERE id = ?""",
152
+ (occurrence_at, status, error, now, task_id),
153
+ )
154
+ await conn.commit()
155
+
156
+
157
+ async def record_run_start(
158
+ task_id: int, occurrence_at: float, trigger: str
159
+ ) -> bool:
160
+ """Insert a run record; return False if (task_id, occurrence_at) already exists."""
161
+ async with get_db() as conn:
162
+ try:
163
+ await conn.execute(
164
+ """INSERT INTO automation_runs
165
+ (task_id, occurrence_at, trigger, status, started_at)
166
+ VALUES (?,?,?,?,?)""",
167
+ (task_id, occurrence_at, trigger, "running", time.time()),
168
+ )
169
+ await conn.commit()
170
+ return True
171
+ except Exception:
172
+ return False
173
+
174
+
175
+ async def record_run_finish(
176
+ task_id: int, occurrence_at: float, status: str, error: Optional[str] = None
177
+ ) -> None:
178
+ async with get_db() as conn:
179
+ await conn.execute(
180
+ """UPDATE automation_runs
181
+ SET status = ?, error = ?, finished_at = ?
182
+ WHERE task_id = ? AND occurrence_at = ?""",
183
+ (status, error, time.time(), task_id, occurrence_at),
184
+ )
185
+ await conn.commit()
@@ -0,0 +1,18 @@
1
+ """Storage column names behind source-neutral domain names (§naming-map).
2
+
3
+ The domain/application layer calls a work's upstream identity ``work_id``
4
+ (source-neutral). The persisted column predates that vocabulary and keeps its
5
+ historical name forever (constraint: zero-renames on storage). All SQL that
6
+ touches the column should reference this constant instead of scattering the
7
+ literal, so the wire→domain→storage mapping has exactly one home:
8
+
9
+ wire ``work_id`` (preferred) / ``pixiv_id`` (deprecated alias)
10
+ → domain ``work_id``
11
+ → storage column ``WORK_ID_COLUMN`` ("pixiv_id", immutable)
12
+
13
+ Audit payloads also keep the historical ``pixiv_id`` key: audit_events rows
14
+ are a persisted schema that old readers may still consume.
15
+ """
16
+
17
+ #: The immutable storage column carrying the domain ``work_id``.
18
+ WORK_ID_COLUMN = "pixiv_id"
@@ -0,0 +1,226 @@
1
+ """Editorial Revision durable storage (§editorial).
2
+
3
+ Revisions are a separate ledger from the Review FSM: a revision row never
4
+ changes ``pending_reviews.status``, and the review flow never reads revision
5
+ rows to decide whether publication is allowed. The only coupling is:
6
+
7
+ * the review row carries ``published_source_revision_id`` (NULL = original
8
+ publish), recorded by the publication service AFTER a confirmed delivery;
9
+ * a finalized revision is required before it may be published;
10
+ * when a review is superseded/decided, its open drafts are terminalized
11
+ (``superseded``) so a stale editor can never publish a stale generation.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from database.db_manager import get_db
20
+
21
+ from ...domain import editorial as domain
22
+
23
+ RevisionRow = Dict[str, Any]
24
+
25
+
26
+ class EditorialConflictError(Exception):
27
+ """CAS/version conflict: another editor moved the revision first (409)."""
28
+
29
+
30
+ class EditorialNotFoundError(Exception):
31
+ """Revision does not exist (404)."""
32
+
33
+
34
+ class EditorialStateError(Exception):
35
+ """Operation illegal in the current revision state (409)."""
36
+
37
+
38
+ def _stamp(now: Optional[float] = None) -> float:
39
+ return time.time() if now is None else float(now)
40
+
41
+
42
+ class EditorialRepository:
43
+ """SQLite repository keyed on review rows (pending_reviews)."""
44
+
45
+ async def create(self, review_id: int, review_chain_id: str, base: domain.Snapshot,
46
+ editor_user_id: Optional[int], editor_username: str = "",
47
+ editor_display_name: str = "",
48
+ connection: Any = None) -> Dict[str, Any]:
49
+ """Create the next draft revision for a review.
50
+
51
+ Revision numbering is data-layer monotonic and race-safe: the number is
52
+ allocated inside a transaction and the UNIQUE(review_id, revision_number)
53
+ constraint rejects a concurrent duplicate; a loser of that race retries
54
+ once with the fresh maximum.
55
+ """
56
+ now = _stamp()
57
+ async with (connection or get_db()) as conn:
58
+ for _attempt in range(2):
59
+ cur = await conn.execute(
60
+ "SELECT COALESCE(MAX(revision_number), 0) + 1 AS next "
61
+ "FROM editorial_revisions WHERE review_id = ?",
62
+ (int(review_id),),
63
+ )
64
+ row = await cur.fetchone()
65
+ number = int(row["next"]) if row else 1
66
+ try:
67
+ cur = await conn.execute(
68
+ """
69
+ INSERT INTO editorial_revisions (
70
+ review_id, review_chain_id, revision_number, status,
71
+ base_snapshot, edited_snapshot, change_set, summary,
72
+ editor_user_id, editor_username, editor_display_name,
73
+ version, created_at, updated_at
74
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
75
+ """,
76
+ (
77
+ int(review_id), review_chain_id, number,
78
+ domain.RevisionStatus.DRAFT.value,
79
+ json.dumps(base.to_dict(), ensure_ascii=False),
80
+ json.dumps(base.to_dict(), ensure_ascii=False),
81
+ "{}", "",
82
+ editor_user_id, editor_username, editor_display_name,
83
+ 1, now, now,
84
+ ),
85
+ )
86
+ return await self._select_in_conn(conn, cur.lastrowid)
87
+ except Exception as exc:
88
+ if "UNIQUE" in str(exc).upper() or getattr(exc, "sqlite_errorcode", None) == 2067:
89
+ continue # concurrent number allocation; retry once
90
+ raise
91
+ raise EditorialConflictError("无法分配 revision 编号,请重试")
92
+
93
+ async def get(self, revision_id: int) -> Optional[Dict[str, Any]]:
94
+ async with get_db() as conn:
95
+ return await self._select_in_conn(conn, int(revision_id))
96
+
97
+ async def get_for_review(self, review_id: int, revision_id: int) -> Optional[Dict[str, Any]]:
98
+ async with get_db() as conn:
99
+ cur = await conn.execute(
100
+ "SELECT * FROM editorial_revisions WHERE id = ? AND review_id = ?",
101
+ (int(revision_id), int(review_id)),
102
+ )
103
+ row = await cur.fetchone()
104
+ return dict(row) if row else None
105
+
106
+ async def list_for_review(self, review_id: int) -> List[Dict[str, Any]]:
107
+ async with get_db() as conn:
108
+ cur = await conn.execute(
109
+ "SELECT * FROM editorial_revisions WHERE review_id = ? "
110
+ "ORDER BY revision_number ASC", (int(review_id),),
111
+ )
112
+ return [dict(r) for r in await cur.fetchall()]
113
+
114
+ async def list_published_for_chain(self, review_chain_id: str) -> List[Dict[str, Any]]:
115
+ """Submitter-safe published history (one logical submission, §36)."""
116
+ async with get_db() as conn:
117
+ cur = await conn.execute(
118
+ "SELECT * FROM editorial_revisions WHERE review_chain_id = ? "
119
+ "AND status = 'published' ORDER BY revision_number ASC",
120
+ (review_chain_id,),
121
+ )
122
+ return [dict(r) for r in await cur.fetchall()]
123
+
124
+ async def update_draft(self, revision_id: int, edited: domain.Snapshot,
125
+ expected_version: int, *,
126
+ editor_user_id: Optional[int] = None,
127
+ editor_username: str = "",
128
+ editor_display_name: str = "",
129
+ severity: str = domain.MINOR) -> Optional[Dict[str, Any]]:
130
+ """CAS update of a DRAFT: rejects when ``version`` moved (409)."""
131
+ now = _stamp()
132
+ current = await self.get(revision_id)
133
+ if current is None:
134
+ raise EditorialNotFoundError("revision 不存在")
135
+ if current["status"] != domain.RevisionStatus.DRAFT.value:
136
+ raise EditorialStateError("只有草稿可以编辑")
137
+ if int(current["version"]) != int(expected_version):
138
+ raise EditorialConflictError("此投稿已被其他审核员更新,请刷新后继续编辑")
139
+ base = domain.Snapshot.from_dict(json.loads(current["base_snapshot"] or "{}"))
140
+ changes = domain.change_set(base, edited)
141
+ summary = "\n".join(domain.change_summary(changes))
142
+ severity = severity if severity in (domain.MINOR, domain.SUBSTANTIVE) else domain.MINOR
143
+ async with get_db() as conn:
144
+ cur = await conn.execute(
145
+ """
146
+ UPDATE editorial_revisions SET
147
+ edited_snapshot = ?, change_set = ?, summary = ?,
148
+ editor_user_id = ?, editor_username = ?, editor_display_name = ?,
149
+ severity = ?, version = version + 1, updated_at = ?
150
+ WHERE id = ? AND version = ?
151
+ """,
152
+ (
153
+ json.dumps(edited.to_dict(), ensure_ascii=False),
154
+ json.dumps(changes, ensure_ascii=False),
155
+ summary,
156
+ editor_user_id, editor_username, editor_display_name,
157
+ severity, now, int(revision_id), int(expected_version),
158
+ ),
159
+ )
160
+ if cur.rowcount != 1:
161
+ raise EditorialConflictError("此投稿已被其他审核员更新,请刷新后继续编辑")
162
+ return await self._select_in_conn(conn, int(revision_id))
163
+
164
+ async def finalize(self, revision_id: int, expected_version: int) -> Optional[Dict[str, Any]]:
165
+ """draft → finalized (only a finalized revision may be published, §30)."""
166
+ current = await self.get(revision_id)
167
+ if current is None:
168
+ raise EditorialNotFoundError("revision 不存在")
169
+ if int(current["version"]) != int(expected_version):
170
+ raise EditorialConflictError("此投稿已被其他审核员更新,请刷新后继续编辑")
171
+ if current["status"] not in (
172
+ domain.RevisionStatus.DRAFT.value, domain.RevisionStatus.FINALIZED.value
173
+ ):
174
+ raise EditorialStateError("该版本已发布或已作废,无法再次编辑发布")
175
+ now = _stamp()
176
+ async with get_db() as conn:
177
+ cur = await conn.execute(
178
+ "UPDATE editorial_revisions SET status = 'finalized', "
179
+ "finalized_at = ?, updated_at = ?, version = version + 1 "
180
+ "WHERE id = ? AND version = ?",
181
+ (now, now, int(revision_id), int(expected_version)),
182
+ )
183
+ if cur.rowcount != 1:
184
+ raise EditorialConflictError("此投稿已被其他审核员更新,请刷新后继续编辑")
185
+ return await self._select_in_conn(conn, int(revision_id))
186
+
187
+ async def mark_published(self, revision_id: int, message_id: int,
188
+ published_snapshot: Dict[str, object]) -> Optional[Dict[str, Any]]:
189
+ """finalized → published, with the immutable published snapshot (§32)."""
190
+ now = _stamp()
191
+ async with get_db() as conn:
192
+ cur = await conn.execute(
193
+ "UPDATE editorial_revisions SET status = 'published', "
194
+ "published_message_id = ?, published_snapshot = ?, "
195
+ "published_at = ?, updated_at = ? "
196
+ "WHERE id = ? AND status = 'finalized'",
197
+ (
198
+ int(message_id),
199
+ json.dumps(published_snapshot, ensure_ascii=False),
200
+ now, now, int(revision_id),
201
+ ),
202
+ )
203
+ if cur.rowcount != 1:
204
+ raise EditorialStateError("该版本状态已变化,无法标记发布")
205
+ return await self._select_in_conn(conn, int(revision_id))
206
+
207
+ async def supersede_for_review(self, review_id: int) -> int:
208
+ """Decide/expire/supersede a review: terminalize its open revisions so a
209
+ stale editor can never publish them (§35)."""
210
+ now = _stamp()
211
+ async with get_db() as conn:
212
+ cur = await conn.execute(
213
+ "UPDATE editorial_revisions SET status = 'superseded', "
214
+ "updated_at = ? WHERE review_id = ? AND status IN ('draft','finalized')",
215
+ (now, int(review_id)),
216
+ )
217
+ return cur.rowcount
218
+
219
+ async def _select_in_conn(self, conn: Any, revision_id: int) -> Dict[str, Any]:
220
+ cur = await conn.execute(
221
+ "SELECT * FROM editorial_revisions WHERE id = ?", (int(revision_id),)
222
+ )
223
+ row = await cur.fetchone()
224
+ if row is None:
225
+ raise EditorialNotFoundError("revision 不存在")
226
+ return dict(row)