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.
- config/__init__.py +3 -0
- config/fly-telepost-policy.example.json +16 -0
- config/review_policy.md +64 -0
- config/settings.py +425 -0
- database/__init__.py +3 -0
- database/db_manager.py +1057 -0
- handlers/__init__.py +15 -0
- handlers/api_commands.py +73 -0
- handlers/automation.py +438 -0
- handlers/botconfig.py +255 -0
- handlers/callback_handlers.py +679 -0
- handlers/channel_listener.py +923 -0
- handlers/command_handlers.py +615 -0
- handlers/conversation.py +74 -0
- handlers/error_handler.py +99 -0
- handlers/index_handlers.py +201 -0
- handlers/mode_selection.py +116 -0
- handlers/moderation.py +145 -0
- handlers/preview_handlers.py +286 -0
- handlers/publish.py +1399 -0
- handlers/reaction_stats.py +240 -0
- handlers/recovery.py +89 -0
- handlers/review.py +2107 -0
- handlers/schedule_status.py +82 -0
- handlers/search_handlers.py +987 -0
- handlers/stats_handlers.py +439 -0
- handlers/upload.py +132 -0
- main.py +939 -0
- mcp_server/__init__.py +1 -0
- mcp_server/review_client.py +151 -0
- mcp_server/server.py +182 -0
- models/__init__.py +3 -0
- models/state.py +13 -0
- protocol/__init__.py +0 -0
- protocol/v1/SOURCES.sha256 +15 -0
- protocol/v1/__init__.py +0 -0
- protocol/v1/error-mapping.json +46 -0
- protocol/v1/fixtures/ackresult.candidate_search.json +6 -0
- protocol/v1/fixtures/capabilities.pixivflow.json +27 -0
- protocol/v1/fixtures/event.job.expired.json +13 -0
- protocol/v1/fixtures/event.job.succeeded.json +13 -0
- protocol/v1/fixtures/eventpage.candidate_search.json +26 -0
- protocol/v1/fixtures/job.cancelled.candidate_search.json +24 -0
- protocol/v1/fixtures/job.failed.no_candidate.json +30 -0
- protocol/v1/fixtures/job.queued.json +19 -0
- protocol/v1/fixtures/job.running.json +19 -0
- protocol/v1/fixtures/job.succeeded.candidate_search.json +54 -0
- protocol/v1/fixtures/jobpage.candidate_search.json +25 -0
- protocol/v1/fixtures/task.candidate_search.json +19 -0
- protocol/v1/fixtures/task.candidate_search.target_only.json +10 -0
- protocol/v1/protocol.schema.json +352 -0
- run.py +869 -0
- services/__init__.py +1 -0
- services/review_service.py +1120 -0
- telepost/__init__.py +0 -0
- telepost/api/__init__.py +0 -0
- telepost/application/__init__.py +0 -0
- telepost/application/admin_ops.py +333 -0
- telepost/application/automation.py +131 -0
- telepost/application/delivery_planner.py +245 -0
- telepost/application/editorial.py +200 -0
- telepost/application/hot.py +253 -0
- telepost/application/novel_fallback_card.py +145 -0
- telepost/application/novel_preview.py +289 -0
- telepost/application/pixivflow_jobs.py +1191 -0
- telepost/application/post_media.py +136 -0
- telepost/application/posts.py +118 -0
- telepost/application/publication.py +712 -0
- telepost/application/reaction_backfill.py +104 -0
- telepost/application/recovery.py +281 -0
- telepost/application/refetch.py +453 -0
- telepost/application/rerender.py +275 -0
- telepost/application/resubmit.py +252 -0
- telepost/application/review_queue.py +944 -0
- telepost/application/submitter_notify.py +337 -0
- telepost/application/telepress_provider.py +170 -0
- telepost/build_info.py +39 -0
- telepost/domain/__init__.py +0 -0
- telepost/domain/automation.py +168 -0
- telepost/domain/delivery.py +248 -0
- telepost/domain/editorial.py +196 -0
- telepost/domain/errors.py +74 -0
- telepost/domain/hot.py +140 -0
- telepost/domain/media.py +120 -0
- telepost/domain/navigation.py +99 -0
- telepost/domain/novel_preview.py +137 -0
- telepost/domain/packing.py +51 -0
- telepost/domain/presentation.py +161 -0
- telepost/domain/presentation_policy.py +175 -0
- telepost/domain/refetch_state.py +316 -0
- telepost/domain/review.py +75 -0
- telepost/domain/submission.py +118 -0
- telepost/miniapp/__init__.py +6 -0
- telepost/miniapp/auth.py +137 -0
- telepost/miniapp/rbac.py +102 -0
- telepost/miniapp/role_bindings.py +131 -0
- telepost/miniapp/session.py +181 -0
- telepost/observability/__init__.py +1 -0
- telepost/observability/audit.py +123 -0
- telepost/observability/cli.py +148 -0
- telepost/observability/doctor.py +1180 -0
- telepost/observability/errors.py +51 -0
- telepost/observability/redact.py +76 -0
- telepost/storage/__init__.py +0 -0
- telepost/storage/sqlite/__init__.py +0 -0
- telepost/storage/sqlite/automation.py +185 -0
- telepost/storage/sqlite/columns.py +18 -0
- telepost/storage/sqlite/editorial.py +226 -0
- telepost/storage/sqlite/ledger.py +150 -0
- telepost/storage/sqlite/media_assets.py +141 -0
- telepost/storage/sqlite/moderation.py +82 -0
- telepost/storage/sqlite/novel_preview.py +91 -0
- telepost/storage/sqlite/posts.py +68 -0
- telepost/storage/sqlite/protocol_events.py +192 -0
- telepost/storage/sqlite/recovery.py +81 -0
- telepost/storage/sqlite/refetch.py +797 -0
- telepost/storage/sqlite/reviews.py +802 -0
- telepost/storage/sqlite/submitter_notifications.py +189 -0
- telepost/telegram/__init__.py +0 -0
- telepost/telegram/capture.py +35 -0
- telepost/telegram/delivery/__init__.py +0 -0
- telepost/telegram/delivery/discussion.py +329 -0
- telepost/telegram/delivery/executor.py +369 -0
- telepost/telegram/delivery/gateway.py +107 -0
- telepost/telegram/delivery/legacy_runner.py +181 -0
- telepost/telegram/delivery/planner.py +120 -0
- telepost/telegram/delivery/preparation.py +1023 -0
- telepost/telegram/delivery/registry.py +112 -0
- telepost/telegram/delivery/sender.py +255 -0
- telepost/telegram/review_keyboard.py +266 -0
- telepost/telegram/review_stager.py +747 -0
- telepost_bot-2.78.0.dist-info/METADATA +164 -0
- telepost_bot-2.78.0.dist-info/RECORD +165 -0
- telepost_bot-2.78.0.dist-info/WHEEL +5 -0
- telepost_bot-2.78.0.dist-info/entry_points.txt +2 -0
- telepost_bot-2.78.0.dist-info/licenses/LICENSE +21 -0
- telepost_bot-2.78.0.dist-info/top_level.txt +13 -0
- ui/__init__.py +0 -0
- ui/keyboards.py +281 -0
- ui/messages.py +512 -0
- utils/__init__.py +3 -0
- utils/api_server.py +3110 -0
- utils/api_tokens.py +62 -0
- utils/blacklist.py +188 -0
- utils/cache.py +51 -0
- utils/config_wizard.py +92 -0
- utils/database.py +201 -0
- utils/file_validator.py +173 -0
- utils/heat_calculator.py +184 -0
- utils/helper_functions.py +662 -0
- utils/index_manager.py +477 -0
- utils/logging_config.py +124 -0
- utils/maintenance_jobs.py +71 -0
- utils/polling_server.py +85 -0
- utils/run_mode.py +36 -0
- utils/runtime_policy.py +86 -0
- utils/schedule_watchdog.py +77 -0
- utils/search_engine.py +515 -0
- utils/submission.py +105 -0
- utils/webhook_server.py +334 -0
- webapp/__init__.py +4 -0
- webapp/dist/assets/2d82b92e720462f8dd3b-Bh4Fahcw.svg +372 -0
- webapp/dist/assets/index-C-xxEN9W.js +110 -0
- webapp/dist/assets/index-DJgL81We.css +1 -0
- 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)
|