telepost-bot 2.84.0__tar.gz → 2.84.2__tar.gz

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 (306) hide show
  1. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/CHANGELOG.md +18 -0
  2. {telepost_bot-2.84.0/telepost_bot.egg-info → telepost_bot-2.84.2}/PKG-INFO +1 -1
  3. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/database/db_manager.py +14 -0
  4. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/callback_handlers.py +58 -15
  5. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/review.py +327 -43
  6. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/main.py +17 -2
  7. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/services/review_service.py +3 -2
  8. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/build_info.py +1 -1
  9. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/reviews.py +17 -1
  10. telepost_bot-2.84.2/telepost/telegram/callback_feedback.py +153 -0
  11. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/inbox_worker.py +38 -5
  12. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/review_keyboard.py +59 -3
  13. {telepost_bot-2.84.0 → telepost_bot-2.84.2/telepost_bot.egg-info}/PKG-INFO +1 -1
  14. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost_bot.egg-info/SOURCES.txt +2 -0
  15. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_review.py +24 -12
  16. telepost_bot-2.84.2/tests/test_review_card_recovery.py +726 -0
  17. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_review_media_lifecycle.py +8 -2
  18. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_update_inbox.py +399 -12
  19. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/ui/locales/en/LC_MESSAGES/telepost.mo +0 -0
  20. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/ui/locales/en/LC_MESSAGES/telepost.po +45 -0
  21. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/LICENSE +0 -0
  22. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/MANIFEST.in +0 -0
  23. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/README.en.md +0 -0
  24. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/README.md +0 -0
  25. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/config/__init__.py +0 -0
  26. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/config/fly-telepost-policy.example.json +0 -0
  27. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/config/review_policy.md +0 -0
  28. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/config/settings.py +0 -0
  29. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/config.ini.example +0 -0
  30. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/database/__init__.py +0 -0
  31. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/__init__.py +0 -0
  32. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/api_commands.py +0 -0
  33. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/automation.py +0 -0
  34. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/botconfig.py +0 -0
  35. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/channel_listener.py +0 -0
  36. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/command_handlers.py +0 -0
  37. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/conversation.py +0 -0
  38. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/error_handler.py +0 -0
  39. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/index_handlers.py +0 -0
  40. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/mode_selection.py +0 -0
  41. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/moderation.py +0 -0
  42. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/preview_handlers.py +0 -0
  43. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/publish.py +0 -0
  44. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/reaction_stats.py +0 -0
  45. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/recovery.py +0 -0
  46. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/schedule_status.py +0 -0
  47. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/search_handlers.py +0 -0
  48. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/stats_handlers.py +0 -0
  49. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/handlers/upload.py +0 -0
  50. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/mcp_server/__init__.py +0 -0
  51. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/mcp_server/review_client.py +0 -0
  52. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/mcp_server/server.py +0 -0
  53. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/models/__init__.py +0 -0
  54. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/models/state.py +0 -0
  55. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/__init__.py +0 -0
  56. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/SOURCES.sha256 +0 -0
  57. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/__init__.py +0 -0
  58. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/error-mapping.json +0 -0
  59. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/ackresult.candidate_search.json +0 -0
  60. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/capabilities.pixivflow.json +0 -0
  61. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/event.job.expired.json +0 -0
  62. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/event.job.succeeded.json +0 -0
  63. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/eventpage.candidate_search.json +0 -0
  64. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.cancelled.candidate_search.json +0 -0
  65. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.failed.no_candidate.json +0 -0
  66. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.queued.json +0 -0
  67. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.running.json +0 -0
  68. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.succeeded.candidate_search.json +0 -0
  69. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/jobpage.candidate_search.json +0 -0
  70. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/task.candidate_search.json +0 -0
  71. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/fixtures/task.candidate_search.target_only.json +0 -0
  72. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/protocol/v1/protocol.schema.json +0 -0
  73. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/pyproject.toml +0 -0
  74. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/run.py +0 -0
  75. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/services/__init__.py +0 -0
  76. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/setup.cfg +0 -0
  77. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/__init__.py +0 -0
  78. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/api/__init__.py +0 -0
  79. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/__init__.py +0 -0
  80. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/admin_ops.py +0 -0
  81. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/automation.py +0 -0
  82. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/delivery_planner.py +0 -0
  83. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/editorial.py +0 -0
  84. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/hot.py +0 -0
  85. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/novel_fallback_card.py +0 -0
  86. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/novel_preview.py +0 -0
  87. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/pixivflow_jobs.py +0 -0
  88. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/post_media.py +0 -0
  89. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/posts.py +0 -0
  90. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/publication.py +0 -0
  91. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/reaction_backfill.py +0 -0
  92. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/recovery.py +0 -0
  93. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/refetch.py +0 -0
  94. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/rerender.py +0 -0
  95. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/resubmit.py +0 -0
  96. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/review_queue.py +0 -0
  97. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/submitter_notify.py +0 -0
  98. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/application/telepress_provider.py +0 -0
  99. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/__init__.py +0 -0
  100. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/automation.py +0 -0
  101. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/delivery.py +0 -0
  102. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/editorial.py +0 -0
  103. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/errors.py +0 -0
  104. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/hot.py +0 -0
  105. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/media.py +0 -0
  106. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/navigation.py +0 -0
  107. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/novel_preview.py +0 -0
  108. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/packing.py +0 -0
  109. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/presentation.py +0 -0
  110. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/presentation_policy.py +0 -0
  111. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/refetch_state.py +0 -0
  112. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/review.py +0 -0
  113. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/review_chat.py +0 -0
  114. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/domain/submission.py +0 -0
  115. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/miniapp/__init__.py +0 -0
  116. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/miniapp/auth.py +0 -0
  117. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/miniapp/rbac.py +0 -0
  118. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/miniapp/role_bindings.py +0 -0
  119. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/miniapp/session.py +0 -0
  120. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/observability/__init__.py +0 -0
  121. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/observability/audit.py +0 -0
  122. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/observability/cli.py +0 -0
  123. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/observability/doctor.py +0 -0
  124. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/observability/errors.py +0 -0
  125. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/observability/redact.py +0 -0
  126. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/__init__.py +0 -0
  127. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/__init__.py +0 -0
  128. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/automation.py +0 -0
  129. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/columns.py +0 -0
  130. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/editorial.py +0 -0
  131. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/inbox.py +0 -0
  132. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/ledger.py +0 -0
  133. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/media_assets.py +0 -0
  134. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/moderation.py +0 -0
  135. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/novel_preview.py +0 -0
  136. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/posts.py +0 -0
  137. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/protocol_events.py +0 -0
  138. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/recovery.py +0 -0
  139. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/refetch.py +0 -0
  140. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/storage/sqlite/submitter_notifications.py +0 -0
  141. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/__init__.py +0 -0
  142. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/capture.py +0 -0
  143. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/delivery/__init__.py +0 -0
  144. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/delivery/discussion.py +0 -0
  145. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/delivery/executor.py +0 -0
  146. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/delivery/gateway.py +0 -0
  147. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/delivery/legacy_runner.py +0 -0
  148. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/delivery/planner.py +0 -0
  149. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/delivery/preparation.py +0 -0
  150. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/delivery/registry.py +0 -0
  151. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/delivery/sender.py +0 -0
  152. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/review_expiry.py +0 -0
  153. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/review_media.py +0 -0
  154. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost/telegram/review_stager.py +0 -0
  155. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost_bot.egg-info/dependency_links.txt +0 -0
  156. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost_bot.egg-info/entry_points.txt +0 -0
  157. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost_bot.egg-info/requires.txt +0 -0
  158. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/telepost_bot.egg-info/top_level.txt +0 -0
  159. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_admin_api.py +0 -0
  160. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_anonymous.py +0 -0
  161. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_api_commands.py +0 -0
  162. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_api_server.py +0 -0
  163. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_api_version.py +0 -0
  164. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_architecture_application.py +0 -0
  165. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_architecture_core.py +0 -0
  166. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_automation.py +0 -0
  167. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_backfill_attribution.py +0 -0
  168. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_blacklist.py +0 -0
  169. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_bot_language.py +0 -0
  170. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_bot_token_logging.py +0 -0
  171. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_botconfig.py +0 -0
  172. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_boundary.py +0 -0
  173. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_callback_contract.py +0 -0
  174. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_caption_mentions.py +0 -0
  175. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_compatibility.py +0 -0
  176. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_config_encoding.py +0 -0
  177. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_console_encoding.py +0 -0
  178. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_conversation_flow.py +0 -0
  179. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_data_class_cleanup.py +0 -0
  180. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_database.py +0 -0
  181. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_database_first_start.py +0 -0
  182. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_database_operations.py +0 -0
  183. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_delivery_planner.py +0 -0
  184. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_disposition.py +0 -0
  185. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_doctor.py +0 -0
  186. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_editorial.py +0 -0
  187. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_file_validator.py +0 -0
  188. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_fly_deploy_contract.py +0 -0
  189. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_frozen_support.py +0 -0
  190. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_handlers.py +0 -0
  191. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_heat_calculator.py +0 -0
  192. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_helper_functions.py +0 -0
  193. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_hot_domain.py +0 -0
  194. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_identity_provenance.py +0 -0
  195. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_install_scripts.py +0 -0
  196. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_integration.py +0 -0
  197. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_legacy_delivery_port_navigation.py +0 -0
  198. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_logic_flow.py +0 -0
  199. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_logical_submissions.py +0 -0
  200. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_maintenance_jobs.py +0 -0
  201. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_manager_notifications.py +0 -0
  202. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_media_decision_audit.py +0 -0
  203. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_media_delivery_pipeline.py +0 -0
  204. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_media_domain.py +0 -0
  205. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_media_preparation.py +0 -0
  206. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_media_proxy_delivery.py +0 -0
  207. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_menu_shortcut_single_reply.py +0 -0
  208. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_messages.py +0 -0
  209. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_miniapp_api.py +0 -0
  210. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_miniapp_auth.py +0 -0
  211. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_miniapp_content.py +0 -0
  212. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_miniapp_entries.py +0 -0
  213. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_miniapp_history.py +0 -0
  214. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_miniapp_rbac.py +0 -0
  215. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_mixed_media_integrity.py +0 -0
  216. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_moderation_and_admin_ux.py +0 -0
  217. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_navigation.py +0 -0
  218. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_no_owner_probe.py +0 -0
  219. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_novel_cover_preview.py +0 -0
  220. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_novel_delivery_semantics.py +0 -0
  221. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_novel_preview.py +0 -0
  222. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_observability.py +0 -0
  223. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_observability_lifecycle.py +0 -0
  224. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_online_reading_root_button.py +0 -0
  225. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_packing_ssot.py +0 -0
  226. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_polling_server.py +0 -0
  227. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_preview_pagination.py +0 -0
  228. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_production_handler_graph.py +0 -0
  229. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_protocol_contract.py +0 -0
  230. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_publication_presentation.py +0 -0
  231. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_publish_idempotency.py +0 -0
  232. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_publish_regressions.py +0 -0
  233. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_publish_reply.py +0 -0
  234. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_reaction_backfill.py +0 -0
  235. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_reaction_stats.py +0 -0
  236. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_recovery.py +0 -0
  237. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_refetch.py +0 -0
  238. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_refetch_card_state.py +0 -0
  239. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_refetch_events.py +0 -0
  240. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_refetch_job_lifecycle.py +0 -0
  241. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_refetch_reason_normalization.py +0 -0
  242. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_refetch_replacement.py +0 -0
  243. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_remote_fetch_fallback.py +0 -0
  244. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_rerender.py +0 -0
  245. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_resubmit.py +0 -0
  246. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_review_album_payload.py +0 -0
  247. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_review_chat_contract.py +0 -0
  248. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_review_consistency.py +0 -0
  249. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_review_expiry_recovery.py +0 -0
  250. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_review_http_mcp.py +0 -0
  251. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_review_service.py +0 -0
  252. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_robustness.py +0 -0
  253. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_run_dispatcher.py +0 -0
  254. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_run_mode.py +0 -0
  255. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_run_supervisor.py +0 -0
  256. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_schedule_outcomes.py +0 -0
  257. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_search_disabled_hint.py +0 -0
  258. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_search_recovery.py +0 -0
  259. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_security.py +0 -0
  260. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_security_headers.py +0 -0
  261. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_session_database_handles.py +0 -0
  262. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_shutdown.py +0 -0
  263. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_streaming_uploads.py +0 -0
  264. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_submission_timeout_idempotency.py +0 -0
  265. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_telegram_golden_messages.py +0 -0
  266. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_telegram_media_cache.py +0 -0
  267. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_upload.py +0 -0
  268. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_user_friendly_messages.py +0 -0
  269. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_webhook_payload_admission.py +0 -0
  270. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_webhook_ready.py +0 -0
  271. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_webhook_router.py +0 -0
  272. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_webhook_secret.py +0 -0
  273. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_webhook_secret_logging.py +0 -0
  274. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_webhook_static.py +0 -0
  275. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/tests/test_work_id_wire.py +0 -0
  276. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/ui/__init__.py +0 -0
  277. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/ui/i18n.py +0 -0
  278. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/ui/keyboards.py +0 -0
  279. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/ui/messages.py +0 -0
  280. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/__init__.py +0 -0
  281. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/api_server.py +0 -0
  282. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/api_tokens.py +0 -0
  283. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/blacklist.py +0 -0
  284. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/cache.py +0 -0
  285. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/config_wizard.py +0 -0
  286. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/console.py +0 -0
  287. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/database.py +0 -0
  288. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/file_validator.py +0 -0
  289. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/heat_calculator.py +0 -0
  290. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/helper_functions.py +0 -0
  291. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/index_manager.py +0 -0
  292. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/logging_config.py +0 -0
  293. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/maintenance_jobs.py +0 -0
  294. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/polling_server.py +0 -0
  295. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/run_mode.py +0 -0
  296. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/runtime_policy.py +0 -0
  297. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/schedule_watchdog.py +0 -0
  298. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/search_engine.py +0 -0
  299. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/security_headers.py +0 -0
  300. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/submission.py +0 -0
  301. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/utils/webhook_server.py +0 -0
  302. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/webapp/__init__.py +0 -0
  303. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/webapp/dist/assets/2d82b92e720462f8dd3b-Bh4Fahcw.svg +0 -0
  304. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/webapp/dist/assets/index-CLfkW8eZ.css +0 -0
  305. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/webapp/dist/assets/index-CfjBQN_e.js +0 -0
  306. {telepost_bot-2.84.0 → telepost_bot-2.84.2}/webapp/dist/index.html +0 -0
@@ -8,6 +8,24 @@
8
8
  > 发布说明面向普通用户:写「对用户有什么改变」,不写内部实现细节(模块名、函数名、配置键);
9
9
  > 技术细节放进代码注释或 `docs/`。每个 Release 的正文由本文件对应版本段自动生成。
10
10
 
11
+ ## [2.84.2](https://github.com/redtidev1918/TelePost/compare/v2.84.1...v2.84.2) (2026-10-22)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **review:** answer each callback once and make failure feedback non-destructive ([223b1c1](https://github.com/redtidev1918/TelePost/commit/223b1c1f4e718273253a140c416e8eea58458989))
17
+ * **review:** keep review cards actionable when a callback fails ([455ba69](https://github.com/redtidev1918/TelePost/commit/455ba690e0ac15a95b986c8e83eeaadf6e5375bf))
18
+ * **review:** keep review cards actionable when a callback fails ([1827946](https://github.com/redtidev1918/TelePost/commit/1827946759284ddf9143f913bb22ae2b1a6408dc))
19
+
20
+ ## [2.84.1](https://github.com/redtidev1918/TelePost/compare/v2.84.0...v2.84.1) (2026-10-22)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * exclude runtime tasks from update persistence ([b420ed6](https://github.com/redtidev1918/TelePost/commit/b420ed629b2fa1e64d38f2f2a763439b199d956f))
26
+ * harden inbox completion and spoiler replay idempotency ([4c6375c](https://github.com/redtidev1918/TelePost/commit/4c6375c4514cfe2b5ae5f18f55a6169b8763ef7c))
27
+ * prevent runtime task persistence from blocking review callbacks ([21dec7d](https://github.com/redtidev1918/TelePost/commit/21dec7d41965bb436d309715c1e58668ae479d1c))
28
+
11
29
  ## [2.84.0](https://github.com/redtidev1918/TelePost/compare/v2.83.2...v2.84.0) (2026-10-21)
12
30
 
13
31
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telepost-bot
3
- Version: 2.84.0
3
+ Version: 2.84.2
4
4
  Summary: Telegram submission, review, and publication bot with a Mini App, HTTP API, and multi-bot supervisor.
5
5
  Author: redtidev1918
6
6
  License-Expression: MIT
@@ -107,6 +107,17 @@ async def init_db():
107
107
  'CREATE INDEX IF NOT EXISTS idx_webhook_inbox_due '
108
108
  'ON webhook_inbox(state, next_attempt_at, update_id)')
109
109
 
110
+ # A toggle and its callback receipt commit together; Inbox replay
111
+ # must not invert the same review a second time.
112
+ await conn.execute('''
113
+ CREATE TABLE IF NOT EXISTS review_spoiler_callbacks (
114
+ callback_id TEXT NOT NULL,
115
+ review_id INTEGER NOT NULL,
116
+ created_at REAL NOT NULL,
117
+ PRIMARY KEY (callback_id, review_id)
118
+ )
119
+ ''')
120
+
110
121
  # 媒体追加去重日志:同一 (user, update) 跨投稿代只应用一次
111
122
  # (epoch 仅作审计),与 submissions 行的更新同事务提交,崩溃后
112
123
  # 重放收敛。worker 严格按 update_id 头部顺序处理,正常路径不会
@@ -1063,6 +1074,9 @@ async def cleanup_old_data():
1063
1074
  await conn.execute(
1064
1075
  "DELETE FROM webhook_inbox WHERE state='done' AND done_at < ?",
1065
1076
  (now_ts - 7 * 86400,))
1077
+ await conn.execute(
1078
+ "DELETE FROM review_spoiler_callbacks WHERE review_id NOT IN "
1079
+ "(SELECT id FROM pending_reviews)")
1066
1080
  await conn.execute(
1067
1081
  "DELETE FROM webhook_inbox WHERE state='dead' AND updated_at < ?",
1068
1082
  (now_ts - 30 * 86400,))
@@ -3,10 +3,12 @@
3
3
  """
4
4
  from ui.i18n import tr
5
5
  import logging
6
+ import re
6
7
  from telegram import Update
7
8
  from telegram.ext import CallbackContext, ConversationHandler
8
9
  from telegram.constants import ParseMode
9
10
 
11
+ from telepost.telegram.callback_feedback import answer_callback, has_answered
10
12
  from ui.keyboards import Keyboards
11
13
  from ui.messages import MessageFormatter
12
14
  from database.db_manager import get_db
@@ -33,16 +35,15 @@ GLOBAL_CALLBACK_PATTERNS = (
33
35
 
34
36
  async def _safe_answer(query, text: str = None, **kwargs):
35
37
  """
36
- 安全应答回调查询。
38
+ 安全应答回调查询(§review-card-recovery)。
37
39
 
38
- 每个 callback query 只能被应答一次,重复应答会抛出 telegram.error.BadRequest。
39
- 这里统一吞掉该类错误:先到的应答生效(例如带 show_alert 的提示),
40
- 后续重复调用不再中断业务流程。
40
+ 每个 callback query 只能被应答一次,重复应答会被 Telegram 拒绝。这里统一
41
+ 委托给 ``callback_feedback.answer_callback``:先到的应答生效(例如带
42
+ show_alert 的提示),后续重复调用直接跳过且不抛错。
41
43
  """
42
- try:
43
- await query.answer(text=text, **kwargs)
44
- except Exception as e:
45
- logger.debug(f"回调应答失败(通常为重复应答或已过期): {e}")
44
+ await answer_callback(
45
+ query, text, show_alert=bool(kwargs.get("show_alert", False)),
46
+ )
46
47
 
47
48
 
48
49
  async def handle_callback_query(update: Update, context: CallbackContext):
@@ -173,17 +174,59 @@ async def handle_callback_query(update: Update, context: CallbackContext):
173
174
 
174
175
  except Exception as e:
175
176
  logger.error(f"处理回调查询时出错: {e}", exc_info=True)
176
- try:
177
- await query.edit_message_text(
178
- MessageFormatter.error_message("general")
177
+ # §review-card-recovery: no text-based "UI-only" classification here. The
178
+ # global catch is the *outer* boundary: a Telegram edit failure inside a
179
+ # handler is classified at that call site (is_ui_only_error requires a
180
+ # BadRequest raised by the edit itself), while anything reaching this
181
+ # level may have been raised by a channel publish, media upload or the
182
+ # review DB. Treating it as display noise would mark the update done and
183
+ # silently drop a retryable business failure.
184
+ #
185
+ # Feedback is answer-only when the callback has not been answered yet
186
+ # (handlers answer exactly once, before long work); otherwise the card is
187
+ # rebuilt from the row — a second answer would be dropped by Telegram.
188
+ if not has_answered(query):
189
+ await _safe_answer(
190
+ query, tr('操作失败,已记录;卡片与审核状态保持不变。'),
191
+ show_alert=True,
179
192
  )
180
- except:
181
- pass
193
+ await _restore_card_after_failure(context, data)
194
+ # Re-raise so PTB's error observers and the durable inbox worker record a
195
+ # bounded retry instead of a silent "done".
196
+ raise
197
+
198
+
199
+ _REVIEW_CALLBACK_DATA = re.compile(
200
+ r"^review_(?:approve|reject|spoiler|preview|refetch|block_user|block_api):(\d+)$"
201
+ )
202
+
203
+
204
+ async def _restore_card_after_failure(context, data: str) -> None:
205
+ """Best-effort card rebuild for a review callback that just failed."""
206
+ match = _REVIEW_CALLBACK_DATA.match(data or "")
207
+ if not match:
208
+ return
209
+ bot = getattr(context, "bot", None)
210
+ if bot is None:
211
+ return
212
+ try:
213
+ from handlers.review import restore_review_card
214
+ await restore_review_card(bot, int(match.group(1)))
215
+ except Exception:
216
+ logger.debug("失败后恢复审核卡出错: data=%s", data, exc_info=True)
182
217
 
183
218
 
184
219
  async def handle_unknown_callback(update: Update, context: CallbackContext):
185
- """Final fallback for callback data unclaimed by every business namespace."""
186
- await update.callback_query.edit_message_text(tr('❌ 未知操作'))
220
+ """Final fallback for callback data unclaimed by every business namespace.
221
+
222
+ §review-card-recovery: an unroutable click (stale menu, old client, a card
223
+ whose namespace was removed) is answered with a popup. The message is NOT
224
+ edited — editing it here used to strip the keyboard off a message that may
225
+ still carry working buttons.
226
+ """
227
+ query = update.callback_query
228
+ logger.warning("未路由的回调查询: %s", query.data)
229
+ await _safe_answer(query, tr('❌ 未知操作'), show_alert=True)
187
230
 
188
231
 
189
232
  async def handle_hot_filter(update: Update, context: CallbackContext):
@@ -49,6 +49,10 @@ from telepost.telegram.delivery.preparation import PHOTO_MAX_BYTES
49
49
  from telepost.telegram.delivery.sender import file_id_of as _file_id_of
50
50
  from telepost.telegram.review_stager import TelegramReviewStager
51
51
  from telepost.telegram import review_keyboard
52
+ from telepost.telegram.callback_feedback import (
53
+ answer_callback,
54
+ is_ui_only_error,
55
+ )
52
56
 
53
57
  logger = logging.getLogger(__name__)
54
58
 
@@ -1572,6 +1576,214 @@ async def refresh_refetch_card(bot, review_id: int, *,
1572
1576
  return False
1573
1577
 
1574
1578
 
1579
+ async def _record_card_restored(review_id: int, outcome: str, status: str,
1580
+ actor=None) -> None:
1581
+ try:
1582
+ from telepost.observability import audit
1583
+ await audit.record_event(
1584
+ "review.card_restored", review_id=int(review_id), actor=actor,
1585
+ detail={"status": status, "result": outcome},
1586
+ )
1587
+ except Exception:
1588
+ logger.debug("记录审核卡恢复审计失败: review_id=%s", review_id, exc_info=True)
1589
+
1590
+
1591
+ async def restore_review_card(bot, review_id: int, *, dry_run: bool = False,
1592
+ actor=None) -> dict:
1593
+ """Re-render a review's control card from its DB row (§review-card-recovery).
1594
+
1595
+ The row is the source of truth and Telegram is a projection: a card that a
1596
+ failed callback replaced with an error text (or that lost its buttons on a
1597
+ replay) is rebuilt in place from ``pending_reviews``. Only an actionable
1598
+ review gets moderation buttons — terminal rows come back READ-ONLY — and
1599
+ review state is NEVER touched here. Nothing is ever reposted: the edit always
1600
+ targets the durable ``control_message_id``.
1601
+
1602
+ Never raises; returns a small JSON-serializable plan/result dict.
1603
+ """
1604
+ result: dict = {"review_id": int(review_id), "ok": False}
1605
+ try:
1606
+ from telepost.storage.sqlite.reviews import ReviewRepository
1607
+ row = await ReviewRepository().get(int(review_id))
1608
+ except Exception:
1609
+ logger.warning("恢复审核卡:读取审核记录失败 review_id=%s", review_id, exc_info=True)
1610
+ result["reason"] = "read_failed"
1611
+ return result
1612
+ if row is None:
1613
+ result["reason"] = "not_found"
1614
+ return result
1615
+
1616
+ status = _row_value(row, "status") or ""
1617
+ control = _row_value(row, "control_message_id")
1618
+ chat_id = _row_value(row, "review_chat_id") or REVIEW_CHAT_ID
1619
+ result.update({"status": status, "control_message_id": control,
1620
+ "chat_id": str(chat_id)})
1621
+ if not control:
1622
+ # A card was never durably recorded: the queue's
1623
+ # reconcile_incomplete_reviews path owns that row. Never repost here.
1624
+ result["reason"] = "no_control_message"
1625
+ return result
1626
+
1627
+ if status == "pending":
1628
+ if dry_run:
1629
+ result.update({"ok": True, "dry_run": True, "card": "pending"})
1630
+ return result
1631
+ if await refresh_refetch_card(bot, int(review_id)):
1632
+ result.update({"ok": True, "card": "pending", "edit": "edited"})
1633
+ await _record_card_restored(review_id, "edited", status, actor)
1634
+ else:
1635
+ result.update({"ok": False, "reason": "edit_failed", "card": "pending"})
1636
+ return result
1637
+
1638
+ text, markup = review_keyboard.card_from_row(row)
1639
+ result.update({"card": "read_only", "text": text})
1640
+ if dry_run:
1641
+ result.update({"ok": True, "dry_run": True})
1642
+ return result
1643
+
1644
+ from telepost.telegram.callback_feedback import edit_card_message
1645
+ try:
1646
+ outcome = await edit_card_message(bot, chat_id, control, text, markup)
1647
+ except Exception:
1648
+ logger.warning("恢复审核卡:编辑消息失败 review_id=%s", review_id, exc_info=True)
1649
+ result.update({"ok": False, "reason": "edit_failed"})
1650
+ await _record_card_restored(review_id, "failed", status, actor)
1651
+ return result
1652
+ result.update({"ok": outcome in ("edited", "unchanged"), "edit": outcome})
1653
+ if outcome == "gone":
1654
+ result["reason"] = "message_gone"
1655
+ await _record_card_restored(review_id, outcome, status, actor)
1656
+ return result
1657
+
1658
+
1659
+ async def _notify_state_mismatch(query, context, review_id: int, text: str) -> None:
1660
+ """§review-card-recovery: report a decided/changed review WITHOUT killing the card.
1661
+
1662
+ The callback was already answered before the business call, so a second
1663
+ ``answerCallbackQuery`` would be ignored by Telegram and the operator would
1664
+ see nothing. Instead the card is rebuilt from the row: it shows the real
1665
+ state (read-only when terminal) plus the original-link button, which is the
1666
+ non-destructive feedback the operator needs. The reason is logged either way.
1667
+ """
1668
+ logger.info(
1669
+ "审核操作与当前状态不一致(已应答,改用卡片状态同步): review_id=%s reason=%s",
1670
+ review_id, text,
1671
+ )
1672
+ bot = getattr(context, "bot", None)
1673
+ if bot is None:
1674
+ return
1675
+ await restore_review_card(bot, int(review_id))
1676
+
1677
+
1678
+ _TERMINAL_REVIEW_STATUSES = ("rejected", "expired", "superseded")
1679
+
1680
+
1681
+ async def _terminal_state_alert(query, context, review_id: int) -> bool:
1682
+ """Answer a click on an already-decided review, BEFORE any business call.
1683
+
1684
+ This is the only place a state conflict still gets a popup: the callback has
1685
+ not been answered yet, so the alert actually reaches the client. The database
1686
+ state machine stays authoritative — this pre-flight only decides *feedback*;
1687
+ a pending/failed/publishing row still goes through the service CAS, and a
1688
+ published row keeps the publish-reuse ledger path (``duplicate_suppressed``)
1689
+ instead of being short-circuited here.
1690
+ Returns True when the handler must stop.
1691
+ """
1692
+ row = await review_service.get_row(review_id)
1693
+ if row is None:
1694
+ await _answer(query, tr('审核记录不存在'), show_alert=True)
1695
+ return True
1696
+ status = _row_value(row, "status") or ""
1697
+ if status not in _TERMINAL_REVIEW_STATUSES:
1698
+ return False
1699
+ await _answer(query, f"该投稿当前状态:{status}", show_alert=True)
1700
+ bot = getattr(context, "bot", None)
1701
+ if bot is not None:
1702
+ await restore_review_card(bot, int(review_id))
1703
+ return True
1704
+
1705
+
1706
+ async def _edit_card_or_recover(query, context, review_id: int, text: str,
1707
+ markup) -> None:
1708
+ """Update a card AFTER a business decision has been committed.
1709
+
1710
+ A display failure here must never repeat the business action (publish,
1711
+ reject, refetch) — the inbox would replay the whole handler. The card is
1712
+ therefore best-effort: Telegram-side edit failures are logged and the card
1713
+ is re-rendered read-only from the row; non-UI failures are surfaced in the
1714
+ log and recovered the same way instead of being re-raised.
1715
+ """
1716
+ try:
1717
+ await query.edit_message_text(
1718
+ text, reply_markup=markup, disable_web_page_preview=True,
1719
+ )
1720
+ return
1721
+ except Exception as exc: # noqa: BLE001 - post-commit display must not raise
1722
+ if is_ui_only_error(exc):
1723
+ logger.info(
1724
+ "审核卡更新未生效(消息不可编辑/未变化,业务已完成): review_id=%s reason=%s",
1725
+ review_id, exc,
1726
+ )
1727
+ else:
1728
+ logger.warning(
1729
+ "审核卡更新失败(业务已完成,不回滚、不重试): review_id=%s",
1730
+ review_id, exc_info=True,
1731
+ )
1732
+ bot = getattr(context, "bot", None)
1733
+ if bot is not None:
1734
+ await restore_review_card(bot, review_id)
1735
+
1736
+
1737
+ async def restore_card_command(update, context):
1738
+ """`/restore_card <review_id> [--dry-run]` — admin recovery of a lost card.
1739
+
1740
+ The command only ever touches the database of the Bot it is sent to: every
1741
+ Bot owns its own SQLite file and review ids collide across Bots, so an admin
1742
+ must send it to the target Bot itself. Cross-Bot addressing is unsupported
1743
+ and rejected explicitly.
1744
+ """
1745
+ user = update.effective_user
1746
+ if user is None or user.id not in ADMIN_IDS:
1747
+ await update.message.reply_text(tr('你没有审核权限'))
1748
+ return
1749
+ args = list(getattr(context, "args", None) or [])
1750
+ # Exactly: one review id (``202`` or ``#202``) + optionally one ``--dry-run``.
1751
+ # ``--bot``, unknown flags, extra tokens and duplicated flags are rejected
1752
+ # without touching any Telegram card.
1753
+ valid = (
1754
+ len(args) in (1, 2)
1755
+ and re.fullmatch(r"#?\d+", args[0]) is not None
1756
+ and (len(args) == 1 or args[1] == "--dry-run")
1757
+ )
1758
+ if not valid:
1759
+ await update.message.reply_text(
1760
+ tr('用法:/restore_card <审核ID> [--dry-run](只重建卡片,不改变审核状态)')
1761
+ )
1762
+ return
1763
+ review_id = int(args[0].lstrip("#"))
1764
+ dry_run = len(args) == 2
1765
+ result = await restore_review_card(
1766
+ context.bot, review_id, dry_run=dry_run, actor=user.id,
1767
+ )
1768
+ if not result.get("ok"):
1769
+ await update.message.reply_text(
1770
+ tr('恢复审核卡失败:{p0}').format(p0=result.get("reason") or 'unknown')
1771
+ )
1772
+ return
1773
+ if dry_run:
1774
+ await update.message.reply_text(
1775
+ tr('审核 #{p0} 卡片预览(状态:{p1},未修改任何内容):\n\n{p2}').format(
1776
+ p0=review_id, p1=result.get("status"), p2=result.get("text") or tr('(待审核,将按重抓状态重建)'),
1777
+ )
1778
+ )
1779
+ return
1780
+ await update.message.reply_text(
1781
+ tr('已恢复审核 #{p0} 的卡片(状态:{p1},未改变审核状态)。').format(
1782
+ p0=review_id, p1=result.get("status"),
1783
+ )
1784
+ )
1785
+
1786
+
1575
1787
  async def reconcile_incomplete_reviews(bot, *, stale_seconds: float = 60.0) -> int:
1576
1788
  """Repair crash-left review rows by restoring a durable control message."""
1577
1789
  repaired = await queue_service.reconcile_incomplete(
@@ -1677,10 +1889,13 @@ async def _apply_review_group_mask(bot, row, mask_on: bool):
1677
1889
 
1678
1890
  # ---- callback handlers ----------------------------------------------------
1679
1891
  async def _answer(query, text=None, **kwargs):
1680
- try:
1681
- await query.answer(text=text, **kwargs)
1682
- except Exception:
1683
- logger.debug("回应审核按钮失败", exc_info=True)
1892
+ # §review-card-recovery: Telegram accepts exactly ONE answer per callback
1893
+ # query. Bookkeeping lives in ``callback_feedback.answer_callback`` so every
1894
+ # handler (and the global fallback) shares it; a second answer would be
1895
+ # silently dropped by Telegram, losing the operator-visible message.
1896
+ await answer_callback(
1897
+ query, text, show_alert=bool(kwargs.get("show_alert", False)),
1898
+ )
1684
1899
 
1685
1900
 
1686
1901
  async def toggle_review_spoiler(update, context):
@@ -1696,7 +1911,8 @@ async def toggle_review_spoiler(update, context):
1696
1911
 
1697
1912
  try:
1698
1913
  await review_service.toggle_spoiler(
1699
- review_id, actor=update.effective_user.id
1914
+ review_id, actor=update.effective_user.id,
1915
+ callback_id=query.id if isinstance(query.id, str) else "",
1700
1916
  )
1701
1917
  except ReviewNotFoundError:
1702
1918
  await _answer(query, tr('审核记录不存在'), show_alert=True)
@@ -1727,24 +1943,17 @@ async def toggle_review_spoiler(update, context):
1727
1943
  media_cleared=_row_value(row, "preview_state") == "cleared",
1728
1944
  )
1729
1945
  )
1730
- except Exception:
1731
- # §review-button-refresh: the mask is already in the DB and the
1732
- # review-group media already re-masked above, so a keyboard refresh
1733
- # failure must NOT be swallowed silently — otherwise the button label
1734
- # stays showing the stale spoiler value. Log at warn (with traceback)
1735
- # and surface a transient, visible alert to the admin via show_alert
1736
- # (a popup, not a new chat message, so repeated toggles do not spam).
1946
+ except Exception as exc: # noqa: BLE001 - business already committed
1947
+ # §review-card-recovery: the spoiler flag + the review-group mask are
1948
+ # already applied; a keyboard refresh failure must not be re-raised (the
1949
+ # inbox would replay the handler and flip the mask again). Log and rebuild
1950
+ # the card from the row — the callback was answered earlier, so a second
1951
+ # answer would be dropped by Telegram anyway.
1737
1952
  logger.warning(
1738
- "刷新审核键盘失败,审核卡遮罩按钮标签可能已陈旧(遮罩状态已入库生效): "
1739
- "review_id=%s",
1740
- review_id,
1741
- exc_info=True,
1742
- )
1743
- await _answer(
1744
- query,
1745
- tr('已遮罩,但按钮无法刷新(状态已{p0})。').format(p0=tr('开启') if new_spoiler else tr('关闭')),
1746
- show_alert=True,
1953
+ "刷新审核键盘失败,已按库内状态重建审核卡: review_id=%s ui_only=%s",
1954
+ review_id, is_ui_only_error(exc), exc_info=True,
1747
1955
  )
1956
+ await restore_review_card(bot, review_id)
1748
1957
 
1749
1958
 
1750
1959
  async def restore_review_preview(update, context):
@@ -1792,13 +2001,30 @@ async def restore_review_preview(update, context):
1792
2001
  await _answer(query, tr('预览正在恢复中,请稍候。'))
1793
2002
  return
1794
2003
 
2004
+ # Answer BEFORE the staging uploads: regeneration sends media to Telegram
2005
+ # and can outlive the callback answer window, and a later answer would be
2006
+ # rejected by Telegram (one answer per callback). The restored media itself
2007
+ # is the visible outcome; the card keeps its handle either way.
2008
+ await _answer(query, tr('预览正在恢复中,请稍候。'))
2009
+
1795
2010
  from telepost.telegram.review_media import regenerate_review_preview
1796
2011
  result = await regenerate_review_preview(context.bot, row)
1797
2012
  if not result["ok"]:
1798
- await _answer(query, result["message"], show_alert=True)
2013
+ logger.warning(
2014
+ "恢复审核预览失败: review_id=%s reason=%s",
2015
+ review_id, result.get("message"),
2016
+ )
2017
+ chat_id = getattr(getattr(query, "message", None), "chat_id", None)
2018
+ if chat_id is not None:
2019
+ try:
2020
+ await context.bot.send_message(
2021
+ chat_id=chat_id,
2022
+ text=f"⚠️ 审核 #{review_id} 预览恢复失败:{result['message']}",
2023
+ )
2024
+ except Exception: # noqa: BLE001 - notice is best-effort
2025
+ logger.debug("发送预览恢复失败提示失败", exc_info=True)
2026
+ await restore_review_card(context.bot, review_id)
1799
2027
  return
1800
- await _answer(query, tr('预览已恢复({p0} 条消息),将按同样的时限自动清理。').format(
1801
- p0=len(result["message_ids"])))
1802
2028
  try:
1803
2029
  await query.edit_message_reply_markup(
1804
2030
  reply_markup=_review_keyboard(
@@ -2003,12 +2229,21 @@ async def approve_review(update, context):
2003
2229
  if update.effective_user.id not in ADMIN_IDS:
2004
2230
  await _answer(query, tr('你没有审核权限'), show_alert=True)
2005
2231
  return
2006
- await _answer(query)
2007
2232
  try:
2008
2233
  review_id = int(query.data.split(":", 1)[1])
2009
2234
  except (ValueError, IndexError):
2010
- await query.edit_message_text(tr('❌ 无效的审核记录'))
2235
+ # §review-card-recovery: invalid payload is answerable — alert, never
2236
+ # overwrite the card with an error text.
2237
+ await _answer(query, tr('无效的审核记录'), show_alert=True)
2011
2238
  return
2239
+ # Pre-flight: an already-decided review is answered HERE (the callback is
2240
+ # still answerable, so the popup actually reaches the operator). The review
2241
+ # state machine (service CAS) stays authoritative for anything not terminal.
2242
+ if await _terminal_state_alert(query, context, review_id):
2243
+ return
2244
+ # Single acknowledgement before the (possibly tens-of-seconds) publish. All
2245
+ # later feedback must be card state sync, not a second answer.
2246
+ await _answer(query)
2012
2247
 
2013
2248
  async def _show_publishing(row):
2014
2249
  try:
@@ -2018,7 +2253,8 @@ async def approve_review(update, context):
2018
2253
  reply_markup=InlineKeyboardMarkup([]),
2019
2254
  )
2020
2255
  except Exception:
2021
- pass
2256
+ logger.debug("发布中卡片更新失败(不改写业务状态): review_id=%s", review_id,
2257
+ exc_info=True)
2022
2258
 
2023
2259
  try:
2024
2260
  result = await review_service.approve(
@@ -2027,20 +2263,25 @@ async def approve_review(update, context):
2027
2263
  on_claim=_show_publishing,
2028
2264
  )
2029
2265
  except ReviewNotFoundError:
2030
- await query.edit_message_text(tr('❌ 审核记录不存在'))
2266
+ logger.info("审核不存在(已应答,改用卡片同步): review_id=%s", review_id)
2267
+ await restore_review_card(context.bot, review_id)
2031
2268
  return
2032
2269
  except ReviewBusyError:
2033
- await query.edit_message_text(tr('ℹ️ 该投稿当前状态:publishing'))
2034
- return
2035
- except ReviewStateError as error:
2036
- await query.edit_message_text(f"ℹ️ {error.message}")
2270
+ # Still being published by another claim: the card already carries the
2271
+ # in-progress shape; re-render it instead of a second answer.
2272
+ logger.info("审核正在发布中(已应答): review_id=%s", review_id)
2273
+ await restore_review_card(context.bot, review_id)
2037
2274
  return
2038
2275
  except PublishFailedError as error:
2039
2276
  row = await _load_review_for_action(query, review_id)
2040
- await query.edit_message_text(
2277
+ if row is None:
2278
+ await restore_review_card(context.bot, review_id)
2279
+ return
2280
+ await _edit_card_or_recover(
2281
+ query, context, review_id,
2041
2282
  tr("""⚠️ 审核 #{p0} {p1}:
2042
2283
  {p2}""").format(p0=review_id, p1=error.retry_hint, p2=error.message),
2043
- reply_markup=_review_keyboard(
2284
+ _review_keyboard(
2044
2285
  review_id, row["link"],
2045
2286
  spoiler=bool(row["spoiler"]), source=row["source"],
2046
2287
  work_id=_work_id_from_link(row["link"] or ""),
@@ -2051,11 +2292,21 @@ async def approve_review(update, context):
2051
2292
  ),
2052
2293
  )
2053
2294
  return
2295
+ except ReviewStateError as error:
2296
+ await _notify_state_mismatch(query, context, review_id, error.message)
2297
+ return
2298
+ except ReviewError as error:
2299
+ await _notify_state_mismatch(
2300
+ query, context, review_id,
2301
+ getattr(error, "message", None) or tr('该投稿已处理,无法发布'),
2302
+ )
2303
+ return
2054
2304
 
2055
- await query.edit_message_text(
2305
+ await _edit_card_or_recover(
2306
+ query, context, review_id,
2056
2307
  tr("""✅ 审核 #{p0} 已发布
2057
2308
  {p1}""").format(p0=review_id, p1=result.link or ''),
2058
- disable_web_page_preview=True,
2309
+ None,
2059
2310
  )
2060
2311
 
2061
2312
 
@@ -2064,12 +2315,14 @@ async def reject_review(update, context):
2064
2315
  if update.effective_user.id not in ADMIN_IDS:
2065
2316
  await _answer(query, tr('你没有审核权限'), show_alert=True)
2066
2317
  return
2067
- await _answer(query)
2068
2318
  try:
2069
2319
  review_id = int(query.data.split(":", 1)[1])
2070
2320
  except (ValueError, IndexError):
2071
- await query.edit_message_text(tr('❌ 无效的审核记录'))
2321
+ await _answer(query, tr('无效的审核记录'), show_alert=True)
2322
+ return
2323
+ if await _terminal_state_alert(query, context, review_id):
2072
2324
  return
2325
+ await _answer(query)
2073
2326
 
2074
2327
  try:
2075
2328
  await review_service.reject(
@@ -2077,13 +2330,23 @@ async def reject_review(update, context):
2077
2330
  actor=update.effective_user.id, source="telegram",
2078
2331
  )
2079
2332
  except ReviewNotFoundError:
2080
- await query.edit_message_text(tr('❌ 审核记录不存在'))
2333
+ logger.info("审核不存在(已应答,改用卡片同步): review_id=%s", review_id)
2334
+ await restore_review_card(context.bot, review_id)
2081
2335
  return
2082
2336
  except ReviewStateError as error:
2083
- await query.edit_message_text(f"ℹ️ {error.message}")
2337
+ await _notify_state_mismatch(query, context, review_id, error.message)
2338
+ return
2339
+ except ReviewError as error:
2340
+ await _notify_state_mismatch(
2341
+ query, context, review_id,
2342
+ getattr(error, "message", None) or tr('该投稿已处理,无法拒绝'),
2343
+ )
2084
2344
  return
2085
2345
 
2086
- await query.edit_message_text(tr('❌ 审核 #{p0} 已拒绝').format(p0=review_id))
2346
+ await _edit_card_or_recover(
2347
+ query, context, review_id,
2348
+ tr('❌ 审核 #{p0} 已拒绝').format(p0=review_id), None,
2349
+ )
2087
2350
 
2088
2351
 
2089
2352
  async def block_review_user(update, context):
@@ -2120,10 +2383,22 @@ async def block_review_user(update, context):
2120
2383
  )
2121
2384
  await _answer(query, tr('已封禁该投稿人:后续投稿将被自动拒绝'))
2122
2385
  try:
2386
+ # §review-card-recovery: append the audit note WITHOUT dropping the
2387
+ # keyboard — losing 发布/拒绝 on a still-pending review is exactly the
2388
+ # reported production failure, only here it would be self-inflicted.
2123
2389
  await query.edit_message_text(
2124
2390
  tr("""{p0}
2125
2391
 
2126
- 🚫 投稿人封禁已由管理员 {p1} 记录""").format(p0=query.message.text, p1=update.effective_user.id)
2392
+ 🚫 投稿人封禁已由管理员 {p1} 记录""").format(p0=query.message.text, p1=update.effective_user.id),
2393
+ reply_markup=_review_keyboard(
2394
+ int(review_id), row["link"],
2395
+ spoiler=bool(row["spoiler"]), source=row["source"],
2396
+ work_id=_work_id_from_link(row["link"] or ""),
2397
+ submitter_user_id=_row_value(row, "submitter_user_id"),
2398
+ actor_kind=_row_value(row, "actor_kind") or "user",
2399
+ actor_subject=_row_value(row, "actor_subject") or "",
2400
+ media_cleared=_row_value(row, "preview_state") == "cleared",
2401
+ ),
2127
2402
  )
2128
2403
  except Exception as exc:
2129
2404
  logger.debug("封禁后更新审核卡失败: %s", exc)
@@ -2165,7 +2440,16 @@ async def block_review_api(update, context):
2165
2440
  await query.edit_message_text(
2166
2441
  tr("""{p0}
2167
2442
 
2168
- 🔑 API token 禁用已由管理员 {p1} 记录""").format(p0=query.message.text, p1=update.effective_user.id)
2443
+ 🔑 API token 禁用已由管理员 {p1} 记录""").format(p0=query.message.text, p1=update.effective_user.id),
2444
+ reply_markup=_review_keyboard(
2445
+ int(review_id), row["link"],
2446
+ spoiler=bool(row["spoiler"]), source=row["source"],
2447
+ work_id=_work_id_from_link(row["link"] or ""),
2448
+ submitter_user_id=_row_value(row, "submitter_user_id"),
2449
+ actor_kind=_row_value(row, "actor_kind") or "user",
2450
+ actor_subject=_row_value(row, "actor_subject") or "",
2451
+ media_cleared=_row_value(row, "preview_state") == "cleared",
2452
+ ),
2169
2453
  )
2170
2454
  except Exception as exc:
2171
2455
  logger.debug("禁用API后更新审核卡失败: %s", exc)