@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80

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 (211) hide show
  1. package/amplify/data/function-handler.js +20 -0
  2. package/amplify/data/function-handler.ts +40 -0
  3. package/amplify/data/resource.js +151 -61
  4. package/amplify/data/resource.ts +157 -61
  5. package/amplify/data/schema.js +2096 -1980
  6. package/amplify/data/schema.ts +198 -69
  7. package/amplify/functions/assignment-action/data-access.js +14 -15
  8. package/amplify/functions/assignment-action/data-access.ts +13 -14
  9. package/amplify/functions/assignment-action/handler.ts +1 -1
  10. package/amplify/functions/automation-view/handler.py +17 -0
  11. package/amplify/functions/automation-view/resource.js +24 -0
  12. package/amplify/functions/automation-view/resource.ts +34 -0
  13. package/amplify/functions/automation-view/schema.js +26 -0
  14. package/amplify/functions/automation-view/schema.ts +28 -0
  15. package/amplify/functions/automation-view/wire.js +56 -0
  16. package/amplify/functions/automation-view/wire.ts +76 -0
  17. package/amplify/functions/content-actions/handler.py +61 -2
  18. package/amplify/functions/content-actions/requirements.txt +1 -0
  19. package/amplify/functions/content-actions/resource.js +1 -1
  20. package/amplify/functions/content-actions/resource.ts +1 -1
  21. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  22. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  23. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  24. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  25. package/amplify/functions/editorial-checks/schema.js +122 -0
  26. package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
  27. package/amplify/functions/editorial-checks/wire.js +53 -0
  28. package/amplify/functions/editorial-checks/wire.ts +61 -0
  29. package/amplify/functions/monitoring-view/handler.py +11 -0
  30. package/amplify/functions/monitoring-view/resource.js +24 -0
  31. package/amplify/functions/monitoring-view/resource.ts +34 -0
  32. package/amplify/functions/monitoring-view/schema.js +17 -0
  33. package/amplify/functions/monitoring-view/schema.ts +19 -0
  34. package/amplify/functions/monitoring-view/wire.js +37 -0
  35. package/amplify/functions/monitoring-view/wire.ts +47 -0
  36. package/amplify/functions/public-forms/core.js +247 -0
  37. package/amplify/functions/public-forms/core.ts +310 -0
  38. package/amplify/functions/public-forms/handler.ts +41 -0
  39. package/amplify/functions/public-forms/origins.js +35 -0
  40. package/amplify/functions/public-forms/origins.ts +43 -0
  41. package/amplify/functions/public-forms/resource.js +9 -0
  42. package/amplify/functions/public-forms/resource.ts +10 -0
  43. package/amplify/functions/public-forms/wire.js +25 -0
  44. package/amplify/functions/public-forms/wire.ts +32 -0
  45. package/amplify/functions/shared/editorial-context.ts +63 -4
  46. package/amplify/functions/shared/python-bundle.js +20 -14
  47. package/amplify/functions/shared/python-bundle.ts +25 -18
  48. package/amplify/plugins/newsletter/config.js +245 -0
  49. package/amplify/plugins/newsletter/config.ts +305 -0
  50. package/amplify/plugins/newsletter/function/handler.py +6 -0
  51. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  52. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  53. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  54. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  55. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  56. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  57. package/amplify/plugins/newsletter/schema.js +57 -0
  58. package/amplify/plugins/newsletter/schema.ts +62 -0
  59. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  60. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  61. package/amplify/plugins/newsletter/sending.js +140 -0
  62. package/amplify/plugins/newsletter/sending.ts +186 -0
  63. package/amplify/plugins/reader-steering/config.js +182 -0
  64. package/amplify/plugins/reader-steering/config.ts +290 -0
  65. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  66. package/amplify/plugins/reader-steering/function/handler.py +1554 -0
  67. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  68. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  69. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  70. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  71. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  72. package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
  73. package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
  74. package/amplify/plugins/reader-steering/schema.js +205 -0
  75. package/amplify/plugins/reader-steering/schema.ts +207 -0
  76. package/amplify/plugins/registry.js +4 -2
  77. package/amplify/plugins/registry.ts +4 -2
  78. package/amplify/plugins/signup/schema.js +2 -1
  79. package/amplify/plugins/signup/schema.ts +2 -1
  80. package/amplify/plugins/types.ts +5 -0
  81. package/amplify/plugins/wire.js +1 -0
  82. package/amplify/plugins/wire.ts +1 -0
  83. package/amplify/publication/projection-lease.js +30 -0
  84. package/amplify/publication/projection-lease.ts +39 -0
  85. package/amplify/publication/redirect-and-index.js +105 -0
  86. package/amplify/publication/redirect-rules.js +73 -0
  87. package/amplify/publication/redirect-rules.ts +93 -0
  88. package/amplify/publication/s3-projection.js +7 -1
  89. package/amplify/publication/s3-projection.ts +9 -1
  90. package/amplify/site-backend-config.ts +18 -1
  91. package/amplify/site-backend-names.js +4 -2
  92. package/amplify/site-backend-names.ts +4 -2
  93. package/amplify/site-backend.js +41 -2
  94. package/amplify/site-backend.ts +46 -2
  95. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  96. package/app/api/newsletter/confirm/route.ts +13 -0
  97. package/app/api/newsletter/preferences/route.ts +13 -0
  98. package/app/api/newsletter/state/route.ts +9 -0
  99. package/app/api/newsletter/subscribe/route.ts +9 -0
  100. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  101. package/app/api/newsroom/curation/context/route.ts +9 -0
  102. package/app/api/newsroom/curation/priming/route.ts +9 -0
  103. package/app/api/newsroom/curation/publication/route.ts +9 -0
  104. package/app/api/reader/comment/delete/route.ts +9 -0
  105. package/app/api/reader/comment/edit/route.ts +9 -0
  106. package/app/api/reader/comment/route.ts +9 -0
  107. package/app/api/reader/flag/route.ts +9 -0
  108. package/app/api/reader/handle/route.ts +9 -0
  109. package/app/api/reader/me/route.ts +9 -0
  110. package/app/api/reader/rating/route.ts +9 -0
  111. package/app/api/reader/report/route.ts +9 -0
  112. package/app/api/reader/retired/route.ts +9 -0
  113. package/app/api/reader/submit/route.ts +9 -0
  114. package/app/api/reader/vote/route.ts +9 -0
  115. package/bin/papyrus-infra.mjs +20 -1
  116. package/components/automation-panel.tsx +49 -0
  117. package/components/automation-run-now.tsx +97 -0
  118. package/components/automation-view.tsx +192 -0
  119. package/components/editorial-checks-admin-panel.tsx +36 -11
  120. package/components/moderation-panel.tsx +248 -0
  121. package/components/monitoring-overview-view.tsx +241 -0
  122. package/components/monitoring-panel.tsx +40 -0
  123. package/components/news-desk-client-provider.tsx +18 -18
  124. package/components/news-desk-taxonomy-client.ts +28 -8
  125. package/components/newsletter-panel.tsx +474 -0
  126. package/components/newsletter-sections.tsx +163 -0
  127. package/components/newsroom-access-gate.tsx +15 -1
  128. package/components/newsroom-article-editor.tsx +6 -6
  129. package/components/newsroom-articles-view.tsx +6 -6
  130. package/components/newsroom-board-view.tsx +8 -3
  131. package/components/newsroom-curation-bootstrap.tsx +45 -0
  132. package/components/newsroom-ops-overview.tsx +22 -92
  133. package/components/newsroom-ops-shell.tsx +2 -2
  134. package/components/newsroom-organizer.css +33 -0
  135. package/components/newsroom-overview-skeleton.tsx +25 -0
  136. package/components/newsroom-references-view.tsx +192 -49
  137. package/components/notification-center.tsx +273 -0
  138. package/components/publication-cyclotron-panel.tsx +15 -10
  139. package/components/reader-auth-state.ts +1 -1
  140. package/components/reference-add-entry.tsx +75 -0
  141. package/components/reference-curation-controls.tsx +31 -22
  142. package/components/reference-cyclotron-priming.tsx +58 -19
  143. package/components/reference-cyclotron-suggestion.tsx +10 -6
  144. package/components/reference-priming.tsx +14 -10
  145. package/components/reference-relevance-review.tsx +3 -3
  146. package/components/reference-section-suggestion.tsx +1 -1
  147. package/components/topic-steering-workspace.tsx +223 -144
  148. package/components/use-editorial-checks.ts +12 -10
  149. package/infra/amplify-app-shell.js +94 -16
  150. package/infra/amplify-app-shell.ts +106 -18
  151. package/infra/cloud-email-config.js +32 -0
  152. package/infra/cloud-email-config.ts +39 -0
  153. package/infra/cloud-email.js +57 -0
  154. package/infra/cloud-email.test.js +69 -0
  155. package/infra/cloud-email.test.ts +66 -0
  156. package/infra/cloud-email.ts +60 -0
  157. package/infra/index.js +1 -0
  158. package/infra/secure-string-parameter-handler.js +41 -0
  159. package/infra/secure-string-parameter-handler.ts +41 -0
  160. package/infra/secure-string-parameter.js +79 -0
  161. package/infra/secure-string-parameter.ts +91 -0
  162. package/infra/site-config.js +88 -6
  163. package/infra/site-config.ts +134 -6
  164. package/lib/automation-client.ts +54 -0
  165. package/lib/automation-fixture.ts +93 -0
  166. package/lib/automation-overview.ts +232 -0
  167. package/lib/category-repository.ts +11 -9
  168. package/lib/curation-routes.ts +345 -0
  169. package/lib/cyclotron-suggestions.ts +4 -4
  170. package/lib/editorial-checks-backend.ts +1 -1
  171. package/lib/editorial-checks-client.ts +19 -1
  172. package/lib/editorial-checks-demo.ts +2 -2
  173. package/lib/editorial-checks-settings.ts +3 -0
  174. package/lib/editorial-checks.ts +8 -11
  175. package/lib/editorial-preview.ts +5 -5
  176. package/lib/monitoring-client.ts +29 -0
  177. package/lib/monitoring-overview-fixture.ts +51 -0
  178. package/lib/monitoring-overview.ts +145 -0
  179. package/lib/news-desk-session.ts +12 -21
  180. package/lib/newsletter-api.ts +418 -0
  181. package/lib/newsletter-client.ts +46 -0
  182. package/lib/newsletter-fixture.ts +184 -0
  183. package/lib/newsletter-model.ts +202 -0
  184. package/lib/newsroom-organizer-nav.ts +2 -1
  185. package/lib/prospectus-session.ts +15 -14
  186. package/lib/prospectus-sign-in-routes.ts +84 -19
  187. package/lib/prospectus-site-identity.ts +24 -1
  188. package/lib/reader-api.ts +145 -0
  189. package/lib/reader-moderation.ts +220 -0
  190. package/lib/reader-retirement-route.ts +89 -0
  191. package/lib/reader-subject.md +29 -0
  192. package/lib/reader-subject.ts +53 -0
  193. package/lib/reference-review-details.ts +71 -0
  194. package/lib/reference-review-request.ts +14 -0
  195. package/lib/relevance-decisions.ts +42 -10
  196. package/lib/relevance-status.ts +2 -2
  197. package/lib/review-client.ts +99 -0
  198. package/lib/review-fixture.ts +45 -0
  199. package/lib/review-requests.ts +238 -0
  200. package/lib/semantic-graph.ts +2 -0
  201. package/lib/site-brand-tenant.ts +11 -6
  202. package/lib/site-brand.ts +6 -3
  203. package/lib/site-origin.ts +67 -0
  204. package/lib/use-reference-review-data.ts +44 -0
  205. package/middleware.ts +14 -3
  206. package/package.json +56 -3
  207. package/routes.manifest.json +215 -2
  208. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  209. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
  210. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
  211. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -0,0 +1,1554 @@
1
+ """reader-actions: the one function behind the reader API of the reader-steering plugin.
2
+
3
+ The CMS app's reader routes invoke it with ``{publication, siteSubject, isStaff, action, payload}`` after they
4
+ have checked the session, the Origin and the body size. It validates every field, rate limits per reader,
5
+ and writes the ReaderProfile, ReaderHandle and ReaderVote tables directly with its own role. It answers
6
+ ``{statusCode, body}``; the body never contains the siteSubject.
7
+
8
+ The siteSubject is the per-site pairwise id that Prospectus returns. The global person id never reaches this
9
+ function. Only ``ss1_`` subjects are accepted, plus ``ssdev_`` ones when the site was synthesized for
10
+ development or test (docs/reader-steering.md).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+ import time
21
+ import uuid
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timezone
24
+ from decimal import Decimal
25
+ from typing import Any, Callable, Protocol
26
+ from urllib.parse import urlsplit, urlunsplit
27
+
28
+ _FUNCTION_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
29
+ if _FUNCTION_DIRECTORY not in sys.path: # a Lambda already has its code directory on the path; a test loading this file by path does not
30
+ sys.path.insert(0, _FUNCTION_DIRECTORY)
31
+ import ai_moderation # noqa: E402
32
+ import moderation as moderation_rules # noqa: E402
33
+ import proposals as proposal_rules # noqa: E402
34
+
35
+ SITE_SUBJECT = re.compile(r"ss1_[A-Za-z0-9_-]{43}")
36
+ INTERIM_SUBJECT = re.compile(r"ssdev_[A-Za-z0-9_-]{43}")
37
+ SUBJECT_KEY = re.compile(r"[A-Za-z0-9._:-]{1,200}")
38
+ HANDLE = re.compile(r"[a-z0-9_-]{3,24}")
39
+ TOGGLE_DIMENSIONS = ("up", "used-it")
40
+ PUBLISHED_ID_PREFIX = "published-"
41
+ MAXIMUM_SUBJECTS = 50
42
+ MINIMUM_RATING = 1
43
+ MAXIMUM_RATING = 5
44
+ HOUR_SECONDS = 3600
45
+ HANDLE_CHANGES_PER_HOUR = 10
46
+ STATE_ACTIVE = "active"
47
+ STATE_LIMITED = "limited"
48
+ STATE_BANNED = "banned"
49
+ ROLE_READER = "reader"
50
+ ROLE_EDITOR = "editor"
51
+ ACTION_ME = "me"
52
+ ACTION_HANDLE = "handle"
53
+ ACTION_VOTE = "vote"
54
+ ACTION_RATING = "rating"
55
+ ACTION_SUBMIT = "submit"
56
+ ACTION_COMMENT = "comment"
57
+ ACTION_REPORT = "report"
58
+ ACTION_FLAG = "flag"
59
+ SUBJECT_TYPE_ITEM = "item"
60
+ SUBJECT_TYPE_SUBMISSION = "submission"
61
+ SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION, "story", "proposal", "concept")
62
+ DEFAULT_SUBJECT_TYPES = (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION)
63
+ SUBJECT_ID = re.compile(r"[A-Za-z0-9._:-]{1,190}")
64
+ VOTE_DIMENSION = "up"
65
+ VOTE_PAYLOAD_KEYS = frozenset({"subject", "value"})
66
+ FLAG_WRONG = "wrong"
67
+ FLAG_BELONGS_UNDER = "belongs_under"
68
+ FLAG_BETTER_NAME = "better_name"
69
+ FLAG_KINDS = (FLAG_WRONG, FLAG_BELONGS_UNDER, FLAG_BETTER_NAME)
70
+ FLAG_PAYLOAD_KEYS = frozenset({"subject", "kind", "reason", "target", "name"})
71
+ MAXIMUM_FLAG_REASON = 500
72
+ MAXIMUM_SUGGESTED_NAME = 120
73
+ RATE_BUCKET_REPORT = "report"
74
+ REPORT_ID_PREFIX = "rep-"
75
+ MAXIMUM_REPORT_REASON = 500
76
+ MODERATION_MODES = ("off", "human", "ai")
77
+ AI_WORKER = "ai-review"
78
+ AI_TASK = {"worker": AI_WORKER}
79
+ AI_DEADLINE_MARGIN_SECONDS = 12
80
+ ACTION_COMMENT_EDIT = "comment-edit"
81
+ ACTION_COMMENT_DELETE = "comment-delete"
82
+ RATE_BUCKET_COMMENT = "comment"
83
+ COMMENT_ID_PREFIX = "cmt-"
84
+ COMMENT_ID = re.compile(r"cmt-[0-9a-f]{32}")
85
+ COMMENT_VISIBLE = "visible"
86
+ COMMENT_HIDDEN = "hidden"
87
+ COMMENT_DELETED = "deleted"
88
+ COMMENT_PAYLOAD_KEYS = frozenset({"subject", "body", "parent", "ratingContext"})
89
+ COMMENT_EDIT_KEYS = frozenset({"id", "body"})
90
+ COMMENT_DELETE_KEYS = frozenset({"id"})
91
+ LINK = re.compile(r"(?:https?://|www\.)\S+", re.IGNORECASE)
92
+ TEXT_CONTROL_CHARACTERS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
93
+ RATE_BUCKET_REACTIONS = "react"
94
+ RATE_BUCKET_HANDLE = "handle"
95
+ RATE_BUCKET_SUBMIT = "submit"
96
+ DAY_SECONDS = 24 * HOUR_SECONDS
97
+ SUBMISSION_ID_PREFIX = "sub-"
98
+ SUBMISSION_ID = re.compile(r"sub-[0-9a-f]{24}")
99
+ SUBMISSION_DOMAIN_INDEX = "submissionsByDomain"
100
+ PROFILE_READER_INDEX = "profilesByReader"
101
+ REPORT_TARGET_INDEX = "reportsByTarget"
102
+ SUBMISSION_PENDING = "pending"
103
+ SUBMISSION_PROMOTED = "promoted"
104
+ SUBMISSION_HIDDEN = "hidden"
105
+ VISIBLE_SUBMISSION_STATES = (SUBMISSION_PENDING, SUBMISSION_PROMOTED)
106
+ EXTRACTION_PENDING = "pending"
107
+ SUBMISSION_PAYLOAD_KEYS = frozenset({"url", "title", "kind", "note"})
108
+ MAXIMUM_URL_LENGTH = 2048
109
+ MAXIMUM_TITLE_LENGTH = 200
110
+ MAXIMUM_NOTE_LENGTH = 500
111
+ MAXIMUM_SIMILAR = 5
112
+ EMAIL_ADDRESS = re.compile(r"[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]*[A-Za-z][A-Za-z0-9-]*\.)+[A-Za-z]{2,}")
113
+ CONTROL_CHARACTERS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
114
+ SNAPSHOT_TASK = {"projectionTask": {"task": "reader-snapshot"}}
115
+ SNAPSHOT_SCHEDULE_PREFIX = "papyrus-reader-snapshot-"
116
+ RESERVED_HANDLES = frozenset(
117
+ "admin administrator root system sysadmin moderator mod mods staff editor editors support help security "
118
+ "official anonymous anon guest null undefined none me you everyone nobody deleted reader readers "
119
+ "papyrus anthus prospectus api www mail abuse postmaster webmaster".split()
120
+ )
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class ReaderSettings:
125
+ dimensions: tuple[str, ...] = ()
126
+ actions_per_hour: int = 120
127
+ new_account_hours: int = 24
128
+ new_account_divisor: int = 3
129
+ reserved_handles: frozenset[str] = frozenset()
130
+ allow_interim_subjects: bool = False
131
+ snapshot_debounce_seconds: int = 60
132
+ submissions_per_day: int = 10
133
+ similar_hours: int = 48
134
+ submission_kinds: tuple[str, ...] = ("link",)
135
+ comments_per_hour: int = 30
136
+ comment_edit_minutes: int = 10
137
+ comment_maximum_length: int = 2000
138
+ comment_maximum_links: int = 3
139
+ moderation_mode: str = "human"
140
+ pre_moderation: bool = False
141
+ reports_per_day: int = 20
142
+ promote_item_type: str = "brief"
143
+ promote_section: str = "unsectioned"
144
+ ai_model: str = ai_moderation.DEFAULT_MODEL
145
+ ai_key_parameter: str = ""
146
+ subject_types: tuple[str, ...] = DEFAULT_SUBJECT_TYPES
147
+
148
+
149
+ def settings_from_environment(environment: dict[str, str] | None = None) -> ReaderSettings:
150
+ environment = os.environ if environment is None else environment
151
+ raw = json.loads(environment.get("PAPYRUS_READER_SETTINGS") or "{}")
152
+ limits = raw.get("limits") or {}
153
+ defaults = ReaderSettings()
154
+ return ReaderSettings(
155
+ dimensions=tuple(raw.get("dimensions") or ()),
156
+ actions_per_hour=int(limits.get("actionsPerHour", defaults.actions_per_hour)),
157
+ new_account_hours=int(limits.get("newAccountHours", defaults.new_account_hours)),
158
+ new_account_divisor=int(limits.get("newAccountDivisor", defaults.new_account_divisor)),
159
+ reserved_handles=frozenset(raw.get("reservedHandles") or ()),
160
+ allow_interim_subjects=bool(raw.get("allowInterimSubjects", False)),
161
+ snapshot_debounce_seconds=int(raw.get("snapshotDebounceSeconds", defaults.snapshot_debounce_seconds)),
162
+ submissions_per_day=int((raw.get("submissions") or {}).get("perDay", defaults.submissions_per_day)),
163
+ similar_hours=int((raw.get("submissions") or {}).get("similarHours", defaults.similar_hours)),
164
+ submission_kinds=tuple((raw.get("submissions") or {}).get("kinds") or defaults.submission_kinds),
165
+ comments_per_hour=int((raw.get("comments") or {}).get("perHour", defaults.comments_per_hour)),
166
+ comment_edit_minutes=int((raw.get("comments") or {}).get("editWindowMinutes", defaults.comment_edit_minutes)),
167
+ comment_maximum_length=int((raw.get("comments") or {}).get("maximumLength", defaults.comment_maximum_length)),
168
+ comment_maximum_links=int((raw.get("comments") or {}).get("maximumLinks", defaults.comment_maximum_links)),
169
+ moderation_mode=str((raw.get("moderation") or {}).get("mode", defaults.moderation_mode)),
170
+ pre_moderation=bool((raw.get("moderation") or {}).get("preModeration", defaults.pre_moderation)),
171
+ reports_per_day=int((raw.get("moderation") or {}).get("reportsPerDay", defaults.reports_per_day)),
172
+ ai_model=str(((raw.get("moderation") or {}).get("ai") or {}).get("model", defaults.ai_model)),
173
+ ai_key_parameter=str(((raw.get("moderation") or {}).get("ai") or {}).get("apiKeyParameter", defaults.ai_key_parameter)),
174
+ subject_types=tuple((raw.get("subjects") or {}).get("types") or defaults.subject_types),
175
+ promote_item_type=str((raw.get("submissions") or {}).get("itemType", defaults.promote_item_type)),
176
+ promote_section=str((raw.get("submissions") or {}).get("section", defaults.promote_section)),
177
+ )
178
+
179
+
180
+ class ReaderStore(Protocol):
181
+ def get_profile(self, site_subject: str) -> dict[str, Any] | None: ...
182
+
183
+ def create_profile(self, profile: dict[str, Any]) -> bool: ...
184
+
185
+ def claim_handle(self, site_subject: str, reader_id: str, handle: str, now_iso: str) -> str: ...
186
+
187
+ def get_vote_values(self, vote_ids: list[str]) -> dict[str, int]: ...
188
+
189
+ def put_vote(self, vote: dict[str, Any], now_iso: str) -> None: ...
190
+
191
+ def delete_vote(self, vote_id: str) -> None: ...
192
+
193
+ def put_flag(self, flag: dict[str, Any], now_iso: str) -> None: ...
194
+
195
+ def is_published(self, subject_key: str) -> bool: ...
196
+
197
+ def get_submission(self, submission_id: str) -> dict[str, Any] | None: ...
198
+
199
+ def create_submission(self, submission: dict[str, Any]) -> bool: ...
200
+
201
+ def recent_submissions_by_domain(self, domain: str, since_iso: str) -> list[dict[str, Any]]: ...
202
+
203
+ def get_comment(self, comment_id: str) -> dict[str, Any] | None: ...
204
+
205
+ def create_comment(self, comment: dict[str, Any]) -> None: ...
206
+
207
+ def update_comment(self, comment_id: str, changes: dict[str, Any], now_iso: str, *, expected_state: str) -> bool: ...
208
+
209
+ def put_report(self, report: dict[str, Any], target_key: str, target_table: str, now_iso: str) -> bool: ...
210
+
211
+ def get_profile_by_reader(self, reader_id: str) -> dict[str, Any] | None: ...
212
+
213
+ def get_action(self, action_id: str) -> dict[str, Any] | None: ...
214
+
215
+ def transact_moderation(self, action: dict[str, Any], target_type: str, target_key: str, plan: Any, *, undoes: str | None = None) -> str: ...
216
+
217
+ def close_reports(self, target: str, now_iso: str) -> int: ...
218
+
219
+ def new_content(self) -> list[tuple[str, dict[str, Any]]]: ...
220
+
221
+ def human_acted_on(self) -> set[str]: ...
222
+
223
+ def count_action(self, reader_id: str, bucket: str, window_start: int, limit: int, expires_at: int) -> bool: ...
224
+
225
+ def claim_snapshot_slot(self, now_seconds: float, debounce_seconds: int) -> int | None: ...
226
+
227
+ def release_snapshot_slot(self, locked_until: int) -> None: ...
228
+
229
+
230
+ def reply(status: int, body: dict[str, Any]) -> dict[str, Any]:
231
+ return {"statusCode": status, "body": body}
232
+
233
+
234
+ def refusal(status: int, error: str, message: str, **extra: Any) -> dict[str, Any]:
235
+ return reply(status, {"error": error, "message": message, **extra})
236
+
237
+
238
+ def iso(now_seconds: float) -> str:
239
+ """UTC with milliseconds, so rows written in the same second still sort in the order they were written."""
240
+ moment = datetime.fromtimestamp(now_seconds, tz=timezone.utc)
241
+ return moment.strftime("%Y-%m-%dT%H:%M:%S.") + f"{moment.microsecond // 1000:03d}Z"
242
+
243
+
244
+ def seconds_of(iso_text: str) -> float:
245
+ return datetime.strptime(iso_text, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc).timestamp()
246
+
247
+
248
+ def vote_id(subject_key: str, reader_id: str, dimension: str) -> str:
249
+ return f"{subject_key}#{reader_id}#{dimension}"
250
+
251
+
252
+ def subject_key(subject_type: str, subject_id: str) -> str:
253
+ """The key a vote is stored under: items and submissions keep their bare id (so every vote written before subject types stays valid); other types are prefixed."""
254
+ return subject_id if subject_type in (SUBJECT_TYPE_ITEM, SUBJECT_TYPE_SUBMISSION) else f"{subject_type}:{subject_id}"
255
+
256
+
257
+ def legacy_subject_type(key: str) -> str:
258
+ """The type of a bare subject key: a submission id, otherwise an item."""
259
+ return SUBJECT_TYPE_SUBMISSION if SUBMISSION_ID.fullmatch(key) else SUBJECT_TYPE_ITEM
260
+
261
+
262
+ def parse_subject(raw: Any, settings: ReaderSettings) -> tuple[tuple[str, str] | None, dict[str, Any] | None]:
263
+ """``({type, id}) -> (type, id)``, or a refusal. A bare string is a subject written before subject types: its type is read from its shape."""
264
+ if isinstance(raw, str):
265
+ raw = {"type": legacy_subject_type(raw), "id": raw}
266
+ if not isinstance(raw, dict) or set(raw) != {"type", "id"} or not isinstance(raw["type"], str) or not isinstance(raw["id"], str):
267
+ return None, refusal(422, "invalid_subject", "Send the subject as {type, id}.")
268
+ subject_type, subject_id = raw["type"], raw["id"]
269
+ if subject_type not in SUBJECT_TYPES or subject_type not in settings.subject_types:
270
+ return None, refusal(422, "unknown_subject_type", "That kind of subject cannot be voted on here.")
271
+ if not SUBJECT_ID.fullmatch(subject_id):
272
+ return None, refusal(422, "invalid_subject", "Send the id of the subject.")
273
+ return (subject_type, subject_id), None
274
+
275
+
276
+ def valid_site_subject(value: Any, settings: ReaderSettings) -> bool:
277
+ if not isinstance(value, str):
278
+ return False
279
+ if SITE_SUBJECT.fullmatch(value):
280
+ return True
281
+ return settings.allow_interim_subjects and INTERIM_SUBJECT.fullmatch(value) is not None
282
+
283
+
284
+ def action_limit(profile: dict[str, Any], settings: ReaderSettings, now_seconds: float) -> int:
285
+ age_hours = (now_seconds - seconds_of(profile["createdAt"])) / HOUR_SECONDS
286
+ if age_hours < settings.new_account_hours:
287
+ return max(1, settings.actions_per_hour // settings.new_account_divisor)
288
+ return settings.actions_per_hour
289
+
290
+
291
+ def within_rate_limit(
292
+ store: ReaderStore, profile: dict[str, Any], bucket: str, limit: int, now_seconds: float, window_seconds: int = HOUR_SECONDS
293
+ ) -> dict[str, Any] | None:
294
+ window_start = int(now_seconds // window_seconds) * window_seconds
295
+ window_end = window_start + window_seconds
296
+ if store.count_action(profile["readerId"], bucket, window_start, limit, window_end + HOUR_SECONDS):
297
+ return None
298
+ return refusal(429, "rate_limited", "Too many actions. Try again later.", retryAfter=max(1, int(window_end - now_seconds)))
299
+
300
+
301
+ def new_account_limit(profile: dict[str, Any], settings: ReaderSettings, now_seconds: float, full_limit: int) -> int:
302
+ """The limit for an action: a third (``new_account_divisor``) of it while the profile is younger than ``new_account_hours``."""
303
+ age_hours = (now_seconds - seconds_of(profile["createdAt"])) / HOUR_SECONDS
304
+ return max(1, full_limit // settings.new_account_divisor) if age_hours < settings.new_account_hours else full_limit
305
+
306
+
307
+ class InvalidUrl(ValueError):
308
+ pass
309
+
310
+
311
+ def canonical_source_url(value: Any) -> str:
312
+ """The canonical form of a source URL. Identical to ``papyrus_content.reference_intake.canonical_source_url`` (a test pins them together)."""
313
+ if not isinstance(value, str) or len(value) > 4096 or any(ord(c) <= 32 for c in value):
314
+ raise InvalidUrl("source_uri must be an HTTP(S) URL without whitespace.")
315
+ try:
316
+ parts = urlsplit(value)
317
+ if parts.scheme.lower() not in {"https", "http"} or not parts.hostname or parts.username or parts.password:
318
+ raise ValueError()
319
+ host = parts.hostname.encode("idna").decode("ascii").lower()
320
+ if ":" in host:
321
+ host = f"[{host}]"
322
+ port = parts.port
323
+ if port is not None and port != {"http": 80, "https": 443}[parts.scheme.lower()]:
324
+ host += f":{port}"
325
+ return urlunsplit((parts.scheme.lower(), host, parts.path or "/", parts.query, ""))
326
+ except (ValueError, UnicodeError) as error:
327
+ raise InvalidUrl("source_uri must be a valid HTTP(S) URL without credentials.") from error
328
+
329
+
330
+ def submission_id_for(canonical_url: str) -> str:
331
+ return SUBMISSION_ID_PREFIX + hashlib.sha256(canonical_url.encode("utf-8")).hexdigest()[:24]
332
+
333
+
334
+ def domain_of(canonical_url: str) -> str:
335
+ return (urlsplit(canonical_url).hostname or "").lower()
336
+
337
+
338
+ def ensure_profile(store: ReaderStore, site_subject: str, now_seconds: float) -> dict[str, Any]:
339
+ profile = store.get_profile(site_subject)
340
+ if profile is not None:
341
+ return profile
342
+ created = {
343
+ "id": site_subject,
344
+ "siteSubject": site_subject,
345
+ "readerId": str(uuid.uuid4()),
346
+ "state": STATE_ACTIVE,
347
+ "karma": 0,
348
+ "createdAt": iso(now_seconds),
349
+ }
350
+ if store.create_profile(created):
351
+ return created
352
+ existing = store.get_profile(site_subject)
353
+ if existing is None:
354
+ raise RuntimeError("The reader profile could not be created.")
355
+ return existing
356
+
357
+
358
+ def public_profile(profile: dict[str, Any]) -> dict[str, Any]:
359
+ return {
360
+ "readerId": profile["readerId"],
361
+ "handle": profile.get("handle"),
362
+ "karma": int(profile.get("karma") or 0),
363
+ "state": profile.get("state", STATE_ACTIVE),
364
+ }
365
+
366
+
367
+ def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], settings: ReaderSettings) -> dict[str, Any]:
368
+ raw_subjects = payload.get("subjects", [])
369
+ if not isinstance(raw_subjects, list) or len(raw_subjects) > MAXIMUM_SUBJECTS:
370
+ return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subjects.")
371
+ subjects: list[str] = []
372
+ for raw in raw_subjects:
373
+ if isinstance(raw, str) and SUBJECT_KEY.fullmatch(raw):
374
+ subjects.append(raw)
375
+ continue
376
+ parsed, _ = parse_subject(raw, settings) if isinstance(raw, dict) else (None, None)
377
+ if parsed is None:
378
+ return refusal(422, "invalid_subjects", f"Send at most {MAXIMUM_SUBJECTS} subject keys or {{type, id}} subjects.")
379
+ subjects.append(subject_key(*parsed))
380
+ dimensions = [*TOGGLE_DIMENSIONS, *settings.dimensions]
381
+ wanted = {vote_id(subject, profile["readerId"], dimension): (subject, dimension) for subject in subjects for dimension in dimensions}
382
+ found = store.get_vote_values(list(wanted))
383
+ votes: dict[str, dict[str, int]] = {}
384
+ for stored_id, value in found.items():
385
+ subject, dimension = wanted[stored_id]
386
+ votes.setdefault(subject, {})[dimension] = value
387
+ # The reader's own up (1) or down (-1) vote per requested subject, by subject key (the key format of items.json).
388
+ subject_votes = {subject: held["up"] for subject, held in votes.items() if held.get("up") in (1, -1)}
389
+ return reply(200, {**public_profile(profile), "votes": votes, "subjectVotes": subject_votes})
390
+
391
+
392
+ def set_handle(
393
+ store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], settings: ReaderSettings, now_seconds: float
394
+ ) -> dict[str, Any]:
395
+ requested = payload.get("handle")
396
+ if not isinstance(requested, str):
397
+ return refusal(422, "invalid_handle", "Send a handle.")
398
+ handle = requested.lower()
399
+ if not HANDLE.fullmatch(handle):
400
+ return refusal(422, "invalid_handle", "A handle has 3 to 24 letters, digits, hyphens or underscores.")
401
+ if handle in RESERVED_HANDLES or handle in settings.reserved_handles:
402
+ return refusal(422, "reserved_handle", "That handle is reserved.")
403
+ if profile.get("handle"):
404
+ return refusal(409, "handle_already_set", "The handle is set once and cannot be changed.")
405
+ limited = within_rate_limit(store, profile, RATE_BUCKET_HANDLE, HANDLE_CHANGES_PER_HOUR, now_seconds)
406
+ if limited:
407
+ return limited
408
+ outcome = store.claim_handle(profile["siteSubject"], profile["readerId"], handle, iso(now_seconds))
409
+ if outcome == "taken":
410
+ return refusal(409, "handle_taken", "That handle is taken.")
411
+ if outcome == "already_set":
412
+ return refusal(409, "handle_already_set", "The handle is set once and cannot be changed.")
413
+ return reply(200, {**public_profile({**profile, "handle": handle})})
414
+
415
+
416
+ def react(
417
+ store: ReaderStore,
418
+ profile: dict[str, Any],
419
+ *,
420
+ subject: Any,
421
+ dimension: Any,
422
+ allowed_dimensions: tuple[str, ...],
423
+ value: int | None,
424
+ is_staff: bool,
425
+ settings: ReaderSettings,
426
+ now_seconds: float,
427
+ schedule_snapshot: Callable[[], None],
428
+ ) -> dict[str, Any]:
429
+ parsed, refused = parse_subject(subject, settings)
430
+ if refused:
431
+ return refused
432
+ subject_type, subject_id = parsed
433
+ subject = subject_key(subject_type, subject_id)
434
+ if not isinstance(dimension, str) or dimension not in allowed_dimensions:
435
+ return refusal(422, "invalid_dimension", "That dimension is not available.")
436
+ if profile.get("state") == STATE_BANNED:
437
+ return refusal(403, "reader_banned", "This reader cannot take part.")
438
+ if profile.get("state") == STATE_LIMITED:
439
+ return refusal(403, "reader_limited", "This reader cannot vote or rate right now.")
440
+ limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
441
+ if limited:
442
+ return limited
443
+ if not subject_is_open(store, subject_type, subject_id):
444
+ return refusal(404, "unknown_subject", "That item is not published.")
445
+ stored_id = vote_id(subject, profile["readerId"], dimension)
446
+ if value is None:
447
+ store.delete_vote(stored_id)
448
+ else:
449
+ store.put_vote(
450
+ {
451
+ "id": stored_id,
452
+ "subjectKey": subject,
453
+ "subjectType": subject_type,
454
+ "readerId": profile["readerId"],
455
+ "dimension": dimension,
456
+ "value": value,
457
+ "voterRole": ROLE_EDITOR if is_staff else ROLE_READER,
458
+ },
459
+ iso(now_seconds),
460
+ )
461
+ schedule_snapshot()
462
+ return reply(200, {"subject": subject, "dimension": dimension, "value": value})
463
+
464
+
465
+ def subject_is_open(store: ReaderStore, subject_type: str, subject_id: str) -> bool:
466
+ """A published item, or a submission that is visible (pending or promoted); hidden submissions cannot be voted on.
467
+
468
+ The other subject types have no record in this plugin (stories, proposals and concepts live in the site's own data), so any well-formed id is open.
469
+ """
470
+ if subject_type == SUBJECT_TYPE_SUBMISSION:
471
+ submission = store.get_submission(subject_id)
472
+ return submission is not None and submission.get("state") in VISIBLE_SUBMISSION_STATES
473
+ if subject_type == SUBJECT_TYPE_ITEM:
474
+ return store.is_published(subject_id)
475
+ return True
476
+
477
+
478
+ def clean_text(value: Any, label: str, maximum: int, *, required: bool) -> tuple[str | None, dict[str, Any] | None]:
479
+ if value is None or value == "":
480
+ if required:
481
+ return None, refusal(422, f"invalid_{label}", f"Send a {label}.")
482
+ return None, None
483
+ if not isinstance(value, str) or CONTROL_CHARACTERS.search(value):
484
+ return None, refusal(422, f"invalid_{label}", f"The {label} must be plain text.")
485
+ if EMAIL_ADDRESS.search(value):
486
+ return None, refusal(422, f"invalid_{label}", f"Public text cannot contain an email address. Remove it from the {label}.")
487
+ text = " ".join(value.split()) if label == "title" else value.strip()
488
+ if not text and required:
489
+ return None, refusal(422, f"invalid_{label}", f"Send a {label}.")
490
+ if len(text) > maximum:
491
+ return None, refusal(422, f"invalid_{label}", f"The {label} is at most {maximum} characters.")
492
+ return text or None, None
493
+
494
+
495
+ def public_submission(submission: dict[str, Any]) -> dict[str, Any]:
496
+ promoted = submission.get("state") == SUBMISSION_PROMOTED and submission.get("itemId")
497
+ return {"subject": submission["itemId"] if promoted else submission["id"], "url": submission["canonicalUrl"], "title": submission["title"], "state": submission["state"]}
498
+
499
+
500
+ def duplicate(existing: dict[str, Any]) -> dict[str, Any]:
501
+ if existing.get("state") not in VISIBLE_SUBMISSION_STATES:
502
+ return refusal(409, "duplicate_submission", "That link was already submitted.")
503
+ return refusal(409, "duplicate_submission", "That link was already submitted.", existing=public_submission(existing))
504
+
505
+
506
+ def new_content_fields(settings: ReaderSettings, visible: str) -> dict[str, Any]:
507
+ """``state`` and ``moderation`` for a new submission or comment.
508
+
509
+ With moderation off nothing waits in a queue. With pre-moderation the item starts hidden ("held") until a moderator approves it.
510
+ """
511
+ if settings.moderation_mode == "off":
512
+ return {"state": visible, "moderation": moderation_rules.MODERATION_APPROVED}
513
+ if settings.pre_moderation:
514
+ return {"state": "hidden", "moderation": moderation_rules.MODERATION_NEW}
515
+ return {"state": visible, "moderation": moderation_rules.MODERATION_NEW}
516
+
517
+
518
+ def submit_action(
519
+ store: ReaderStore,
520
+ profile: dict[str, Any],
521
+ payload: dict[str, Any],
522
+ settings: ReaderSettings,
523
+ now_seconds: float,
524
+ schedule_snapshot: Callable[[], None],
525
+ ) -> dict[str, Any]:
526
+ if profile.get("state") == STATE_LIMITED:
527
+ return refusal(403, "reader_limited", "This reader cannot submit right now.")
528
+ if not profile.get("handle"):
529
+ return refusal(422, "handle_required", "Choose a handle before submitting a link.")
530
+ if set(payload) - SUBMISSION_PAYLOAD_KEYS:
531
+ return refusal(422, "invalid_submission", "Send only url, title, kind and note.")
532
+ raw_url = payload.get("url")
533
+ if not isinstance(raw_url, str) or not raw_url.strip() or len(raw_url) > MAXIMUM_URL_LENGTH:
534
+ return refusal(422, "invalid_url", "Send the link as an http or https address.")
535
+ try:
536
+ canonical = canonical_source_url(raw_url.strip())
537
+ except InvalidUrl:
538
+ return refusal(422, "invalid_url", "Send the link as an http or https address.")
539
+ if len(canonical) > MAXIMUM_URL_LENGTH or EMAIL_ADDRESS.search(canonical):
540
+ return refusal(422, "invalid_url", "Send the link as an http or https address without an email address in it.")
541
+ title, problem = clean_text(payload.get("title"), "title", MAXIMUM_TITLE_LENGTH, required=True)
542
+ if problem:
543
+ return problem
544
+ note, problem = clean_text(payload.get("note"), "note", MAXIMUM_NOTE_LENGTH, required=False)
545
+ if problem:
546
+ return problem
547
+ kind = payload.get("kind", settings.submission_kinds[0])
548
+ if not isinstance(kind, str) or kind not in settings.submission_kinds:
549
+ return refusal(422, "invalid_kind", "That kind is not available.")
550
+ limit = new_account_limit(profile, settings, now_seconds, settings.submissions_per_day)
551
+ limited = within_rate_limit(store, profile, RATE_BUCKET_SUBMIT, limit, now_seconds, DAY_SECONDS)
552
+ if limited:
553
+ return limited
554
+ submission_id = submission_id_for(canonical)
555
+ existing = store.get_submission(submission_id)
556
+ if existing is not None:
557
+ return duplicate(existing)
558
+ domain = domain_of(canonical)
559
+ since = iso(now_seconds - settings.similar_hours * HOUR_SECONDS)
560
+ similar_rows = [
561
+ row
562
+ for row in store.recent_submissions_by_domain(domain, since)
563
+ if row.get("state") in VISIBLE_SUBMISSION_STATES and row["id"] != submission_id
564
+ ]
565
+ similar_rows.sort(key=lambda row: row["createdAt"], reverse=True)
566
+ similar_rows = similar_rows[:MAXIMUM_SIMILAR]
567
+ submission = {
568
+ "id": submission_id,
569
+ "canonicalUrl": canonical,
570
+ "domain": domain,
571
+ "title": title,
572
+ "kind": kind,
573
+ "readerId": profile["readerId"],
574
+ **new_content_fields(settings, SUBMISSION_PENDING),
575
+ "similar": [row["id"] for row in similar_rows],
576
+ "extractionState": EXTRACTION_PENDING,
577
+ "createdAt": iso(now_seconds),
578
+ }
579
+ if note:
580
+ submission["note"] = note
581
+ if not store.create_submission(submission):
582
+ return duplicate(store.get_submission(submission_id) or submission)
583
+ schedule_snapshot()
584
+ return reply(
585
+ 201,
586
+ {
587
+ "subject": submission_id,
588
+ "url": canonical,
589
+ "title": title,
590
+ "kind": kind,
591
+ "state": "held" if submission["state"] == SUBMISSION_HIDDEN else SUBMISSION_PENDING,
592
+ "extraction": EXTRACTION_PENDING,
593
+ "similar": [{"subject": row["id"], "url": row["canonicalUrl"], "title": row["title"]} for row in similar_rows],
594
+ },
595
+ )
596
+
597
+
598
+ def clean_comment_body(value: Any, settings: ReaderSettings) -> tuple[str | None, dict[str, Any] | None]:
599
+ """Plain text, trimmed, at most ``comment_maximum_length`` characters, ``comment_maximum_links`` links, no email address."""
600
+ if not isinstance(value, str):
601
+ return None, refusal(422, "invalid_body", "Send the comment as text.")
602
+ text = value.replace("\r\n", "\n").replace("\r", "\n").strip()
603
+ if not text:
604
+ return None, refusal(422, "invalid_body", "Write something first.")
605
+ if TEXT_CONTROL_CHARACTERS.search(text):
606
+ return None, refusal(422, "invalid_body", "A comment is plain text.")
607
+ if len(text) > settings.comment_maximum_length:
608
+ return None, refusal(422, "body_too_long", f"A comment is at most {settings.comment_maximum_length} characters.")
609
+ if len(LINK.findall(text)) > settings.comment_maximum_links:
610
+ return None, refusal(422, "too_many_links", f"A comment has at most {settings.comment_maximum_links} links.")
611
+ if EMAIL_ADDRESS.search(text):
612
+ return None, refusal(422, "invalid_body", "Public text cannot contain an email address. Remove it from the comment.")
613
+ return text, None
614
+
615
+
616
+ def writes_comments(profile: dict[str, Any]) -> dict[str, Any] | None:
617
+ if profile.get("state") == STATE_LIMITED:
618
+ return refusal(403, "reader_limited", "This reader cannot comment right now.")
619
+ if not profile.get("handle"):
620
+ return refusal(422, "handle_required", "Choose a handle before commenting.")
621
+ return None
622
+
623
+
624
+ def rating_context_for(store: ReaderStore, profile: dict[str, Any], subject: str, raw: Any, settings: ReaderSettings) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
625
+ """The rating a comment explains must be the reader's own current vote, so a comment cannot claim a rating nobody gave."""
626
+ if raw is None:
627
+ return None, None
628
+ if not isinstance(raw, dict) or set(raw) != {"dimension", "value"}:
629
+ return None, refusal(422, "invalid_rating_context", "Send ratingContext as {dimension, value}.")
630
+ dimension, value = raw["dimension"], raw["value"]
631
+ if not isinstance(dimension, str) or dimension not in (*TOGGLE_DIMENSIONS, *settings.dimensions) or isinstance(value, bool) or not isinstance(value, int):
632
+ return None, refusal(422, "invalid_rating_context", "That rating context is not available.")
633
+ stored = store.get_vote_values([vote_id(subject, profile["readerId"], dimension)])
634
+ if stored.get(vote_id(subject, profile["readerId"], dimension)) != value:
635
+ return None, refusal(422, "rating_context_mismatch", "Rate or vote first; a comment can only explain your own current rating.")
636
+ return {"dimension": dimension, "value": value}, None
637
+
638
+
639
+ def comment_action(
640
+ store: ReaderStore,
641
+ profile: dict[str, Any],
642
+ payload: dict[str, Any],
643
+ settings: ReaderSettings,
644
+ now_seconds: float,
645
+ schedule_snapshot: Callable[[], None],
646
+ ) -> dict[str, Any]:
647
+ problem = writes_comments(profile)
648
+ if problem:
649
+ return problem
650
+ if set(payload) - COMMENT_PAYLOAD_KEYS:
651
+ return refusal(422, "invalid_comment", "Send only subject, body, parent and ratingContext.")
652
+ subject = payload.get("subject")
653
+ if not isinstance(subject, str) or not SUBJECT_KEY.fullmatch(subject):
654
+ return refusal(422, "invalid_subject", "Send the key of a published item.")
655
+ body, problem = clean_comment_body(payload.get("body"), settings)
656
+ if problem:
657
+ return problem
658
+ parent_id = payload.get("parent")
659
+ if parent_id is not None and (not isinstance(parent_id, str) or not COMMENT_ID.fullmatch(parent_id)):
660
+ return refusal(422, "invalid_parent", "That comment cannot be replied to.")
661
+ context, problem = rating_context_for(store, profile, subject, payload.get("ratingContext"), settings)
662
+ if problem:
663
+ return problem
664
+ if context is not None and parent_id is not None:
665
+ return refusal(422, "invalid_rating_context", "A reply does not explain a rating.")
666
+ limited = within_rate_limit(store, profile, RATE_BUCKET_COMMENT, new_account_limit(profile, settings, now_seconds, settings.comments_per_hour), now_seconds)
667
+ if limited:
668
+ return limited
669
+ if not subject_is_open(store, legacy_subject_type(subject), subject):
670
+ return refusal(404, "unknown_subject", "That item is not published.")
671
+ if parent_id is not None:
672
+ parent = store.get_comment(parent_id)
673
+ if parent is None or parent.get("subjectKey") != subject or parent.get("parentId") or parent.get("state") != COMMENT_VISIBLE:
674
+ return refusal(422, "invalid_parent", "That comment cannot be replied to.")
675
+ comment_id = COMMENT_ID_PREFIX + uuid.uuid4().hex
676
+ created = iso(now_seconds)
677
+ comment = {
678
+ "id": comment_id,
679
+ "subjectKey": subject,
680
+ "readerId": profile["readerId"],
681
+ "body": body,
682
+ **new_content_fields(settings, COMMENT_VISIBLE),
683
+ "createdAt": created,
684
+ }
685
+ if parent_id is not None:
686
+ comment["parentId"] = parent_id
687
+ if context is not None:
688
+ comment["ratingContext"] = json.dumps(context, separators=(",", ":"))
689
+ store.create_comment(comment)
690
+ schedule_snapshot()
691
+ return reply(
692
+ 201,
693
+ {
694
+ "id": comment_id, "subject": subject, "parent": parent_id, "body": body, "createdAt": created, "ratingContext": context,
695
+ "state": "held" if comment["state"] == COMMENT_HIDDEN else COMMENT_VISIBLE,
696
+ },
697
+ )
698
+
699
+
700
+ def report_action(
701
+ store: ReaderStore,
702
+ profile: dict[str, Any],
703
+ payload: dict[str, Any],
704
+ settings: ReaderSettings,
705
+ now_seconds: float,
706
+ schedule_snapshot: Callable[[], None],
707
+ ) -> dict[str, Any]:
708
+ """A reader reports a visible submission or comment; it goes back to the moderation queue. One report per reader and target."""
709
+ if set(payload) - {"target", "reason"}:
710
+ return refusal(422, "invalid_report", "Send only target and reason.")
711
+ target = payload.get("target")
712
+ target_type = moderation_rules.target_type_of(target) if isinstance(target, str) else None
713
+ if target_type not in (moderation_rules.TARGET_SUBMISSION, moderation_rules.TARGET_COMMENT):
714
+ return refusal(422, "invalid_target", "Report a submission or a comment.")
715
+ reason = payload.get("reason")
716
+ if not isinstance(reason, str) or not reason.strip() or len(reason.strip()) > MAXIMUM_REPORT_REASON or CONTROL_CHARACTERS.search(reason):
717
+ return refusal(422, "invalid_reason", f"Give a reason in plain text of at most {MAXIMUM_REPORT_REASON} characters.")
718
+ limited = within_rate_limit(
719
+ store, profile, RATE_BUCKET_REPORT, new_account_limit(profile, settings, now_seconds, settings.reports_per_day), now_seconds, DAY_SECONDS
720
+ )
721
+ if limited:
722
+ return limited
723
+ row = store.get_submission(target) if target_type == moderation_rules.TARGET_SUBMISSION else store.get_comment(target)
724
+ visible = VISIBLE_SUBMISSION_STATES if target_type == moderation_rules.TARGET_SUBMISSION else (COMMENT_VISIBLE,)
725
+ if row is None or row.get("state") not in visible:
726
+ return refusal(404, "unknown_target", "That cannot be reported.")
727
+ report = {
728
+ "id": REPORT_ID_PREFIX + hashlib.sha256(f"{profile['readerId']}#{target}".encode("utf-8")).hexdigest()[:24],
729
+ "targetType": target_type,
730
+ "target": target,
731
+ "reporterReaderId": profile["readerId"],
732
+ "reason": reason.strip(),
733
+ "state": "open",
734
+ "createdAt": iso(now_seconds),
735
+ }
736
+ store.put_report(report, target, "submission" if target_type == moderation_rules.TARGET_SUBMISSION else "comment", iso(now_seconds))
737
+ return reply(200, {"reported": True})
738
+
739
+
740
+ def flag_action(
741
+ store: ReaderStore,
742
+ profile: dict[str, Any],
743
+ payload: dict[str, Any],
744
+ settings: ReaderSettings,
745
+ is_staff: bool,
746
+ now_seconds: float,
747
+ schedule_snapshot: Callable[[], None],
748
+ ) -> dict[str, Any]:
749
+ """A reader flags a subject as wrong, as belonging under another subject, or as needing a better name; a reason is required.
750
+
751
+ One flag per reader, subject and kind: flagging again replaces the reason. Flags are stored apart from votes and reach the
752
+ snapshot as counts only. The stored row holds the readerId, never a siteSubject.
753
+ """
754
+ if set(payload) - FLAG_PAYLOAD_KEYS:
755
+ return refusal(422, "invalid_flag", "Send subject, kind, reason, and target or name for the kind.")
756
+ kind = payload.get("kind")
757
+ if kind not in FLAG_KINDS:
758
+ return refusal(422, "invalid_flag_kind", f"Send kind as one of {', '.join(FLAG_KINDS)}.")
759
+ parsed, refused = parse_subject(payload.get("subject"), settings)
760
+ if refused:
761
+ return refused
762
+ reason = payload.get("reason")
763
+ if not isinstance(reason, str) or not reason.strip() or len(reason.strip()) > MAXIMUM_FLAG_REASON or CONTROL_CHARACTERS.search(reason):
764
+ return refusal(422, "invalid_reason", f"Give a reason in plain text of at most {MAXIMUM_FLAG_REASON} characters.")
765
+ if EMAIL_ADDRESS.search(reason):
766
+ return refusal(422, "invalid_reason", "A reason cannot contain an email address.")
767
+ target_key: str | None = None
768
+ suggested: str | None = None
769
+ if kind == FLAG_BELONGS_UNDER:
770
+ target, refused = parse_subject(payload.get("target"), settings) if isinstance(payload.get("target"), dict) else (None, True)
771
+ if refused or "name" in payload:
772
+ return refusal(422, "invalid_target", "Send target as {type, id} for belongs_under.")
773
+ target_key = subject_key(*target)
774
+ if target_key == subject_key(*parsed):
775
+ return refusal(422, "invalid_target", "A subject cannot belong under itself.")
776
+ elif kind == FLAG_BETTER_NAME:
777
+ suggested, refused = clean_text(payload.get("name"), "name", MAXIMUM_SUGGESTED_NAME, required=True)
778
+ if refused or "target" in payload:
779
+ return refused or refusal(422, "invalid_name", "Send name for better_name.")
780
+ elif "target" in payload or "name" in payload:
781
+ return refusal(422, "invalid_flag", "wrong takes only a reason.")
782
+ if profile.get("state") == STATE_LIMITED:
783
+ return refusal(403, "reader_limited", "This reader cannot flag right now.")
784
+ limited = within_rate_limit(store, profile, RATE_BUCKET_REACTIONS, action_limit(profile, settings, now_seconds), now_seconds)
785
+ if limited:
786
+ return limited
787
+ subject_type, subject_id = parsed
788
+ if not subject_is_open(store, subject_type, subject_id):
789
+ return refusal(404, "unknown_subject", "That subject cannot be flagged.")
790
+ key = subject_key(subject_type, subject_id)
791
+ store.put_flag(
792
+ {
793
+ "id": f"{key}#{profile['readerId']}#{kind}",
794
+ "subjectKey": key,
795
+ "subjectType": subject_type,
796
+ "readerId": profile["readerId"],
797
+ "kind": kind,
798
+ "reason": reason.strip(),
799
+ "targetKey": target_key,
800
+ "suggestedName": suggested,
801
+ "voterRole": ROLE_EDITOR if is_staff else ROLE_READER,
802
+ },
803
+ iso(now_seconds),
804
+ )
805
+ schedule_snapshot()
806
+ return reply(200, {"subject": payload["subject"], "kind": kind})
807
+
808
+
809
+ def own_comment(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any], allowed: frozenset[str]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
810
+ if set(payload) - allowed:
811
+ return None, refusal(422, "invalid_comment", "Send only " + ", ".join(sorted(allowed)) + ".")
812
+ comment_id = payload.get("id")
813
+ if not isinstance(comment_id, str) or not COMMENT_ID.fullmatch(comment_id):
814
+ return None, refusal(404, "unknown_comment", "That comment does not exist.")
815
+ comment = store.get_comment(comment_id)
816
+ if comment is None or comment.get("readerId") != profile["readerId"] or comment.get("state") == COMMENT_HIDDEN:
817
+ return None, refusal(404, "unknown_comment", "That comment does not exist.")
818
+ return comment, None
819
+
820
+
821
+ def edit_comment_action(
822
+ store: ReaderStore,
823
+ profile: dict[str, Any],
824
+ payload: dict[str, Any],
825
+ settings: ReaderSettings,
826
+ now_seconds: float,
827
+ schedule_snapshot: Callable[[], None],
828
+ ) -> dict[str, Any]:
829
+ problem = writes_comments(profile)
830
+ if problem:
831
+ return problem
832
+ comment, problem = own_comment(store, profile, payload, COMMENT_EDIT_KEYS)
833
+ if problem:
834
+ return problem
835
+ if comment.get("state") != COMMENT_VISIBLE:
836
+ return refusal(409, "comment_deleted", "A deleted comment cannot be edited.")
837
+ if now_seconds - seconds_of(comment["createdAt"]) > settings.comment_edit_minutes * 60:
838
+ return refusal(403, "edit_window_closed", f"A comment can be edited for {settings.comment_edit_minutes} minutes after it is posted.")
839
+ body, problem = clean_comment_body(payload.get("body"), settings)
840
+ if problem:
841
+ return problem
842
+ limited = within_rate_limit(store, profile, RATE_BUCKET_COMMENT, new_account_limit(profile, settings, now_seconds, settings.comments_per_hour), now_seconds)
843
+ if limited:
844
+ return limited
845
+ edited = iso(now_seconds)
846
+ if not store.update_comment(comment["id"], {"body": body, "editedAt": edited, "reviewedAt": None, "signals": None}, edited, expected_state=COMMENT_VISIBLE):
847
+ return refusal(409, "comment_changed", "That comment changed. Reload and try again.")
848
+ schedule_snapshot()
849
+ return reply(200, {"id": comment["id"], "body": body, "editedAt": edited})
850
+
851
+
852
+ def delete_comment_action(
853
+ store: ReaderStore,
854
+ profile: dict[str, Any],
855
+ payload: dict[str, Any],
856
+ settings: ReaderSettings,
857
+ now_seconds: float,
858
+ schedule_snapshot: Callable[[], None],
859
+ ) -> dict[str, Any]:
860
+ comment, problem = own_comment(store, profile, payload, COMMENT_DELETE_KEYS)
861
+ if problem:
862
+ return problem
863
+ if comment.get("state") == COMMENT_DELETED:
864
+ return reply(200, {"id": comment["id"], "state": COMMENT_DELETED})
865
+ deleted = iso(now_seconds)
866
+ if not store.update_comment(comment["id"], {"body": "", "deletedAt": deleted, "state": COMMENT_DELETED, "ratingContext": None}, deleted, expected_state=COMMENT_VISIBLE):
867
+ return refusal(409, "comment_changed", "That comment changed. Reload and try again.")
868
+ schedule_snapshot()
869
+ return reply(200, {"id": comment["id"], "state": COMMENT_DELETED})
870
+
871
+
872
+ def typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
873
+ """``{subject: {type, id}, value: 1 | -1 | null}``: an up or down vote on any enabled subject type; ``null`` removes the reader's vote."""
874
+ value = payload.get("value")
875
+ if set(payload) - VOTE_PAYLOAD_KEYS or isinstance(value, bool) or value not in (1, -1, None):
876
+ return refusal(422, "invalid_vote", "Send only subject and value, and value as 1, -1 or null.")
877
+ answer = react(
878
+ store, profile, subject=payload.get("subject"), dimension=VOTE_DIMENSION, allowed_dimensions=(VOTE_DIMENSION,),
879
+ value=value, is_staff=is_staff, settings=settings, now_seconds=now_seconds, schedule_snapshot=schedule_snapshot,
880
+ )
881
+ if answer["statusCode"] == 200:
882
+ answer = reply(200, {"subject": payload["subject"], "value": value})
883
+ return answer
884
+
885
+
886
+ def vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
887
+ if isinstance(payload.get("subject"), dict):
888
+ return typed_vote_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot)
889
+ on = payload.get("on")
890
+ if not isinstance(on, bool):
891
+ return refusal(422, "invalid_vote", "Send on as true or false.")
892
+ return react(
893
+ store, profile, subject=payload.get("subject"), dimension=payload.get("dimension"), allowed_dimensions=TOGGLE_DIMENSIONS,
894
+ value=1 if on else None, is_staff=is_staff, settings=settings, now_seconds=now_seconds, schedule_snapshot=schedule_snapshot,
895
+ )
896
+
897
+
898
+ def rating_action(store, profile, payload, settings, is_staff, now_seconds, schedule_snapshot):
899
+ value = payload.get("value")
900
+ if value is not None and (isinstance(value, bool) or not isinstance(value, int) or not MINIMUM_RATING <= value <= MAXIMUM_RATING):
901
+ return refusal(422, "invalid_rating", f"Send a whole number from {MINIMUM_RATING} to {MAXIMUM_RATING}, or null.")
902
+ return react(
903
+ store, profile, subject=payload.get("subject"), dimension=payload.get("dimension"), allowed_dimensions=settings.dimensions,
904
+ value=value, is_staff=is_staff, settings=settings, now_seconds=now_seconds, schedule_snapshot=schedule_snapshot,
905
+ )
906
+
907
+
908
+ def handle_event(
909
+ event: dict[str, Any],
910
+ *,
911
+ store: ReaderStore,
912
+ settings: ReaderSettings,
913
+ now_seconds: float,
914
+ schedule_snapshot: Callable[[], None],
915
+ ) -> dict[str, Any]:
916
+ site_subject, action, payload = event.get("siteSubject"), event.get("action"), event.get("payload")
917
+ if not valid_site_subject(site_subject, settings):
918
+ return refusal(403, "reader_identity_invalid", "The reader identity is not valid for this site.")
919
+ if not isinstance(payload, dict) or not isinstance(event.get("isStaff"), bool):
920
+ return refusal(400, "malformed_request", "The request is malformed.")
921
+ profile = ensure_profile(store, site_subject, now_seconds)
922
+ if action == ACTION_ME:
923
+ return my_state(store, profile, payload, settings)
924
+ if profile.get("state") == STATE_BANNED:
925
+ return refusal(403, "reader_banned", "This reader cannot take part.")
926
+ if action == ACTION_HANDLE:
927
+ return set_handle(store, profile, payload, settings, now_seconds)
928
+ if action == ACTION_VOTE:
929
+ return vote_action(store, profile, payload, settings, event["isStaff"], now_seconds, schedule_snapshot)
930
+ if action == ACTION_RATING:
931
+ return rating_action(store, profile, payload, settings, event["isStaff"], now_seconds, schedule_snapshot)
932
+ if action == ACTION_SUBMIT:
933
+ return submit_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
934
+ if action == ACTION_COMMENT:
935
+ return comment_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
936
+ if action == ACTION_COMMENT_EDIT:
937
+ return edit_comment_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
938
+ if action == ACTION_COMMENT_DELETE:
939
+ return delete_comment_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
940
+ if action == ACTION_REPORT:
941
+ return report_action(store, profile, payload, settings, now_seconds, schedule_snapshot)
942
+ if action == ACTION_FLAG:
943
+ return flag_action(store, profile, payload, settings, event["isStaff"], now_seconds, schedule_snapshot)
944
+ return refusal(400, "unknown_action", "Unknown action.")
945
+
946
+
947
+ def plain(value: Any) -> Any:
948
+ if isinstance(value, Decimal):
949
+ return int(value) if value == value.to_integral_value() else float(value)
950
+ if isinstance(value, dict):
951
+ return {key: plain(item) for key, item in value.items()}
952
+ if isinstance(value, list):
953
+ return [plain(item) for item in value]
954
+ return value
955
+
956
+
957
+ def _without_none(item: dict[str, Any]) -> dict[str, Any]:
958
+ return {key: value for key, value in item.items() if value is not None}
959
+
960
+
961
+ def is_conditional_failure(error: Exception) -> bool:
962
+ return str(getattr(error, "response", {}).get("Error", {}).get("Code", "")) == "ConditionalCheckFailedException"
963
+
964
+
965
+ class DynamoReaderStore:
966
+ """The Amplify model tables written directly: items carry ``__typename``, ``createdAt`` and ``updatedAt`` so staff can list them."""
967
+
968
+ def __init__(
969
+ self,
970
+ *,
971
+ resource: Any,
972
+ profile_table: str,
973
+ handle_table: str,
974
+ vote_table: str,
975
+ published_item_table: str,
976
+ rate_table: str,
977
+ submission_table: str = "",
978
+ comment_table: str = "",
979
+ report_table: str = "",
980
+ action_table: str = "",
981
+ item_table: str = "",
982
+ proposal_table: str = "",
983
+ flag_table: str = "",
984
+ ) -> None:
985
+ self._resource = resource
986
+ self._names = {
987
+ "profile": profile_table,
988
+ "handle": handle_table,
989
+ "vote": vote_table,
990
+ "published": published_item_table,
991
+ "rate": rate_table,
992
+ "submission": submission_table,
993
+ "comment": comment_table,
994
+ "report": report_table,
995
+ "action": action_table,
996
+ "item": item_table,
997
+ "proposal": proposal_table,
998
+ "flag": flag_table,
999
+ }
1000
+
1001
+ def _table(self, key: str) -> Any:
1002
+ return self._resource.Table(self._names[key])
1003
+
1004
+ def get_proposal(self, proposal_id: str) -> dict[str, Any] | None:
1005
+ item = self._table("proposal").get_item(Key={"id": proposal_id}, ConsistentRead=True).get("Item")
1006
+ return plain(item) if item else None
1007
+
1008
+ def create_proposal(self, row: dict[str, Any]) -> bool:
1009
+ item = {**_without_none(row), "__typename": "Proposal", "updatedAt": row["createdAt"]}
1010
+ try:
1011
+ self._table("proposal").put_item(Item=item, ConditionExpression="attribute_not_exists(id)")
1012
+ except Exception as error:
1013
+ if is_conditional_failure(error):
1014
+ return False
1015
+ raise
1016
+ return True
1017
+
1018
+ def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool:
1019
+ names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
1020
+ assignments = ", ".join(f"#c{index} = :c{index}" for index in range(len(changes)))
1021
+ values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values())}}
1022
+ try:
1023
+ self._table("proposal").update_item(
1024
+ Key={"id": proposal_id},
1025
+ UpdateExpression=f"SET {assignments}, #u = :now",
1026
+ ConditionExpression="#st = :expected",
1027
+ ExpressionAttributeNames=names,
1028
+ ExpressionAttributeValues=values,
1029
+ )
1030
+ except Exception as error:
1031
+ if is_conditional_failure(error):
1032
+ return False
1033
+ raise
1034
+ return True
1035
+
1036
+ def get_profile(self, site_subject: str) -> dict[str, Any] | None:
1037
+ item = self._table("profile").get_item(Key={"id": site_subject}, ConsistentRead=True).get("Item")
1038
+ return plain(item) if item else None
1039
+
1040
+ def create_profile(self, profile: dict[str, Any]) -> bool:
1041
+ item = {**profile, "__typename": "ReaderProfile", "updatedAt": profile["createdAt"]}
1042
+ try:
1043
+ self._table("profile").put_item(Item=item, ConditionExpression="attribute_not_exists(id)")
1044
+ except Exception as error:
1045
+ if is_conditional_failure(error):
1046
+ return False
1047
+ raise
1048
+ return True
1049
+
1050
+ def claim_handle(self, site_subject: str, reader_id: str, handle: str, now_iso: str) -> str:
1051
+ client = self._resource.meta.client
1052
+ try:
1053
+ client.transact_write_items(
1054
+ TransactItems=[
1055
+ {
1056
+ "Put": {
1057
+ "TableName": self._names["handle"],
1058
+ "Item": {"id": handle, "__typename": "ReaderHandle", "readerId": reader_id, "createdAt": now_iso, "updatedAt": now_iso},
1059
+ "ConditionExpression": "attribute_not_exists(id)",
1060
+ }
1061
+ },
1062
+ {
1063
+ "Update": {
1064
+ "TableName": self._names["profile"],
1065
+ "Key": {"id": site_subject},
1066
+ "UpdateExpression": "SET #h = :h, updatedAt = :now",
1067
+ "ConditionExpression": "attribute_exists(id) AND attribute_not_exists(#h)",
1068
+ "ExpressionAttributeNames": {"#h": "handle"},
1069
+ "ExpressionAttributeValues": {":h": handle, ":now": now_iso},
1070
+ }
1071
+ },
1072
+ ]
1073
+ )
1074
+ except Exception as error:
1075
+ reasons = getattr(error, "response", {}).get("CancellationReasons") or []
1076
+ codes = [str(reason.get("Code", "")) for reason in reasons]
1077
+ if codes[:1] == ["ConditionalCheckFailed"]:
1078
+ return "taken"
1079
+ if codes[1:2] == ["ConditionalCheckFailed"]:
1080
+ return "already_set"
1081
+ raise
1082
+ return "claimed"
1083
+
1084
+ def get_vote_values(self, vote_ids: list[str]) -> dict[str, int]:
1085
+ found: dict[str, int] = {}
1086
+ table_name = self._names["vote"]
1087
+ for start in range(0, len(vote_ids), 100):
1088
+ pending = {table_name: {"Keys": [{"id": stored_id} for stored_id in vote_ids[start : start + 100]], "ProjectionExpression": "id, #v", "ExpressionAttributeNames": {"#v": "value"}}}
1089
+ while pending:
1090
+ response = self._resource.batch_get_item(RequestItems=pending)
1091
+ for item in response.get("Responses", {}).get(table_name, []):
1092
+ found[item["id"]] = int(item["value"])
1093
+ pending = response.get("UnprocessedKeys") or {}
1094
+ return found
1095
+
1096
+ def put_vote(self, vote: dict[str, Any], now_iso: str) -> None:
1097
+ self._table("vote").update_item(
1098
+ Key={"id": vote["id"]},
1099
+ UpdateExpression=(
1100
+ "SET #t = :t, subjectKey = :s, subjectType = :st, readerId = :r, #d = :d, #v = :v, voterRole = :role, "
1101
+ "createdAt = if_not_exists(createdAt, :now), updatedAt = :now"
1102
+ ),
1103
+ ExpressionAttributeNames={"#t": "__typename", "#d": "dimension", "#v": "value"},
1104
+ ExpressionAttributeValues={
1105
+ ":t": "ReaderVote",
1106
+ ":s": vote["subjectKey"],
1107
+ ":st": vote.get("subjectType") or legacy_subject_type(vote["subjectKey"]),
1108
+ ":r": vote["readerId"],
1109
+ ":d": vote["dimension"],
1110
+ ":v": vote["value"],
1111
+ ":role": vote["voterRole"],
1112
+ ":now": now_iso,
1113
+ },
1114
+ )
1115
+
1116
+ def delete_vote(self, vote_id_: str) -> None:
1117
+ self._table("vote").delete_item(Key={"id": vote_id_})
1118
+
1119
+ def put_flag(self, flag: dict[str, Any], now_iso: str) -> None:
1120
+ self._table("flag").put_item(Item=_without_none({**flag, "__typename": "ReaderFlag", "createdAt": now_iso, "updatedAt": now_iso}))
1121
+
1122
+ def is_published(self, subject_key: str) -> bool:
1123
+ item = self._table("published").get_item(Key={"id": PUBLISHED_ID_PREFIX + subject_key}, ProjectionExpression="#s", ExpressionAttributeNames={"#s": "status"}).get("Item")
1124
+ return bool(item) and item.get("status") == "published"
1125
+
1126
+ def get_submission(self, submission_id: str) -> dict[str, Any] | None:
1127
+ item = self._table("submission").get_item(Key={"id": submission_id}, ConsistentRead=True).get("Item")
1128
+ return plain(item) if item else None
1129
+
1130
+ def create_submission(self, submission: dict[str, Any]) -> bool:
1131
+ item = {**submission, "__typename": "ReaderSubmission", "updatedAt": submission["createdAt"]}
1132
+ try:
1133
+ self._table("submission").put_item(Item=item, ConditionExpression="attribute_not_exists(id)")
1134
+ except Exception as error:
1135
+ if is_conditional_failure(error):
1136
+ return False
1137
+ raise
1138
+ return True
1139
+
1140
+ def recent_submissions_by_domain(self, domain: str, since_iso: str) -> list[dict[str, Any]]:
1141
+ from boto3.dynamodb.conditions import Key
1142
+
1143
+ rows: list[dict[str, Any]] = []
1144
+ kwargs: dict[str, Any] = {
1145
+ "IndexName": SUBMISSION_DOMAIN_INDEX,
1146
+ "KeyConditionExpression": Key("domain").eq(domain) & Key("createdAt").gte(since_iso),
1147
+ }
1148
+ while True:
1149
+ page = self._table("submission").query(**kwargs)
1150
+ rows.extend(plain(item) for item in page.get("Items", []))
1151
+ if "LastEvaluatedKey" not in page:
1152
+ return rows
1153
+ kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"]
1154
+
1155
+ def get_comment(self, comment_id: str) -> dict[str, Any] | None:
1156
+ item = self._table("comment").get_item(Key={"id": comment_id}, ConsistentRead=True).get("Item")
1157
+ return plain(item) if item else None
1158
+
1159
+ def create_comment(self, comment: dict[str, Any]) -> None:
1160
+ self._table("comment").put_item(
1161
+ Item={**comment, "__typename": "ReaderComment", "updatedAt": comment["createdAt"]}, ConditionExpression="attribute_not_exists(id)"
1162
+ )
1163
+
1164
+ def update_comment(self, comment_id: str, changes: dict[str, Any], now_iso: str, *, expected_state: str) -> bool:
1165
+ """Sets the changed fields (``None`` removes one) while the comment is still in ``expected_state``."""
1166
+ names = {"#state": "state"}
1167
+ values: dict[str, Any] = {":expected": expected_state, ":now": now_iso}
1168
+ sets, removes = ["updatedAt = :now"], []
1169
+ for number, (field, value) in enumerate(changes.items()):
1170
+ names[f"#f{number}"] = field
1171
+ if value is None:
1172
+ removes.append(f"#f{number}")
1173
+ else:
1174
+ values[f":v{number}"] = value
1175
+ sets.append(f"#f{number} = :v{number}")
1176
+ expression = "SET " + ", ".join(sets) + (" REMOVE " + ", ".join(removes) if removes else "")
1177
+ try:
1178
+ self._table("comment").update_item(
1179
+ Key={"id": comment_id},
1180
+ UpdateExpression=expression,
1181
+ ConditionExpression="attribute_exists(id) AND #state = :expected",
1182
+ ExpressionAttributeNames=names,
1183
+ ExpressionAttributeValues=values,
1184
+ )
1185
+ except Exception as error:
1186
+ if is_conditional_failure(error):
1187
+ return False
1188
+ raise
1189
+ return True
1190
+
1191
+ def put_report(self, report: dict[str, Any], target_key: str, target_table: str, now_iso: str) -> bool:
1192
+ """Stores the report and puts the target back in the queue in one transaction. A second report by the same reader changes nothing."""
1193
+ item = {**report, "__typename": "ReaderReport", "updatedAt": report["createdAt"]}
1194
+ try:
1195
+ self._resource.meta.client.transact_write_items(
1196
+ TransactItems=[
1197
+ {"Put": {"TableName": self._names["report"], "Item": _without_none(item), "ConditionExpression": "attribute_not_exists(id)"}},
1198
+ {
1199
+ "Update": {
1200
+ "TableName": self._names[target_table],
1201
+ "Key": {"id": target_key},
1202
+ "UpdateExpression": "SET moderation = :new, updatedAt = :now ADD reportCount :one",
1203
+ "ConditionExpression": "attribute_exists(id)",
1204
+ "ExpressionAttributeValues": {":new": moderation_rules.MODERATION_NEW, ":now": now_iso, ":one": 1},
1205
+ }
1206
+ },
1207
+ ]
1208
+ )
1209
+ except Exception as error:
1210
+ reasons = getattr(error, "response", {}).get("CancellationReasons") or []
1211
+ if [str(reason.get("Code", "")) for reason in reasons][:1] == ["ConditionalCheckFailed"]:
1212
+ return False
1213
+ raise
1214
+ return True
1215
+
1216
+ def get_profile_by_reader(self, reader_id: str) -> dict[str, Any] | None:
1217
+ from boto3.dynamodb.conditions import Key
1218
+
1219
+ page = self._table("profile").query(IndexName=PROFILE_READER_INDEX, KeyConditionExpression=Key("readerId").eq(reader_id))
1220
+ rows = page.get("Items", [])
1221
+ return plain(rows[0]) if rows else None
1222
+
1223
+ def get_action(self, action_id: str) -> dict[str, Any] | None:
1224
+ item = self._table("action").get_item(Key={"id": action_id}, ConsistentRead=True).get("Item")
1225
+ return plain(item) if item else None
1226
+
1227
+ def transact_moderation(self, action: dict[str, Any], target_type: str, target_key: str, plan: Any, *, undoes: str | None = None) -> str:
1228
+ """Writes the log row, the target change (only if the target still has the expected state) and, for an undo, the pointer on the original."""
1229
+ table_for = {moderation_rules.TARGET_SUBMISSION: "submission", moderation_rules.TARGET_COMMENT: "comment", moderation_rules.TARGET_READER: "profile"}
1230
+ names: dict[str, str] = {}
1231
+ values: dict[str, Any] = {":now": action["at"]}
1232
+ sets = ["updatedAt = :now"]
1233
+ conditions = ["attribute_exists(id)"]
1234
+ for number, (field, value) in enumerate(plan.changes.items()):
1235
+ names[f"#c{number}"] = field
1236
+ values[f":c{number}"] = value
1237
+ sets.append(f"#c{number} = :c{number}")
1238
+ for number, (field, value) in enumerate(plan.expected.items()):
1239
+ names[f"#e{number}"] = field
1240
+ values[f":e{number}"] = value
1241
+ conditions.append(f"#e{number} = :e{number}")
1242
+ items: list[dict[str, Any]] = [
1243
+ {"Put": {"TableName": self._names["action"], "Item": _without_none({**action, "__typename": "ModerationAction", "updatedAt": action["at"]}), "ConditionExpression": "attribute_not_exists(id)"}},
1244
+ {
1245
+ "Update": {
1246
+ "TableName": self._names[table_for[target_type]],
1247
+ "Key": {"id": target_key},
1248
+ "UpdateExpression": "SET " + ", ".join(sets),
1249
+ "ConditionExpression": " AND ".join(conditions),
1250
+ "ExpressionAttributeNames": names,
1251
+ "ExpressionAttributeValues": values,
1252
+ }
1253
+ },
1254
+ ]
1255
+ if undoes:
1256
+ items.append(
1257
+ {
1258
+ "Update": {
1259
+ "TableName": self._names["action"],
1260
+ "Key": {"id": undoes},
1261
+ "UpdateExpression": "SET undoneBy = :by, updatedAt = :now",
1262
+ "ConditionExpression": "attribute_exists(id) AND attribute_not_exists(undoneBy)",
1263
+ "ExpressionAttributeValues": {":by": action["id"], ":now": action["at"]},
1264
+ }
1265
+ }
1266
+ )
1267
+ if plan.item is not None:
1268
+ items.append({"Put": {"TableName": self._names["item"], "Item": _without_none(plan.item), "ConditionExpression": "attribute_not_exists(id)"}})
1269
+ try:
1270
+ self._resource.meta.client.transact_write_items(TransactItems=items)
1271
+ except Exception as error:
1272
+ reasons = getattr(error, "response", {}).get("CancellationReasons") or []
1273
+ codes = [str(reason.get("Code", "")) for reason in reasons]
1274
+ if codes[1:2] == ["ConditionalCheckFailed"]:
1275
+ return "target_changed"
1276
+ if codes[2:3] == ["ConditionalCheckFailed"] and undoes:
1277
+ return "already_undone"
1278
+ raise
1279
+ return "ok"
1280
+
1281
+ def close_reports(self, target: str, now_iso: str) -> int:
1282
+ from boto3.dynamodb.conditions import Key
1283
+
1284
+ table = self._table("report")
1285
+ closed = 0
1286
+ kwargs: dict[str, Any] = {"IndexName": REPORT_TARGET_INDEX, "KeyConditionExpression": Key("target").eq(target)}
1287
+ while True:
1288
+ page = table.query(**kwargs)
1289
+ for row in page.get("Items", []):
1290
+ if row.get("state") == "open":
1291
+ table.update_item(
1292
+ Key={"id": row["id"]},
1293
+ UpdateExpression="SET #s = :closed, closedAt = :now, updatedAt = :now",
1294
+ ExpressionAttributeNames={"#s": "state"},
1295
+ ExpressionAttributeValues={":closed": "closed", ":now": now_iso},
1296
+ )
1297
+ closed += 1
1298
+ if "LastEvaluatedKey" not in page:
1299
+ return closed
1300
+ kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"]
1301
+
1302
+ def _scan(self, table_key: str, **arguments: Any) -> list[dict[str, Any]]:
1303
+ rows: list[dict[str, Any]] = []
1304
+ while True:
1305
+ page = self._table(table_key).scan(**arguments)
1306
+ rows.extend(plain(item) for item in page.get("Items", []))
1307
+ if "LastEvaluatedKey" not in page:
1308
+ return rows
1309
+ arguments["ExclusiveStartKey"] = page["LastEvaluatedKey"]
1310
+
1311
+ def new_content(self) -> list[tuple[str, dict[str, Any]]]:
1312
+ """Submissions and comments waiting for a decision (``moderation: new``). A scan: the queue of a pilot is small."""
1313
+ from boto3.dynamodb.conditions import Attr
1314
+
1315
+ waiting = Attr("moderation").eq(moderation_rules.MODERATION_NEW)
1316
+ return [
1317
+ *((moderation_rules.TARGET_SUBMISSION, row) for row in self._scan("submission", FilterExpression=waiting)),
1318
+ *((moderation_rules.TARGET_COMMENT, row) for row in self._scan("comment", FilterExpression=waiting)),
1319
+ ]
1320
+
1321
+ def human_acted_on(self) -> set[str]:
1322
+ """Targets a person has decided on; the model never judges those again."""
1323
+ from boto3.dynamodb.conditions import Attr
1324
+
1325
+ rows = self._scan("action", FilterExpression=Attr("by").eq(moderation_rules.BY_HUMAN))
1326
+ return {row["target"] for row in rows}
1327
+
1328
+ def count_action(self, reader_id: str, bucket: str, window_start: int, limit: int, expires_at: int) -> bool:
1329
+ try:
1330
+ self._table("rate").update_item(
1331
+ Key={"pk": f"{reader_id}#{bucket}#{window_start}"},
1332
+ UpdateExpression="ADD hits :one SET expiresAt = :expires",
1333
+ ConditionExpression="attribute_not_exists(hits) OR hits < :limit",
1334
+ ExpressionAttributeValues={":one": 1, ":expires": expires_at, ":limit": limit},
1335
+ )
1336
+ except Exception as error:
1337
+ if is_conditional_failure(error):
1338
+ return False
1339
+ raise
1340
+ return True
1341
+
1342
+ def claim_snapshot_slot(self, now_seconds: float, debounce_seconds: int) -> int | None:
1343
+ """Opens a debounce window and returns when it closes, or ``None`` when a window is already open."""
1344
+ locked_until = int(now_seconds) + debounce_seconds
1345
+ try:
1346
+ self._table("rate").put_item(
1347
+ Item={"pk": "snapshot-debounce", "lockedUntil": locked_until, "expiresAt": locked_until + debounce_seconds + HOUR_SECONDS},
1348
+ ConditionExpression="attribute_not_exists(pk) OR lockedUntil <= :now",
1349
+ ExpressionAttributeValues={":now": int(now_seconds)},
1350
+ )
1351
+ except Exception as error:
1352
+ if is_conditional_failure(error):
1353
+ return None
1354
+ raise
1355
+ return locked_until
1356
+
1357
+ def release_snapshot_slot(self, locked_until: int) -> None:
1358
+ """Reopens the window this call opened, so the next write can schedule the run that failed to schedule."""
1359
+ try:
1360
+ self._table("rate").delete_item(
1361
+ Key={"pk": "snapshot-debounce"},
1362
+ ConditionExpression="lockedUntil = :until",
1363
+ ExpressionAttributeValues={":until": locked_until},
1364
+ )
1365
+ except Exception as error:
1366
+ if not is_conditional_failure(error):
1367
+ raise
1368
+
1369
+
1370
+ def snapshot_schedule_name(locked_until: int) -> str:
1371
+ return f"{SNAPSHOT_SCHEDULE_PREFIX}{locked_until}"
1372
+
1373
+
1374
+ def snapshot_scheduler(
1375
+ store: ReaderStore, settings: ReaderSettings, now_seconds: float, target: "SnapshotTarget | None", scheduler_client: Any
1376
+ ) -> Callable[[], None]:
1377
+ """Trailing-edge debounce: the first write in a window schedules one run at the end of the window.
1378
+
1379
+ Later writes in the window schedule nothing, because the run at the end of the window reads them all. A failure never
1380
+ fails the vote; it reopens the window so the next write tries again.
1381
+ """
1382
+
1383
+ def schedule() -> None:
1384
+ if target is None:
1385
+ return
1386
+ locked_until = None
1387
+ try:
1388
+ locked_until = store.claim_snapshot_slot(now_seconds, settings.snapshot_debounce_seconds)
1389
+ if locked_until is None:
1390
+ return
1391
+ scheduler_client.create_schedule(
1392
+ Name=snapshot_schedule_name(locked_until),
1393
+ ScheduleExpression="at(" + time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(locked_until)) + ")",
1394
+ ScheduleExpressionTimezone="UTC",
1395
+ FlexibleTimeWindow={"Mode": "OFF"},
1396
+ ActionAfterCompletion="DELETE",
1397
+ Target={
1398
+ "Arn": target.function_arn,
1399
+ "RoleArn": target.role_arn,
1400
+ "Input": target.task_json,
1401
+ "RetryPolicy": {"MaximumRetryAttempts": 0},
1402
+ },
1403
+ )
1404
+ except Exception as error:
1405
+ print(json.dumps({"readerSnapshotScheduleFailed": type(error).__name__}))
1406
+ if locked_until is not None and type(error).__name__ != "ConflictException":
1407
+ try:
1408
+ store.release_snapshot_slot(locked_until)
1409
+ except Exception as release_error:
1410
+ print(json.dumps({"readerSnapshotReleaseFailed": type(release_error).__name__}))
1411
+
1412
+ return schedule
1413
+
1414
+
1415
+ @dataclass(frozen=True)
1416
+ class SnapshotTarget:
1417
+ function_arn: str
1418
+ role_arn: str
1419
+ task_json: str = json.dumps(SNAPSHOT_TASK)
1420
+
1421
+ def for_ai_review(self, own_function_arn: str) -> "SnapshotTarget":
1422
+ """In AI mode the debounced run is the AI worker (this function), which asks for the snapshot when it has decided."""
1423
+ return SnapshotTarget(own_function_arn, self.role_arn, json.dumps(AI_TASK))
1424
+
1425
+
1426
+ def snapshot_target_from_environment(environment: dict[str, str] | None = None) -> SnapshotTarget | None:
1427
+ environment = os.environ if environment is None else environment
1428
+ function_arn = environment.get("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", "")
1429
+ role_arn = environment.get("PAPYRUS_READER_SCHEDULER_ROLE_ARN", "")
1430
+ return SnapshotTarget(function_arn, role_arn) if function_arn and role_arn else None
1431
+
1432
+
1433
+ def store_from_environment(environment: dict[str, str] | None = None) -> DynamoReaderStore:
1434
+ import boto3
1435
+
1436
+ environment = os.environ if environment is None else environment
1437
+ return DynamoReaderStore(
1438
+ resource=boto3.resource("dynamodb"),
1439
+ profile_table=environment["PAPYRUS_READER_PROFILE_TABLE"],
1440
+ handle_table=environment["PAPYRUS_READER_HANDLE_TABLE"],
1441
+ vote_table=environment["PAPYRUS_READER_VOTE_TABLE"],
1442
+ published_item_table=environment["PAPYRUS_READER_PUBLISHED_ITEM_TABLE"],
1443
+ rate_table=environment["PAPYRUS_READER_RATE_TABLE"],
1444
+ submission_table=environment.get("PAPYRUS_READER_SUBMISSION_TABLE", ""),
1445
+ comment_table=environment.get("PAPYRUS_READER_COMMENT_TABLE", ""),
1446
+ report_table=environment.get("PAPYRUS_READER_REPORT_TABLE", ""),
1447
+ action_table=environment.get("PAPYRUS_READER_ACTION_TABLE", ""),
1448
+ item_table=environment.get("PAPYRUS_READER_ITEM_TABLE", ""),
1449
+ proposal_table=environment.get("PAPYRUS_READER_PROPOSAL_TABLE", ""),
1450
+ flag_table=environment.get("PAPYRUS_READER_FLAG_TABLE", ""),
1451
+ )
1452
+
1453
+
1454
+ def read_api_key(settings: ReaderSettings, ssm_client: Any) -> str:
1455
+ """The model's API key from its SSM SecureString (never Secrets Manager). The caller keeps it out of logs and results."""
1456
+ name = settings.ai_key_parameter
1457
+ if not ai_moderation.PARAMETER_NAME.fullmatch(name):
1458
+ raise ValueError("moderation.ai.apiKeyParameter is not a /papyrus/<site>/openai-api-key name")
1459
+ value = ssm_client.get_parameter(Name=name, WithDecryption=True)["Parameter"]["Value"].strip()
1460
+ if not value:
1461
+ raise ValueError("the API key parameter is empty")
1462
+ return value
1463
+
1464
+
1465
+ def run_ai_worker(
1466
+ *,
1467
+ store: Any,
1468
+ settings: ReaderSettings,
1469
+ now: Callable[[], float],
1470
+ deadline: float,
1471
+ read_key: Callable[[], str],
1472
+ model_factory: Callable[[str, str], Any],
1473
+ invoke_snapshot: Callable[[], None],
1474
+ ) -> dict[str, Any]:
1475
+ """The scheduled AI review: judge new items, then ask for a snapshot so decisions reach the public files.
1476
+
1477
+ Anything that goes wrong is reported by error class name. The snapshot is requested whatever happens, so votes still show.
1478
+ """
1479
+ if settings.moderation_mode != "ai":
1480
+ return {"ok": False, "error": "ai_moderation_off"}
1481
+ result: dict[str, Any]
1482
+ try:
1483
+ model = model_factory(read_key(), settings.ai_model)
1484
+ report = ai_moderation.run_ai_moderation(
1485
+ store,
1486
+ model,
1487
+ now=now,
1488
+ settings=moderation_rules.ModerationSettings(item_type=settings.promote_item_type, section=settings.promote_section),
1489
+ deadline=deadline,
1490
+ iso=iso,
1491
+ )
1492
+ result = {"ok": True, **report.to_dict()}
1493
+ except Exception as error: # noqa: BLE001 - never put the message in the result: it could echo a key or reader text
1494
+ result = {"ok": False, "error": type(error).__name__}
1495
+ try:
1496
+ invoke_snapshot()
1497
+ except Exception as error: # noqa: BLE001
1498
+ result["snapshotError"] = type(error).__name__
1499
+ return result
1500
+
1501
+
1502
+ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float) -> dict[str, Any]:
1503
+ """``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
1504
+ if (event.get("info") or {}).get("fieldName") in proposal_rules.MUTATIONS:
1505
+ return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds))
1506
+ identity = event.get("identity") or {}
1507
+ claims = identity.get("claims") or {}
1508
+ groups = claims.get("cognito:groups") or identity.get("groups") or []
1509
+ if isinstance(groups, str):
1510
+ groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
1511
+ subject = identity.get("sub")
1512
+ if not isinstance(subject, str) or not subject or not moderation_rules.STAFF_GROUPS.intersection(groups):
1513
+ return moderation_rules.refused("not_staff", "Only editors and admins can moderate.").to_dict()
1514
+ arguments = event.get("arguments") or {}
1515
+ outcome = moderation_rules.apply_action(
1516
+ store,
1517
+ action=arguments.get("action"),
1518
+ target_type=arguments.get("targetType"),
1519
+ target=arguments.get("target"),
1520
+ reason=arguments.get("reason"),
1521
+ undo_of=arguments.get("undoOf"),
1522
+ actor_sub=subject,
1523
+ by=moderation_rules.BY_HUMAN,
1524
+ now_iso=iso(now_seconds),
1525
+ settings=moderation_rules.ModerationSettings(item_type=settings.promote_item_type, section=settings.promote_section),
1526
+ )
1527
+ return outcome.to_dict()
1528
+
1529
+
1530
+ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
1531
+ import boto3
1532
+
1533
+ settings = settings_from_environment()
1534
+ store = store_from_environment()
1535
+ now_seconds = time.time()
1536
+ if "arguments" in event and "info" in event:
1537
+ return handle_staff_event(event, store=store, settings=settings, now_seconds=now_seconds)
1538
+ target = snapshot_target_from_environment()
1539
+ if settings.moderation_mode == "ai" and target is not None and _context is not None:
1540
+ if event.get("worker") == AI_WORKER:
1541
+ remaining = _context.get_remaining_time_in_millis() / 1000 - AI_DEADLINE_MARGIN_SECONDS
1542
+ lambda_client = boto3.client("lambda")
1543
+ return run_ai_worker(
1544
+ store=store,
1545
+ settings=settings,
1546
+ now=time.time,
1547
+ deadline=time.time() + max(remaining, 5),
1548
+ read_key=lambda: read_api_key(settings, boto3.client("ssm")),
1549
+ model_factory=lambda key, model: ai_moderation.OpenAIModerationModel(key, model=model),
1550
+ invoke_snapshot=lambda: lambda_client.invoke(FunctionName=target.function_arn, InvocationType="Event", Payload=json.dumps(SNAPSHOT_TASK).encode("utf-8")),
1551
+ )
1552
+ target = target.for_ai_review(_context.invoked_function_arn)
1553
+ scheduler = snapshot_scheduler(store, settings, now_seconds, target, boto3.client("scheduler"))
1554
+ return handle_event(event, store=store, settings=settings, now_seconds=now_seconds, schedule_snapshot=scheduler)