@rocapine/community 0.1.0 → 0.3.0
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.
- package/README.md +5 -0
- package/lib/commands/init.js +5 -2
- package/lib/commands/upgrade.d.ts +4 -0
- package/lib/commands/upgrade.js +34 -9
- package/lib/index.js +2 -0
- package/lib/install-shared.d.ts +9 -5
- package/lib/install-shared.js +19 -5
- package/package.json +1 -1
- package/templates/functions/_shared/client.ts +35 -2
- package/templates/functions/_shared/config.ts +3 -19
- package/templates/functions/_shared/copy.ts +247 -0
- package/templates/functions/_shared/translation.ts +389 -0
- package/templates/functions/broadcast-post/index.ts +28 -29
- package/templates/functions/daily-moderation/index.ts +176 -45
- package/templates/functions/daily-translation/index.ts +120 -0
- package/templates/functions/deno.json +1 -0
- package/templates/functions/moderate-one/index.ts +33 -8
- package/templates/functions/notify-comment/index.ts +45 -20
- package/templates/functions/notify-like/index.ts +19 -5
- package/templates/functions/notify-reaction/index.ts +12 -84
- package/templates/functions/report-to-slack/index.ts +17 -11
- package/templates/functions/translate-one/index.ts +20 -0
- package/templates/migrations/core/007_username_moderation.sql +44 -0
- package/templates/migrations/polls/001_polls.sql +24 -0
- package/templates/migrations/translation/001_translations.sql +190 -0
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
// Daily moderation batch + backstop. Runs every not-yet-moderated post/comment
|
|
2
2
|
// through OpenAI's moderation endpoint (free): flagged -> soft-hide, clean
|
|
3
3
|
// 'pending' -> promote to 'visible' (catches items whose synchronous
|
|
4
|
-
// moderate-one call failed), clean 'visible' -> just stamp.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// moderate-one call failed), clean 'visible' -> just stamp. Also sweeps
|
|
5
|
+
// not-yet-checked usernames (profiles.username is client-writable, see
|
|
6
|
+
// core/007_username_moderation.sql): flagged -> blanked. Posts a Slack
|
|
7
|
+
// summary when there was something to check (no-op if SLACK_WEBHOOK_URL is
|
|
8
|
+
// unset — see _shared/slack.ts). Never deletes rows. App criticism is not a
|
|
9
|
+
// category (stays visible).
|
|
10
|
+
//
|
|
11
|
+
// Reachable with the anon key (the cron calls it that way) and idempotent:
|
|
12
|
+
// once a sweep has stamped its items, a stray call finds nothing to check and
|
|
13
|
+
// exits without an API call or a Slack post.
|
|
7
14
|
|
|
8
15
|
import { adminClient } from "../_shared/client.ts";
|
|
9
16
|
import {
|
|
@@ -37,11 +44,93 @@ function toOne<T>(v: T | T[] | null | undefined): T | null {
|
|
|
37
44
|
return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
/** One OpenAI moderation call for up to 100 inputs; null on any failure so
|
|
48
|
+
* the caller can skip stamping and retry next run. */
|
|
49
|
+
async function moderateBatch(inputs: string[]): Promise<ModerationResult[] | null> {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch("https://api.openai.com/v1/moderations", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({ model: "omni-moderation-latest", input: inputs }),
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok) return null;
|
|
60
|
+
const { results } = await res.json();
|
|
61
|
+
return results as ModerationResult[];
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ProfileItem {
|
|
68
|
+
id: string;
|
|
69
|
+
username: string;
|
|
70
|
+
amplitude_id: string | null;
|
|
71
|
+
revenuecat_id: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Username sweep (core/007): moderate every not-yet-checked username, blank
|
|
76
|
+
* the flagged ones (remembering the value in `username_rejected` so a client
|
|
77
|
+
* re-sync keeps it blank) and stamp the rest. Capped per run so the first
|
|
78
|
+
* sweep after the migration stays within the function's time budget. On an
|
|
79
|
+
* install without core/007 the select errors and the sweep is skipped.
|
|
80
|
+
*/
|
|
81
|
+
async function sweepUsernames(): Promise<{
|
|
82
|
+
checked: number;
|
|
83
|
+
blanked: { profile: ProfileItem; categories: string[] }[];
|
|
84
|
+
failedBatches: number;
|
|
85
|
+
skipped: boolean;
|
|
86
|
+
}> {
|
|
87
|
+
const { data, error } = await supabase
|
|
88
|
+
.from("profiles")
|
|
89
|
+
.select("id, username, amplitude_id, revenuecat_id")
|
|
90
|
+
.not("username", "is", null)
|
|
91
|
+
.is("username_checked_at", null)
|
|
92
|
+
.limit(1000);
|
|
93
|
+
if (error) return { checked: 0, blanked: [], failedBatches: 0, skipped: true };
|
|
94
|
+
const profiles = (data ?? []) as ProfileItem[];
|
|
95
|
+
const blanked: { profile: ProfileItem; categories: string[] }[] = [];
|
|
96
|
+
const processed: ProfileItem[] = [];
|
|
97
|
+
let failedBatches = 0;
|
|
98
|
+
for (let i = 0; i < profiles.length; i += 100) {
|
|
99
|
+
const batch = profiles.slice(i, i + 100);
|
|
100
|
+
const results = await moderateBatch(batch.map((p) => p.username));
|
|
101
|
+
if (!results) {
|
|
102
|
+
failedBatches++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
results.forEach((r, idx) => {
|
|
106
|
+
const cats = flaggedCategories(r);
|
|
107
|
+
if (cats.length > 0) blanked.push({ profile: batch[idx], categories: cats });
|
|
108
|
+
});
|
|
109
|
+
processed.push(...batch);
|
|
110
|
+
}
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
for (const { profile } of blanked) {
|
|
113
|
+
// Guarded on the current value: a name changed since the select is left
|
|
114
|
+
// for the next sweep (the change trigger reset its checked_at anyway).
|
|
115
|
+
await supabase
|
|
116
|
+
.from("profiles")
|
|
117
|
+
.update({ username: null, username_rejected: profile.username, username_checked_at: now })
|
|
118
|
+
.eq("id", profile.id)
|
|
119
|
+
.eq("username", profile.username);
|
|
120
|
+
}
|
|
121
|
+
const blankedIds = new Set(blanked.map((b) => b.profile.id));
|
|
122
|
+
const cleanIds = processed.filter((p) => !blankedIds.has(p.id)).map((p) => p.id);
|
|
123
|
+
if (cleanIds.length > 0) {
|
|
124
|
+
await supabase.from("profiles").update({ username_checked_at: now }).in("id", cleanIds);
|
|
125
|
+
}
|
|
126
|
+
return { checked: processed.length, blanked, failedBatches, skipped: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
40
129
|
Deno.serve(async () => {
|
|
41
130
|
const { data: posts, error: postsError } = await supabase
|
|
42
131
|
.from("posts")
|
|
43
132
|
.select(
|
|
44
|
-
"id, content, author_id, status,
|
|
133
|
+
"id, content, author_id, status, profiles!posts_author_id_fkey(username, amplitude_id, revenuecat_id)",
|
|
45
134
|
)
|
|
46
135
|
.is("moderated_at", null)
|
|
47
136
|
.in("status", ["pending", "visible"]);
|
|
@@ -57,16 +146,34 @@ Deno.serve(async () => {
|
|
|
57
146
|
}
|
|
58
147
|
|
|
59
148
|
// Poll option labels are user content: fold them into the post's moderated
|
|
60
|
-
// text (same treatment as the synchronous moderate-one function).
|
|
149
|
+
// text (same treatment as the synchronous moderate-one function). The polls
|
|
150
|
+
// module is optional: without it the table doesn't exist, the query errors,
|
|
151
|
+
// and posts are moderated on their text alone (a separate query instead of
|
|
152
|
+
// an embed so a core-only install doesn't 400 the whole sweep).
|
|
153
|
+
const optionsByPost = new Map<string, { idx: number; label: string }[]>();
|
|
154
|
+
if (posts && posts.length > 0) {
|
|
155
|
+
const { data: options, error } = await supabase
|
|
156
|
+
.from("poll_options")
|
|
157
|
+
.select("post_id, idx, label")
|
|
158
|
+
.in(
|
|
159
|
+
"post_id",
|
|
160
|
+
posts.map((p) => p.id),
|
|
161
|
+
);
|
|
162
|
+
if (!error) {
|
|
163
|
+
for (const o of options ?? []) {
|
|
164
|
+
const list = optionsByPost.get(o.post_id) ?? [];
|
|
165
|
+
list.push({ idx: o.idx, label: o.label });
|
|
166
|
+
optionsByPost.set(o.post_id, list);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
61
170
|
const items: Item[] = [
|
|
62
171
|
...(posts ?? []).map((p) => ({
|
|
63
172
|
...p,
|
|
64
173
|
profiles: toOne(p.profiles),
|
|
65
174
|
content: [
|
|
66
175
|
p.content,
|
|
67
|
-
...(p.
|
|
68
|
-
.sort((a: { idx: number }, b: { idx: number }) => a.idx - b.idx)
|
|
69
|
-
.map((o: { label: string }) => o.label),
|
|
176
|
+
...(optionsByPost.get(p.id) ?? []).sort((a, b) => a.idx - b.idx).map((o) => o.label),
|
|
70
177
|
].join("\n"),
|
|
71
178
|
table: "posts" as const,
|
|
72
179
|
})),
|
|
@@ -76,9 +183,6 @@ Deno.serve(async () => {
|
|
|
76
183
|
table: "comments" as const,
|
|
77
184
|
})),
|
|
78
185
|
];
|
|
79
|
-
if (items.length === 0) {
|
|
80
|
-
return new Response(JSON.stringify({ checked: 0, hidden: 0, failed_batches: 0 }));
|
|
81
|
-
}
|
|
82
186
|
|
|
83
187
|
// Moderate in batches of 100 (API limit). Only items whose batch succeeded
|
|
84
188
|
// get stamped moderated_at, so a transient failure is retried tomorrow.
|
|
@@ -87,40 +191,29 @@ Deno.serve(async () => {
|
|
|
87
191
|
let failedBatches = 0;
|
|
88
192
|
for (let i = 0; i < items.length; i += 100) {
|
|
89
193
|
const batch = items.slice(i, i + 100);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
|
|
95
|
-
"Content-Type": "application/json",
|
|
96
|
-
},
|
|
97
|
-
body: JSON.stringify({
|
|
98
|
-
model: "omni-moderation-latest",
|
|
99
|
-
input: batch.map((it) => it.content),
|
|
100
|
-
}),
|
|
101
|
-
});
|
|
102
|
-
if (!res.ok) {
|
|
103
|
-
failedBatches++;
|
|
104
|
-
continue; // skip stamping; retried next run
|
|
105
|
-
}
|
|
106
|
-
const { results } = await res.json();
|
|
107
|
-
results.forEach((r: ModerationResult, idx: number) => {
|
|
108
|
-
const cats = flaggedCategories(r);
|
|
109
|
-
if (cats.length > 0) flagged.push({ item: batch[idx], categories: cats });
|
|
110
|
-
});
|
|
111
|
-
processed.push(...batch);
|
|
112
|
-
} catch {
|
|
113
|
-
failedBatches++; // network error; skip stamping, retried next run
|
|
194
|
+
const results = await moderateBatch(batch.map((it) => it.content));
|
|
195
|
+
if (!results) {
|
|
196
|
+
failedBatches++; // API/network error; skip stamping, retried next run
|
|
197
|
+
continue;
|
|
114
198
|
}
|
|
199
|
+
results.forEach((r, idx) => {
|
|
200
|
+
const cats = flaggedCategories(r);
|
|
201
|
+
if (cats.length > 0) flagged.push({ item: batch[idx], categories: cats });
|
|
202
|
+
});
|
|
203
|
+
processed.push(...batch);
|
|
115
204
|
}
|
|
116
205
|
const totalItems = items.length;
|
|
117
206
|
|
|
118
207
|
const flaggedIds = new Set(flagged.map((f) => f.item.id));
|
|
208
|
+
// Status guards: a row the author soft-deleted while the sweep ran must not
|
|
209
|
+
// be resurrected as hidden/visible, so every write re-checks the status it
|
|
210
|
+
// was selected with.
|
|
119
211
|
for (const { item, categories } of flagged) {
|
|
120
212
|
await supabase
|
|
121
213
|
.from(item.table)
|
|
122
214
|
.update({ status: "hidden", moderation_reason: categories.join(",") })
|
|
123
|
-
.eq("id", item.id)
|
|
215
|
+
.eq("id", item.id)
|
|
216
|
+
.in("status", ["pending", "visible"]);
|
|
124
217
|
}
|
|
125
218
|
// Backstop: promote clean 'pending' items to 'visible' (their synchronous
|
|
126
219
|
// moderate-one call must have failed).
|
|
@@ -128,10 +221,18 @@ Deno.serve(async () => {
|
|
|
128
221
|
const promotePostIds = promote.filter((i) => i.table === "posts").map((i) => i.id);
|
|
129
222
|
const promoteCommentIds = promote.filter((i) => i.table === "comments").map((i) => i.id);
|
|
130
223
|
if (promotePostIds.length > 0) {
|
|
131
|
-
await supabase
|
|
224
|
+
await supabase
|
|
225
|
+
.from("posts")
|
|
226
|
+
.update({ status: "visible" })
|
|
227
|
+
.in("id", promotePostIds)
|
|
228
|
+
.eq("status", "pending");
|
|
132
229
|
}
|
|
133
230
|
if (promoteCommentIds.length > 0) {
|
|
134
|
-
await supabase
|
|
231
|
+
await supabase
|
|
232
|
+
.from("comments")
|
|
233
|
+
.update({ status: "visible" })
|
|
234
|
+
.in("id", promoteCommentIds)
|
|
235
|
+
.eq("status", "pending");
|
|
135
236
|
}
|
|
136
237
|
const now = new Date().toISOString();
|
|
137
238
|
const processedPostIds = processed.filter((i) => i.table === "posts").map((i) => i.id);
|
|
@@ -143,6 +244,23 @@ Deno.serve(async () => {
|
|
|
143
244
|
await supabase.from("comments").update({ moderated_at: now }).in("id", processedCommentIds);
|
|
144
245
|
}
|
|
145
246
|
|
|
247
|
+
const usernames = await sweepUsernames();
|
|
248
|
+
const allFailedBatches = failedBatches + usernames.failedBatches;
|
|
249
|
+
|
|
250
|
+
// Quiet when there was nothing to check at all (a stray or duplicate call
|
|
251
|
+
// finds every item already stamped): no Slack noise, no API cost.
|
|
252
|
+
if (totalItems === 0 && usernames.checked === 0 && allFailedBatches === 0) {
|
|
253
|
+
return new Response(
|
|
254
|
+
JSON.stringify({
|
|
255
|
+
checked: 0,
|
|
256
|
+
hidden: 0,
|
|
257
|
+
usernames_checked: 0,
|
|
258
|
+
usernames_blanked: 0,
|
|
259
|
+
failed_batches: 0,
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
146
264
|
const lines = flagged.map(({ item, categories }) => {
|
|
147
265
|
const p = item.profiles;
|
|
148
266
|
return (
|
|
@@ -151,17 +269,28 @@ Deno.serve(async () => {
|
|
|
151
269
|
` amplitude: \`${p?.amplitude_id ?? "?"}\` | revenuecat: \`${p?.revenuecat_id ?? "?"}\``
|
|
152
270
|
);
|
|
153
271
|
});
|
|
272
|
+
const usernameLines = usernames.blanked.map(
|
|
273
|
+
({ profile, categories }) =>
|
|
274
|
+
`* [username] *${profile.username}* blanked, _${categories.join(", ")}_\n` +
|
|
275
|
+
` amplitude: \`${profile.amplitude_id ?? "?"}\` | revenuecat: \`${profile.revenuecat_id ?? "?"}\``,
|
|
276
|
+
);
|
|
277
|
+
const usernameSummary =
|
|
278
|
+
usernames.checked > 0
|
|
279
|
+
? ` Usernames: ${usernames.blanked.length}/${usernames.checked} blanked.`
|
|
280
|
+
: "";
|
|
154
281
|
let summaryText: string;
|
|
155
|
-
if (
|
|
282
|
+
if (allFailedBatches > 0) {
|
|
156
283
|
const uncheckedCount = totalItems - processed.length;
|
|
157
284
|
summaryText =
|
|
158
|
-
`Daily moderation: WARNING, ${
|
|
285
|
+
`Daily moderation: WARNING, ${allFailedBatches} batch(es) failed to reach the moderation API ` +
|
|
159
286
|
`(${uncheckedCount} of ${totalItems} items were not checked and will be retried). ` +
|
|
160
|
-
`${flagged.length} items hidden
|
|
161
|
-
} else if (flagged.length === 0) {
|
|
162
|
-
summaryText = `Daily moderation: ${processed.length} items checked, nothing to report
|
|
287
|
+
`${flagged.length} items hidden.${usernameSummary}`;
|
|
288
|
+
} else if (flagged.length === 0 && usernames.blanked.length === 0) {
|
|
289
|
+
summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.${usernameSummary}`;
|
|
163
290
|
} else {
|
|
164
|
-
summaryText =
|
|
291
|
+
summaryText =
|
|
292
|
+
`Daily moderation: ${flagged.length}/${processed.length} items hidden.${usernameSummary}\n\n` +
|
|
293
|
+
[...lines, ...usernameLines].join("\n");
|
|
165
294
|
}
|
|
166
295
|
await postToSlack({ text: summaryText });
|
|
167
296
|
|
|
@@ -169,7 +298,9 @@ Deno.serve(async () => {
|
|
|
169
298
|
JSON.stringify({
|
|
170
299
|
checked: processed.length,
|
|
171
300
|
hidden: flagged.length,
|
|
172
|
-
|
|
301
|
+
usernames_checked: usernames.checked,
|
|
302
|
+
usernames_blanked: usernames.blanked.length,
|
|
303
|
+
failed_batches: allFailedBatches,
|
|
173
304
|
}),
|
|
174
305
|
);
|
|
175
306
|
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Daily translation sweep: back-fills the history after installation and
|
|
2
|
+
// catches every item translate-one missed (API outage, secret set later, a
|
|
3
|
+
// locale added to COMMUNITY_TRANSLATION_LOCALES). Each invocation runs a
|
|
4
|
+
// bounded time budget at limited concurrency, then self-chains (POSTs itself)
|
|
5
|
+
// to keep going until the backlog is drained or MAX_CHAIN is hit. Quiet
|
|
6
|
+
// unless something failed.
|
|
7
|
+
|
|
8
|
+
import { adminClient, json } from "../_shared/client.ts";
|
|
9
|
+
import { ensureTranslations, runPool, TARGET_LOCALES } from "../_shared/translation.ts";
|
|
10
|
+
import { postToSlack } from "../_shared/slack.ts";
|
|
11
|
+
|
|
12
|
+
const supabase = adminClient();
|
|
13
|
+
// COMMUNITY_TRANSLATION_SWEEP_BATCH: optional test/cost knob (a small value
|
|
14
|
+
// forces chaining on a small backlog).
|
|
15
|
+
const MAX_ITEMS_PER_KIND = Math.max(
|
|
16
|
+
1,
|
|
17
|
+
Number(Deno.env.get("COMMUNITY_TRANSLATION_SWEEP_BATCH")) || 250,
|
|
18
|
+
);
|
|
19
|
+
const CONCURRENCY = 4;
|
|
20
|
+
const TIME_BUDGET_MS = 60_000;
|
|
21
|
+
const MAX_CHAIN = 200;
|
|
22
|
+
|
|
23
|
+
async function sweep(
|
|
24
|
+
kind: "post" | "comment",
|
|
25
|
+
withinBudget: () => boolean,
|
|
26
|
+
): Promise<{ done: number; failed: number; remaining: number }> {
|
|
27
|
+
const { data, error } = await supabase.rpc("items_missing_translations", {
|
|
28
|
+
kind,
|
|
29
|
+
target_locales: TARGET_LOCALES,
|
|
30
|
+
max_items: MAX_ITEMS_PER_KIND,
|
|
31
|
+
});
|
|
32
|
+
if (error) {
|
|
33
|
+
console.error("items_missing_translations failed", error.message);
|
|
34
|
+
return { done: 0, failed: 1, remaining: 0 };
|
|
35
|
+
}
|
|
36
|
+
const ids = ((data ?? []) as { id: string }[]).map((row) => row.id);
|
|
37
|
+
const { results, processed } = await runPool(
|
|
38
|
+
ids,
|
|
39
|
+
CONCURRENCY,
|
|
40
|
+
(id) => ensureTranslations(supabase, kind, id),
|
|
41
|
+
withinBudget,
|
|
42
|
+
);
|
|
43
|
+
const done = results.filter((r) => r !== null).length;
|
|
44
|
+
const failed = processed - done;
|
|
45
|
+
const remaining = Math.max(ids.length - processed, ids.length === MAX_ITEMS_PER_KIND ? 1 : 0);
|
|
46
|
+
return { done, failed, remaining };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Deno.serve(async (req) => {
|
|
50
|
+
if (TARGET_LOCALES.length === 0) {
|
|
51
|
+
return json({
|
|
52
|
+
posts: 0,
|
|
53
|
+
comments: 0,
|
|
54
|
+
failed: 0,
|
|
55
|
+
note: "COMMUNITY_TRANSLATION_LOCALES not set",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
const withinBudget = () => Date.now() - started < TIME_BUDGET_MS;
|
|
60
|
+
|
|
61
|
+
const posts = await sweep("post", withinBudget);
|
|
62
|
+
const comments = await sweep("comment", withinBudget);
|
|
63
|
+
const done = posts.done + comments.done;
|
|
64
|
+
const failed = posts.failed + comments.failed;
|
|
65
|
+
const remaining = posts.remaining + comments.remaining;
|
|
66
|
+
|
|
67
|
+
// The endpoint only needs the public anon key, so the caller controls this
|
|
68
|
+
// header: clamp to a non-negative integer so a malicious/garbage value (a
|
|
69
|
+
// large negative number, NaN, -Infinity) can't bypass MAX_CHAIN.
|
|
70
|
+
const depth = Math.max(0, Math.trunc(Number(req.headers.get("x-community-chain"))) || 0);
|
|
71
|
+
let chained = false;
|
|
72
|
+
// Why no chain was started, reported to Slack when items remain.
|
|
73
|
+
let chainFailure = done > 0 ? "depth cap" : "no progress this run";
|
|
74
|
+
if (remaining > 0 && done > 0 && depth < MAX_CHAIN) {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timeout = setTimeout(() => controller.abort(), 2_000);
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/daily-translation`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: {
|
|
81
|
+
// Forward the caller's own JWT (cron/pg_net sends the anon key the CLI
|
|
82
|
+
// substituted, which passes verify_jwt); the platform's SUPABASE_ANON_KEY
|
|
83
|
+
// env may be a publishable sb_ key that verify_jwt rejects.
|
|
84
|
+
Authorization:
|
|
85
|
+
req.headers.get("authorization") ?? `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
"x-community-chain": String(depth + 1),
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({}),
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
});
|
|
92
|
+
chained = res.ok;
|
|
93
|
+
if (!res.ok) chainFailure = `chain request failed (${res.status})`;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e instanceof DOMException && e.name === "AbortError") {
|
|
96
|
+
// We stopped waiting after 2s; the request was already sent and the
|
|
97
|
+
// chained run continues server-side regardless of our response.
|
|
98
|
+
chained = true;
|
|
99
|
+
} else {
|
|
100
|
+
chainFailure = "chain request failed";
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Only the last link of a chain reports: an item that always fails would
|
|
108
|
+
// otherwise post a warning on every one of up to 200 chained links.
|
|
109
|
+
if (failed > 0 && !chained) {
|
|
110
|
+
await postToSlack({
|
|
111
|
+
text: `Daily translation: WARNING, ${failed} item(s) failed to translate (${posts.done} posts and ${comments.done} comments done, ${remaining} remaining); they will be retried by the next sweep.`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (remaining > 0 && !chained) {
|
|
115
|
+
await postToSlack({
|
|
116
|
+
text: `Translation sweep stopped with ${remaining} items remaining (chain not started: ${chainFailure}); the daily cron continues tomorrow.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return json({ posts: posts.done, comments: comments.done, failed, remaining, chained, depth });
|
|
120
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "compilerOptions": { "lib": ["deno.window"], "strict": true } }
|
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
// promoted to 'visible' (published); flagged -> 'hidden' (rejected) with the
|
|
4
4
|
// reason. On an OpenAI error we fail closed (leave it pending); the daily
|
|
5
5
|
// backstop re-checks stale pending items so nothing is silently lost.
|
|
6
|
+
//
|
|
7
|
+
// Security: the caller must be the row's author (resolved from their user
|
|
8
|
+
// JWT through Auth) or the service role — an anon-key-only call can't
|
|
9
|
+
// moderate someone else's pending item. Every status write is guarded with
|
|
10
|
+
// `status = 'pending'` so a row the author deleted mid-flight is never
|
|
11
|
+
// resurrected.
|
|
6
12
|
|
|
7
|
-
import { adminClient, json } from "../_shared/client.ts";
|
|
13
|
+
import { adminClient, callerUserId, isServiceCaller, json } from "../_shared/client.ts";
|
|
8
14
|
import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
|
|
9
15
|
|
|
10
16
|
assertModerationConfigured();
|
|
@@ -12,7 +18,10 @@ assertModerationConfigured();
|
|
|
12
18
|
const supabase = adminClient();
|
|
13
19
|
|
|
14
20
|
Deno.serve(async (req) => {
|
|
15
|
-
const { kind, id } = (await req.json())
|
|
21
|
+
const { kind, id } = (await req.json().catch(() => ({}))) as {
|
|
22
|
+
kind?: "post" | "comment";
|
|
23
|
+
id?: string;
|
|
24
|
+
};
|
|
16
25
|
if ((kind !== "post" && kind !== "comment") || !id) {
|
|
17
26
|
return json({ status: "error", error: "bad request" }, 400);
|
|
18
27
|
}
|
|
@@ -20,25 +29,33 @@ Deno.serve(async (req) => {
|
|
|
20
29
|
|
|
21
30
|
const { data: row } = await supabase
|
|
22
31
|
.from(table)
|
|
23
|
-
.select("id, content, status")
|
|
32
|
+
.select("id, author_id, content, status")
|
|
24
33
|
.eq("id", id)
|
|
25
34
|
.single();
|
|
26
35
|
if (!row) return json({ status: "error", error: "not found" });
|
|
36
|
+
|
|
37
|
+
if (!isServiceCaller(req)) {
|
|
38
|
+
const uid = await callerUserId(req);
|
|
39
|
+
if (!uid || uid !== row.author_id) return json({ status: "error", error: "forbidden" }, 403);
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
// Idempotent: only pending rows are moderated here.
|
|
28
43
|
if (row.status !== "pending") {
|
|
29
44
|
return json({ status: row.status === "visible" ? "published" : row.status });
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
// A poll post's option labels are user content too: moderate them together
|
|
33
|
-
// with the question, one verdict for the whole post.
|
|
48
|
+
// with the question, one verdict for the whole post. The polls module is
|
|
49
|
+
// optional — on an install without it the table doesn't exist and the
|
|
50
|
+
// query errors, which simply means "no options".
|
|
34
51
|
let input = row.content;
|
|
35
52
|
if (kind === "post") {
|
|
36
|
-
const { data: options } = await supabase
|
|
53
|
+
const { data: options, error } = await supabase
|
|
37
54
|
.from("poll_options")
|
|
38
55
|
.select("label")
|
|
39
56
|
.eq("post_id", id)
|
|
40
57
|
.order("idx");
|
|
41
|
-
if (options && options.length > 0) {
|
|
58
|
+
if (!error && options && options.length > 0) {
|
|
42
59
|
input = [row.content, ...options.map((o) => o.label)].join("\n");
|
|
43
60
|
}
|
|
44
61
|
}
|
|
@@ -54,9 +71,17 @@ Deno.serve(async (req) => {
|
|
|
54
71
|
await supabase
|
|
55
72
|
.from(table)
|
|
56
73
|
.update({ status: "hidden", moderation_reason: verdict.reason, moderated_at: now })
|
|
57
|
-
.eq("id", id)
|
|
74
|
+
.eq("id", id)
|
|
75
|
+
.eq("status", "pending");
|
|
58
76
|
return json({ status: "rejected", reason: verdict.reason });
|
|
59
77
|
}
|
|
60
|
-
|
|
78
|
+
const { data: updated } = await supabase
|
|
79
|
+
.from(table)
|
|
80
|
+
.update({ status: "visible", moderated_at: now })
|
|
81
|
+
.eq("id", id)
|
|
82
|
+
.eq("status", "pending")
|
|
83
|
+
.select("id");
|
|
84
|
+
// Nothing updated: the author deleted (or a sweep moderated) it meanwhile.
|
|
85
|
+
if (!updated || updated.length === 0) return json({ status: "pending" });
|
|
61
86
|
return json({ status: "published" });
|
|
62
87
|
});
|
|
@@ -1,29 +1,36 @@
|
|
|
1
|
-
// Real-time comment notification: a pg_net trigger
|
|
2
|
-
//
|
|
3
|
-
// the author has blocked the commenter).
|
|
1
|
+
// Real-time comment notification: a pg_net trigger fires here when a comment
|
|
2
|
+
// becomes visible (published). We push to the post author (unless self, opted
|
|
3
|
+
// out, no token, or the author has blocked the commenter).
|
|
4
|
+
//
|
|
5
|
+
// Security: this function is reachable with the public anon key (the trigger
|
|
6
|
+
// calls it that way), so the body is UNTRUSTED. Only `record.id` is used; the
|
|
7
|
+
// comment is re-read from the database and must be visible — nothing in the
|
|
8
|
+
// push comes from the request itself.
|
|
4
9
|
|
|
5
10
|
import { adminClient } from "../_shared/client.ts";
|
|
6
11
|
import { sendExpoPush } from "../_shared/push.ts";
|
|
7
|
-
import {
|
|
12
|
+
import { pushCopy } from "../_shared/copy.ts";
|
|
13
|
+
import { ensureTranslations, resolveTargetLocale, languageOf } from "../_shared/translation.ts";
|
|
8
14
|
|
|
9
15
|
const supabase = adminClient();
|
|
10
16
|
|
|
11
|
-
interface CommentRecord {
|
|
12
|
-
id: string;
|
|
13
|
-
post_id: string;
|
|
14
|
-
author_id: string;
|
|
15
|
-
content: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
17
|
Deno.serve(async (req) => {
|
|
19
|
-
const { record } = (await req.json()) as { record
|
|
18
|
+
const { record } = (await req.json().catch(() => ({}))) as { record?: { id?: string } };
|
|
19
|
+
if (!record?.id) return new Response("skip: no id", { status: 400 });
|
|
20
|
+
|
|
21
|
+
const { data: comment } = await supabase
|
|
22
|
+
.from("comments")
|
|
23
|
+
.select("id, post_id, author_id, content, status")
|
|
24
|
+
.eq("id", record.id)
|
|
25
|
+
.single();
|
|
26
|
+
if (!comment || comment.status !== "visible") return new Response("skip: not visible");
|
|
20
27
|
|
|
21
28
|
const { data: post } = await supabase
|
|
22
29
|
.from("posts")
|
|
23
30
|
.select("author_id")
|
|
24
|
-
.eq("id",
|
|
31
|
+
.eq("id", comment.post_id)
|
|
25
32
|
.single();
|
|
26
|
-
if (!post || post.author_id ===
|
|
33
|
+
if (!post || post.author_id === comment.author_id) return new Response("skip: self/none");
|
|
27
34
|
|
|
28
35
|
const { data: pref } = await supabase
|
|
29
36
|
.from("push_tokens")
|
|
@@ -36,25 +43,43 @@ Deno.serve(async (req) => {
|
|
|
36
43
|
.from("blocks")
|
|
37
44
|
.select("blocker_id")
|
|
38
45
|
.eq("blocker_id", post.author_id)
|
|
39
|
-
.eq("blocked_id",
|
|
46
|
+
.eq("blocked_id", comment.author_id)
|
|
40
47
|
.maybeSingle();
|
|
41
48
|
if (blocked) return new Response("skip: blocked");
|
|
42
49
|
|
|
50
|
+
const { data: recipient } = await supabase
|
|
51
|
+
.from("profiles")
|
|
52
|
+
.select("locale")
|
|
53
|
+
.eq("id", post.author_id)
|
|
54
|
+
.single();
|
|
55
|
+
const copy = pushCopy(recipient?.locale);
|
|
56
|
+
|
|
43
57
|
const { data: actor } = await supabase
|
|
44
58
|
.from("profiles")
|
|
45
59
|
.select("username")
|
|
46
|
-
.eq("id",
|
|
60
|
+
.eq("id", comment.author_id)
|
|
47
61
|
.single();
|
|
48
|
-
const name = actor?.username?.trim() ||
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
const name = actor?.username?.trim() || copy.fallbackName;
|
|
63
|
+
// Recipient-language excerpt when the translation module is on: translate
|
|
64
|
+
// now (a few seconds) rather than push the source language; on failure the
|
|
65
|
+
// original goes out and the sweep fills the rows later.
|
|
66
|
+
let excerptSource = comment.content;
|
|
67
|
+
const targetLocale = resolveTargetLocale(recipient?.locale);
|
|
68
|
+
if (targetLocale) {
|
|
69
|
+
const rows = await ensureTranslations(supabase, "comment", comment.id);
|
|
70
|
+
const match = rows?.find(
|
|
71
|
+
(r) => r.locale === targetLocale && languageOf(r.locale) !== r.source_locale,
|
|
72
|
+
);
|
|
73
|
+
if (match) excerptSource = match.content;
|
|
74
|
+
}
|
|
75
|
+
const excerpt = excerptSource.length > 140 ? `${excerptSource.slice(0, 137)}...` : excerptSource;
|
|
51
76
|
// Title states the event; body is the commenter's name then the comment in
|
|
52
77
|
// quotes, e.g. Marie: "So happy for you!".
|
|
53
78
|
const body = `${name}: "${excerpt}"`;
|
|
54
79
|
|
|
55
80
|
await sendExpoPush({
|
|
56
81
|
to: pref.expo_push_token,
|
|
57
|
-
title: "
|
|
82
|
+
title: copy.text("comment.title"),
|
|
58
83
|
body,
|
|
59
84
|
data: { route: "/community", kind: "community_comment" },
|
|
60
85
|
badge: 1,
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
// Coalesced like notifications. Trigger path (body has record): apply a 10-min
|
|
2
2
|
// per-post cooldown so bursts do not spam. Digest path (no record, from cron):
|
|
3
3
|
// sweep posts with unnotified likes older than the cooldown. Either way, group
|
|
4
|
-
// as "<name> liked your post" (1) or "<name> and N others liked your post"
|
|
4
|
+
// as "<name> liked your post" (1) or "<name> and N others liked your post",
|
|
5
|
+
// in the recipient's locale (profiles.locale) — see _shared/copy.ts for the
|
|
6
|
+
// built-in copy and the COMMUNITY_PUSH_COPY override.
|
|
7
|
+
//
|
|
8
|
+
// Security: the trigger path only takes `record.post_id` from the (anon-key
|
|
9
|
+
// reachable, hence untrusted) body and re-derives everything from the likes
|
|
10
|
+
// table, so a forged body can at most flush a real post's pending like
|
|
11
|
+
// notifications early.
|
|
5
12
|
|
|
6
13
|
import { adminClient } from "../_shared/client.ts";
|
|
7
14
|
import { sendExpoPush } from "../_shared/push.ts";
|
|
8
|
-
import {
|
|
15
|
+
import { pushCopy } from "../_shared/copy.ts";
|
|
9
16
|
|
|
10
17
|
const supabase = adminClient();
|
|
11
18
|
const COOLDOWN_MS = 10 * 60 * 1000;
|
|
@@ -52,16 +59,23 @@ async function flushPost(postId: string) {
|
|
|
52
59
|
);
|
|
53
60
|
if (contributors.length === 0) return;
|
|
54
61
|
|
|
62
|
+
const { data: recipient } = await supabase
|
|
63
|
+
.from("profiles")
|
|
64
|
+
.select("locale")
|
|
65
|
+
.eq("id", post.author_id)
|
|
66
|
+
.single();
|
|
67
|
+
const copy = pushCopy(recipient?.locale);
|
|
68
|
+
|
|
55
69
|
const { data: actor } = await supabase
|
|
56
70
|
.from("profiles")
|
|
57
71
|
.select("username")
|
|
58
72
|
.eq("id", contributors[0].user_id)
|
|
59
73
|
.single();
|
|
60
|
-
const name = actor?.username?.trim() ||
|
|
74
|
+
const name = actor?.username?.trim() || copy.fallbackName;
|
|
61
75
|
const title =
|
|
62
76
|
contributors.length === 1
|
|
63
|
-
?
|
|
64
|
-
:
|
|
77
|
+
? copy.text("like.one", { name })
|
|
78
|
+
: copy.text("like.many", { name, count: contributors.length - 1 });
|
|
65
79
|
|
|
66
80
|
await sendExpoPush({
|
|
67
81
|
to: pref.expo_push_token,
|