telepost-bot 2.84.1__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.1 → telepost_bot-2.84.2}/CHANGELOG.md +9 -0
  2. {telepost_bot-2.84.1/telepost_bot.egg-info → telepost_bot-2.84.2}/PKG-INFO +1 -1
  3. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/callback_handlers.py +58 -15
  4. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/review.py +325 -42
  5. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/main.py +3 -0
  6. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/build_info.py +1 -1
  7. telepost_bot-2.84.2/telepost/telegram/callback_feedback.py +153 -0
  8. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/review_keyboard.py +59 -3
  9. {telepost_bot-2.84.1 → telepost_bot-2.84.2/telepost_bot.egg-info}/PKG-INFO +1 -1
  10. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost_bot.egg-info/SOURCES.txt +2 -0
  11. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_review.py +24 -12
  12. telepost_bot-2.84.2/tests/test_review_card_recovery.py +726 -0
  13. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_review_media_lifecycle.py +8 -2
  14. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_update_inbox.py +1 -0
  15. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/ui/locales/en/LC_MESSAGES/telepost.mo +0 -0
  16. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/ui/locales/en/LC_MESSAGES/telepost.po +45 -0
  17. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/LICENSE +0 -0
  18. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/MANIFEST.in +0 -0
  19. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/README.en.md +0 -0
  20. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/README.md +0 -0
  21. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/config/__init__.py +0 -0
  22. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/config/fly-telepost-policy.example.json +0 -0
  23. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/config/review_policy.md +0 -0
  24. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/config/settings.py +0 -0
  25. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/config.ini.example +0 -0
  26. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/database/__init__.py +0 -0
  27. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/database/db_manager.py +0 -0
  28. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/__init__.py +0 -0
  29. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/api_commands.py +0 -0
  30. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/automation.py +0 -0
  31. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/botconfig.py +0 -0
  32. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/channel_listener.py +0 -0
  33. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/command_handlers.py +0 -0
  34. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/conversation.py +0 -0
  35. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/error_handler.py +0 -0
  36. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/index_handlers.py +0 -0
  37. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/mode_selection.py +0 -0
  38. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/moderation.py +0 -0
  39. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/preview_handlers.py +0 -0
  40. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/publish.py +0 -0
  41. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/reaction_stats.py +0 -0
  42. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/recovery.py +0 -0
  43. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/schedule_status.py +0 -0
  44. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/search_handlers.py +0 -0
  45. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/stats_handlers.py +0 -0
  46. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/handlers/upload.py +0 -0
  47. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/mcp_server/__init__.py +0 -0
  48. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/mcp_server/review_client.py +0 -0
  49. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/mcp_server/server.py +0 -0
  50. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/models/__init__.py +0 -0
  51. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/models/state.py +0 -0
  52. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/__init__.py +0 -0
  53. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/SOURCES.sha256 +0 -0
  54. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/__init__.py +0 -0
  55. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/error-mapping.json +0 -0
  56. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/ackresult.candidate_search.json +0 -0
  57. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/capabilities.pixivflow.json +0 -0
  58. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/event.job.expired.json +0 -0
  59. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/event.job.succeeded.json +0 -0
  60. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/eventpage.candidate_search.json +0 -0
  61. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.cancelled.candidate_search.json +0 -0
  62. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.failed.no_candidate.json +0 -0
  63. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.queued.json +0 -0
  64. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.running.json +0 -0
  65. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/job.succeeded.candidate_search.json +0 -0
  66. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/jobpage.candidate_search.json +0 -0
  67. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/task.candidate_search.json +0 -0
  68. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/fixtures/task.candidate_search.target_only.json +0 -0
  69. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/protocol/v1/protocol.schema.json +0 -0
  70. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/pyproject.toml +0 -0
  71. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/run.py +0 -0
  72. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/services/__init__.py +0 -0
  73. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/services/review_service.py +0 -0
  74. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/setup.cfg +0 -0
  75. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/__init__.py +0 -0
  76. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/api/__init__.py +0 -0
  77. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/__init__.py +0 -0
  78. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/admin_ops.py +0 -0
  79. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/automation.py +0 -0
  80. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/delivery_planner.py +0 -0
  81. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/editorial.py +0 -0
  82. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/hot.py +0 -0
  83. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/novel_fallback_card.py +0 -0
  84. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/novel_preview.py +0 -0
  85. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/pixivflow_jobs.py +0 -0
  86. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/post_media.py +0 -0
  87. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/posts.py +0 -0
  88. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/publication.py +0 -0
  89. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/reaction_backfill.py +0 -0
  90. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/recovery.py +0 -0
  91. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/refetch.py +0 -0
  92. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/rerender.py +0 -0
  93. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/resubmit.py +0 -0
  94. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/review_queue.py +0 -0
  95. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/submitter_notify.py +0 -0
  96. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/application/telepress_provider.py +0 -0
  97. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/__init__.py +0 -0
  98. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/automation.py +0 -0
  99. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/delivery.py +0 -0
  100. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/editorial.py +0 -0
  101. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/errors.py +0 -0
  102. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/hot.py +0 -0
  103. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/media.py +0 -0
  104. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/navigation.py +0 -0
  105. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/novel_preview.py +0 -0
  106. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/packing.py +0 -0
  107. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/presentation.py +0 -0
  108. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/presentation_policy.py +0 -0
  109. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/refetch_state.py +0 -0
  110. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/review.py +0 -0
  111. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/review_chat.py +0 -0
  112. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/domain/submission.py +0 -0
  113. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/miniapp/__init__.py +0 -0
  114. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/miniapp/auth.py +0 -0
  115. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/miniapp/rbac.py +0 -0
  116. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/miniapp/role_bindings.py +0 -0
  117. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/miniapp/session.py +0 -0
  118. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/observability/__init__.py +0 -0
  119. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/observability/audit.py +0 -0
  120. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/observability/cli.py +0 -0
  121. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/observability/doctor.py +0 -0
  122. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/observability/errors.py +0 -0
  123. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/observability/redact.py +0 -0
  124. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/__init__.py +0 -0
  125. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/__init__.py +0 -0
  126. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/automation.py +0 -0
  127. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/columns.py +0 -0
  128. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/editorial.py +0 -0
  129. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/inbox.py +0 -0
  130. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/ledger.py +0 -0
  131. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/media_assets.py +0 -0
  132. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/moderation.py +0 -0
  133. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/novel_preview.py +0 -0
  134. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/posts.py +0 -0
  135. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/protocol_events.py +0 -0
  136. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/recovery.py +0 -0
  137. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/refetch.py +0 -0
  138. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/reviews.py +0 -0
  139. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/storage/sqlite/submitter_notifications.py +0 -0
  140. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/__init__.py +0 -0
  141. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/capture.py +0 -0
  142. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/delivery/__init__.py +0 -0
  143. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/delivery/discussion.py +0 -0
  144. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/delivery/executor.py +0 -0
  145. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/delivery/gateway.py +0 -0
  146. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/delivery/legacy_runner.py +0 -0
  147. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/delivery/planner.py +0 -0
  148. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/delivery/preparation.py +0 -0
  149. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/delivery/registry.py +0 -0
  150. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/delivery/sender.py +0 -0
  151. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/inbox_worker.py +0 -0
  152. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/review_expiry.py +0 -0
  153. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/review_media.py +0 -0
  154. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost/telegram/review_stager.py +0 -0
  155. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost_bot.egg-info/dependency_links.txt +0 -0
  156. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost_bot.egg-info/entry_points.txt +0 -0
  157. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost_bot.egg-info/requires.txt +0 -0
  158. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/telepost_bot.egg-info/top_level.txt +0 -0
  159. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_admin_api.py +0 -0
  160. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_anonymous.py +0 -0
  161. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_api_commands.py +0 -0
  162. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_api_server.py +0 -0
  163. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_api_version.py +0 -0
  164. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_architecture_application.py +0 -0
  165. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_architecture_core.py +0 -0
  166. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_automation.py +0 -0
  167. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_backfill_attribution.py +0 -0
  168. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_blacklist.py +0 -0
  169. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_bot_language.py +0 -0
  170. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_bot_token_logging.py +0 -0
  171. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_botconfig.py +0 -0
  172. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_boundary.py +0 -0
  173. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_callback_contract.py +0 -0
  174. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_caption_mentions.py +0 -0
  175. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_compatibility.py +0 -0
  176. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_config_encoding.py +0 -0
  177. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_console_encoding.py +0 -0
  178. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_conversation_flow.py +0 -0
  179. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_data_class_cleanup.py +0 -0
  180. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_database.py +0 -0
  181. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_database_first_start.py +0 -0
  182. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_database_operations.py +0 -0
  183. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_delivery_planner.py +0 -0
  184. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_disposition.py +0 -0
  185. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_doctor.py +0 -0
  186. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_editorial.py +0 -0
  187. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_file_validator.py +0 -0
  188. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_fly_deploy_contract.py +0 -0
  189. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_frozen_support.py +0 -0
  190. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_handlers.py +0 -0
  191. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_heat_calculator.py +0 -0
  192. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_helper_functions.py +0 -0
  193. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_hot_domain.py +0 -0
  194. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_identity_provenance.py +0 -0
  195. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_install_scripts.py +0 -0
  196. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_integration.py +0 -0
  197. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_legacy_delivery_port_navigation.py +0 -0
  198. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_logic_flow.py +0 -0
  199. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_logical_submissions.py +0 -0
  200. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_maintenance_jobs.py +0 -0
  201. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_manager_notifications.py +0 -0
  202. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_media_decision_audit.py +0 -0
  203. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_media_delivery_pipeline.py +0 -0
  204. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_media_domain.py +0 -0
  205. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_media_preparation.py +0 -0
  206. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_media_proxy_delivery.py +0 -0
  207. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_menu_shortcut_single_reply.py +0 -0
  208. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_messages.py +0 -0
  209. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_miniapp_api.py +0 -0
  210. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_miniapp_auth.py +0 -0
  211. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_miniapp_content.py +0 -0
  212. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_miniapp_entries.py +0 -0
  213. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_miniapp_history.py +0 -0
  214. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_miniapp_rbac.py +0 -0
  215. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_mixed_media_integrity.py +0 -0
  216. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_moderation_and_admin_ux.py +0 -0
  217. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_navigation.py +0 -0
  218. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_no_owner_probe.py +0 -0
  219. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_novel_cover_preview.py +0 -0
  220. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_novel_delivery_semantics.py +0 -0
  221. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_novel_preview.py +0 -0
  222. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_observability.py +0 -0
  223. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_observability_lifecycle.py +0 -0
  224. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_online_reading_root_button.py +0 -0
  225. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_packing_ssot.py +0 -0
  226. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_polling_server.py +0 -0
  227. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_preview_pagination.py +0 -0
  228. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_production_handler_graph.py +0 -0
  229. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_protocol_contract.py +0 -0
  230. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_publication_presentation.py +0 -0
  231. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_publish_idempotency.py +0 -0
  232. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_publish_regressions.py +0 -0
  233. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_publish_reply.py +0 -0
  234. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_reaction_backfill.py +0 -0
  235. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_reaction_stats.py +0 -0
  236. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_recovery.py +0 -0
  237. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_refetch.py +0 -0
  238. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_refetch_card_state.py +0 -0
  239. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_refetch_events.py +0 -0
  240. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_refetch_job_lifecycle.py +0 -0
  241. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_refetch_reason_normalization.py +0 -0
  242. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_refetch_replacement.py +0 -0
  243. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_remote_fetch_fallback.py +0 -0
  244. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_rerender.py +0 -0
  245. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_resubmit.py +0 -0
  246. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_review_album_payload.py +0 -0
  247. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_review_chat_contract.py +0 -0
  248. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_review_consistency.py +0 -0
  249. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_review_expiry_recovery.py +0 -0
  250. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_review_http_mcp.py +0 -0
  251. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_review_service.py +0 -0
  252. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_robustness.py +0 -0
  253. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_run_dispatcher.py +0 -0
  254. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_run_mode.py +0 -0
  255. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_run_supervisor.py +0 -0
  256. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_schedule_outcomes.py +0 -0
  257. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_search_disabled_hint.py +0 -0
  258. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_search_recovery.py +0 -0
  259. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_security.py +0 -0
  260. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_security_headers.py +0 -0
  261. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_session_database_handles.py +0 -0
  262. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_shutdown.py +0 -0
  263. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_streaming_uploads.py +0 -0
  264. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_submission_timeout_idempotency.py +0 -0
  265. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_telegram_golden_messages.py +0 -0
  266. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_telegram_media_cache.py +0 -0
  267. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_upload.py +0 -0
  268. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_user_friendly_messages.py +0 -0
  269. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_webhook_payload_admission.py +0 -0
  270. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_webhook_ready.py +0 -0
  271. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_webhook_router.py +0 -0
  272. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_webhook_secret.py +0 -0
  273. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_webhook_secret_logging.py +0 -0
  274. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_webhook_static.py +0 -0
  275. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/tests/test_work_id_wire.py +0 -0
  276. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/ui/__init__.py +0 -0
  277. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/ui/i18n.py +0 -0
  278. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/ui/keyboards.py +0 -0
  279. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/ui/messages.py +0 -0
  280. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/__init__.py +0 -0
  281. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/api_server.py +0 -0
  282. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/api_tokens.py +0 -0
  283. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/blacklist.py +0 -0
  284. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/cache.py +0 -0
  285. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/config_wizard.py +0 -0
  286. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/console.py +0 -0
  287. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/database.py +0 -0
  288. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/file_validator.py +0 -0
  289. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/heat_calculator.py +0 -0
  290. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/helper_functions.py +0 -0
  291. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/index_manager.py +0 -0
  292. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/logging_config.py +0 -0
  293. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/maintenance_jobs.py +0 -0
  294. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/polling_server.py +0 -0
  295. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/run_mode.py +0 -0
  296. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/runtime_policy.py +0 -0
  297. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/schedule_watchdog.py +0 -0
  298. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/search_engine.py +0 -0
  299. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/security_headers.py +0 -0
  300. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/submission.py +0 -0
  301. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/utils/webhook_server.py +0 -0
  302. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/webapp/__init__.py +0 -0
  303. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/webapp/dist/assets/2d82b92e720462f8dd3b-Bh4Fahcw.svg +0 -0
  304. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/webapp/dist/assets/index-CLfkW8eZ.css +0 -0
  305. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/webapp/dist/assets/index-CfjBQN_e.js +0 -0
  306. {telepost_bot-2.84.1 → telepost_bot-2.84.2}/webapp/dist/index.html +0 -0
@@ -8,6 +8,15 @@
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
+
11
20
  ## [2.84.1](https://github.com/redtidev1918/TelePost/compare/v2.84.0...v2.84.1) (2026-10-22)
12
21
 
13
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telepost-bot
3
- Version: 2.84.1
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
@@ -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):
@@ -1728,24 +1943,17 @@ async def toggle_review_spoiler(update, context):
1728
1943
  media_cleared=_row_value(row, "preview_state") == "cleared",
1729
1944
  )
1730
1945
  )
1731
- except Exception:
1732
- # §review-button-refresh: the mask is already in the DB and the
1733
- # review-group media already re-masked above, so a keyboard refresh
1734
- # failure must NOT be swallowed silently — otherwise the button label
1735
- # stays showing the stale spoiler value. Log at warn (with traceback)
1736
- # and surface a transient, visible alert to the admin via show_alert
1737
- # (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.
1738
1952
  logger.warning(
1739
- "刷新审核键盘失败,审核卡遮罩按钮标签可能已陈旧(遮罩状态已入库生效): "
1740
- "review_id=%s",
1741
- review_id,
1742
- exc_info=True,
1743
- )
1744
- await _answer(
1745
- query,
1746
- tr('已遮罩,但按钮无法刷新(状态已{p0})。').format(p0=tr('开启') if new_spoiler else tr('关闭')),
1747
- show_alert=True,
1953
+ "刷新审核键盘失败,已按库内状态重建审核卡: review_id=%s ui_only=%s",
1954
+ review_id, is_ui_only_error(exc), exc_info=True,
1748
1955
  )
1956
+ await restore_review_card(bot, review_id)
1749
1957
 
1750
1958
 
1751
1959
  async def restore_review_preview(update, context):
@@ -1793,13 +2001,30 @@ async def restore_review_preview(update, context):
1793
2001
  await _answer(query, tr('预览正在恢复中,请稍候。'))
1794
2002
  return
1795
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
+
1796
2010
  from telepost.telegram.review_media import regenerate_review_preview
1797
2011
  result = await regenerate_review_preview(context.bot, row)
1798
2012
  if not result["ok"]:
1799
- 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)
1800
2027
  return
1801
- await _answer(query, tr('预览已恢复({p0} 条消息),将按同样的时限自动清理。').format(
1802
- p0=len(result["message_ids"])))
1803
2028
  try:
1804
2029
  await query.edit_message_reply_markup(
1805
2030
  reply_markup=_review_keyboard(
@@ -2004,12 +2229,21 @@ async def approve_review(update, context):
2004
2229
  if update.effective_user.id not in ADMIN_IDS:
2005
2230
  await _answer(query, tr('你没有审核权限'), show_alert=True)
2006
2231
  return
2007
- await _answer(query)
2008
2232
  try:
2009
2233
  review_id = int(query.data.split(":", 1)[1])
2010
2234
  except (ValueError, IndexError):
2011
- 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)
2012
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)
2013
2247
 
2014
2248
  async def _show_publishing(row):
2015
2249
  try:
@@ -2019,7 +2253,8 @@ async def approve_review(update, context):
2019
2253
  reply_markup=InlineKeyboardMarkup([]),
2020
2254
  )
2021
2255
  except Exception:
2022
- pass
2256
+ logger.debug("发布中卡片更新失败(不改写业务状态): review_id=%s", review_id,
2257
+ exc_info=True)
2023
2258
 
2024
2259
  try:
2025
2260
  result = await review_service.approve(
@@ -2028,20 +2263,25 @@ async def approve_review(update, context):
2028
2263
  on_claim=_show_publishing,
2029
2264
  )
2030
2265
  except ReviewNotFoundError:
2031
- await query.edit_message_text(tr('❌ 审核记录不存在'))
2266
+ logger.info("审核不存在(已应答,改用卡片同步): review_id=%s", review_id)
2267
+ await restore_review_card(context.bot, review_id)
2032
2268
  return
2033
2269
  except ReviewBusyError:
2034
- await query.edit_message_text(tr('ℹ️ 该投稿当前状态:publishing'))
2035
- return
2036
- except ReviewStateError as error:
2037
- 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)
2038
2274
  return
2039
2275
  except PublishFailedError as error:
2040
2276
  row = await _load_review_for_action(query, review_id)
2041
- 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,
2042
2282
  tr("""⚠️ 审核 #{p0} {p1}:
2043
2283
  {p2}""").format(p0=review_id, p1=error.retry_hint, p2=error.message),
2044
- reply_markup=_review_keyboard(
2284
+ _review_keyboard(
2045
2285
  review_id, row["link"],
2046
2286
  spoiler=bool(row["spoiler"]), source=row["source"],
2047
2287
  work_id=_work_id_from_link(row["link"] or ""),
@@ -2052,11 +2292,21 @@ async def approve_review(update, context):
2052
2292
  ),
2053
2293
  )
2054
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
2055
2304
 
2056
- await query.edit_message_text(
2305
+ await _edit_card_or_recover(
2306
+ query, context, review_id,
2057
2307
  tr("""✅ 审核 #{p0} 已发布
2058
2308
  {p1}""").format(p0=review_id, p1=result.link or ''),
2059
- disable_web_page_preview=True,
2309
+ None,
2060
2310
  )
2061
2311
 
2062
2312
 
@@ -2065,12 +2315,14 @@ async def reject_review(update, context):
2065
2315
  if update.effective_user.id not in ADMIN_IDS:
2066
2316
  await _answer(query, tr('你没有审核权限'), show_alert=True)
2067
2317
  return
2068
- await _answer(query)
2069
2318
  try:
2070
2319
  review_id = int(query.data.split(":", 1)[1])
2071
2320
  except (ValueError, IndexError):
2072
- 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):
2073
2324
  return
2325
+ await _answer(query)
2074
2326
 
2075
2327
  try:
2076
2328
  await review_service.reject(
@@ -2078,13 +2330,23 @@ async def reject_review(update, context):
2078
2330
  actor=update.effective_user.id, source="telegram",
2079
2331
  )
2080
2332
  except ReviewNotFoundError:
2081
- await query.edit_message_text(tr('❌ 审核记录不存在'))
2333
+ logger.info("审核不存在(已应答,改用卡片同步): review_id=%s", review_id)
2334
+ await restore_review_card(context.bot, review_id)
2082
2335
  return
2083
2336
  except ReviewStateError as error:
2084
- 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
+ )
2085
2344
  return
2086
2345
 
2087
- 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
+ )
2088
2350
 
2089
2351
 
2090
2352
  async def block_review_user(update, context):
@@ -2121,10 +2383,22 @@ async def block_review_user(update, context):
2121
2383
  )
2122
2384
  await _answer(query, tr('已封禁该投稿人:后续投稿将被自动拒绝'))
2123
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.
2124
2389
  await query.edit_message_text(
2125
2390
  tr("""{p0}
2126
2391
 
2127
- 🚫 投稿人封禁已由管理员 {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
+ ),
2128
2402
  )
2129
2403
  except Exception as exc:
2130
2404
  logger.debug("封禁后更新审核卡失败: %s", exc)
@@ -2166,7 +2440,16 @@ async def block_review_api(update, context):
2166
2440
  await query.edit_message_text(
2167
2441
  tr("""{p0}
2168
2442
 
2169
- 🔑 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
+ ),
2170
2453
  )
2171
2454
  except Exception as exc:
2172
2455
  logger.debug("禁用API后更新审核卡失败: %s", exc)
@@ -66,6 +66,7 @@ from handlers.review import (
66
66
  poll_refetch_jobs,
67
67
  reconcile_incomplete_reviews,
68
68
  recover_refetch_jobs,
69
+ restore_card_command,
69
70
  )
70
71
 
71
72
  # 错误处理
@@ -737,6 +738,8 @@ def setup_application(application):
737
738
  application.add_handler(CommandHandler('blacklist_list', blacklist_list), group=-998)
738
739
  application.add_handler(CommandHandler('ban_user', ban_user_command), group=-998)
739
740
  application.add_handler(CommandHandler('ban_api', ban_api_command), group=-998)
741
+ # §review-card-recovery: read-only rebuild of a destroyed review card.
742
+ application.add_handler(CommandHandler('restore_card', restore_card_command), group=-998)
740
743
  application.add_handler(CommandHandler('botconfig', botconfig), group=-998)
741
744
  # 不再注册高优先级的cancel命令,只在ConversationHandler的fallbacks中注册
742
745
  # application.add_handler(CommandHandler('cancel', cancel), group=-998) # 注释掉这行
@@ -8,7 +8,7 @@ to the entry points; when present it overrides these committed defaults.
8
8
  # Bumped automatically by release-please (extra-files in
9
9
  # release-please-config.json). Release bundles and images override it at build
10
10
  # time with the exact version/commit; this constant is the local-dev fallback.
11
- RELEASE_VERSION = "2.84.1" # x-release-please-version
11
+ RELEASE_VERSION = "2.84.2" # x-release-please-version
12
12
  RELEASE_COMMIT = "dev"
13
13
  BUILD_DATE = "dev"
14
14
  SERVICE = "telepost"