openvibers/openvibe.meme Commits

Publish Meme caption activity for guided quests (#164)

OP OpenVibers committed 2 days ago · d744e07c1e · 8 files, +58 −8

* Publish public caption activity for quests

* Gate caption relay until Events accepts contract

Changes

ddiff --git a/.env.example b/.env.example
iindex 7695b9d..8ba5c39 100644
---- a/.env.example
++++ b/.env.example
@@@ -33,6 +33,8 @@ OV_OAUTH_CLIENT_SECRET=
# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).
MEME_EVENTS_SECRET=
MEME_EVENTS_URL=
++# Relay queued public caption events after Contracts #444 is released and Events accepts the topic.
++MEME_CAPTION_EVENTS_ENABLED=off
#MEME_EVENTS_ENDPOINT=http://127.0.0.1:5120/internal/events
# OpenVibe.Search's owner API: the queued Search documents (meme_outbox) are delivered there. Unset: they wait.
#MEME_SEARCH_URL=http://127.0.0.1:4710
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b96850e..1b04d26 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,8 @@ What changed in OpenVibe.Meme, newest first.
## Unreleased
++- Public caption saves now queue a content-free `meme.caption.created` activity event in the same transaction; hidden and unlisted captions do not emit it. The relay starts when `MEME_CAPTION_EVENTS_ENABLED=on` after Events accepts the contract.
++
- **The maker, second slice.** `/make` is a phone-first editor: template search, draggable and resizable caption
boxes, crop and type/outline controls, emoji stickers, optional signed-out caption ideas from OpenVibe.AI's cheapest
route, and PNG/GIF/MP4 downloads. Captions save their editor state as text and geometry, never as a rendered image.
ddiff --git a/migrations/0012_caption_events.sql b/migrations/0012_caption_events.sql
nnew file mode 100644
iindex 0000000..476970e
---- /dev/null
++++ b/migrations/0012_caption_events.sql
@@@ -0,0 +1,17 @@
++-- phase: expand
++-- Public caption activity reaches Quest through Events after the caption commits.
++CREATE TABLE IF NOT EXISTS event_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text NOT NULL UNIQUE,
++ envelope jsonb NOT NULL,
++ traceparent text,
++ created_at bigint NOT NULL,
++ attempts integer NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ sent_at bigint,
++ seq bigint,
++ rejected_at bigint,
++ last_error text
++);
++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
ddiff --git a/server/app.js b/server/app.js
iindex 763b1d2..8d2598b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -56,6 +56,7 @@ const { createGenerateAi } = require('./generate-ai');
const { createGenerateMedia } = require('./generate-media');
const { createGenerator } = require('./generate');
const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { createServiceOutbox } = require('openvibe-sdk/events');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
@@@ -86,7 +87,11 @@ async function createApp(opts = {}) {
}) : null,
});
const search = createSearchIndex({ config, s, onQueued: () => searchRelay.kick() });
-- const entries = createStore(s, { search });
++ const eventOutbox = createServiceOutbox({ db: s.db, source: 'meme', eventsUrl: config.events.url,
++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret, intervalMs: config.events.intervalMs,
++ now: s.now, fetch: fetchImpl, log, eventTypes: ['meme.caption.created'] });
++ const entries = createStore(s, { search, eventOutbox });
const upstreams = createUpstreams({ config, fetchImpl, log });
const importer = createImporter({ config, s, entries, upstreams, fetchImpl, log });
const reverifier = createReverifier({ config, s, entries, upstreams, log });
@@@ -118,7 +123,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Meme cannot answer account events'); });
const ctx = {
-- config, s, keys, sso, principal, search, searchRelay, entries, upstreams, importer, reverifier, accountData, accountSend, log,
++ config, s, keys, sso, principal, search, searchRelay, eventOutbox, entries, upstreams, importer, reverifier, accountData, accountSend, log,
fetchImpl,
ai: aiRunner, judgeAi: ai, context, judge, judgeJob,
media, trends, reddit, sourceRegistration,
ddiff --git a/server/config.js b/server/config.js
iindex b98e8f2..2e07be4 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -59,6 +59,7 @@ function load(env = process.env) {
endpoint: env.MEME_EVENTS_ENDPOINT || '',
intervalMs: Math.max(500, int(env.MEME_EVENTS_INTERVAL_MS, 5000)),
scope: env.MEME_EVENTS_SCOPE || 'events.event.publish',
++ captionRelayEnabled: env.MEME_CAPTION_EVENTS_ENABLED === 'on',
},
// OpenVibe.Search's owner API: the documents queued in meme_outbox are delivered there (server/app.js,
ddiff --git a/server/index.js b/server/index.js
iindex c293603..d5edf01 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -24,6 +24,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
() => ctx.judgeJob.stop(),
() => ctx.reddit.stop(),
() => ctx.searchRelay.stop(),
++ () => ctx.eventOutbox.stop(),
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
() => ctx.s.close(),
...extra,
@@@ -57,6 +58,7 @@ async function start() {
ctx.reddit.start();
// Queued Search documents → OpenVibe.Search (a tick a few seconds after boot, then every minute).
if (!config.drill) ctx.searchRelay.start();
++ if (!config.drill && config.events.captionRelayEnabled) ctx.eventOutbox.start();
// The Reddit source is created disabled in Sources, once, when the owner asks (MEME_SOURCES_REGISTER=1).
if (!config.drill) ctx.sourceRegistration.ensure().catch(() => {});
ddiff --git a/server/store.js b/server/store.js
iindex 19c51bb..2667fac 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -495,7 +495,7 @@ function captionLines(raw) {
return { ok: true, lines };
}
--function createStore(s, { search }) {
++function createStore(s, { search, eventOutbox = null }) {
const db = s.db;
// Every write function takes an optional transaction handle; without one it runs on the pool and is its own
// statement (no multi-statement atomicity unless the caller opens s.tx()).
@@@ -1233,11 +1233,19 @@ function createStore(s, { search }) {
const tpl = await db.maybe('SELECT id FROM meme_templates WHERE id = $1', [String(templateId || '')]);
if (!tpl) return refusal(404, 'meme.template_not_found', 'No such template.');
const id = s.newId('mcp');
-- const row = await db.maybe(
-- `INSERT INTO meme_captions (id, subject, template_id, lines, created_at, visibility, editor_state)
-- VALUES ($1, $2, $3, $4::jsonb, now(), $5, $6::jsonb) RETURNING *`,
-- [id, subject, tpl.id, JSON.stringify(checked.lines), visibility, JSON.stringify(editor && typeof editor === 'object' ? editor : {})],
-- );
++ const row = await s.tx(async (t) => {
++ const inserted = await t.maybe(
++ `INSERT INTO meme_captions (id, subject, template_id, lines, created_at, visibility, editor_state)
++ VALUES ($1, $2, $3, $4::jsonb, now(), $5, $6::jsonb) RETURNING *`,
++ [id, subject, tpl.id, JSON.stringify(checked.lines), visibility, JSON.stringify(editor && typeof editor === 'object' ? editor : {})],
++ );
++ if (visibility === 'public' && eventOutbox) await eventOutbox.emitIn(t, {
++ event_type: 'meme.caption.created', actor: { type: 'user', id: subject },
++ subject: { type: 'caption', id }, visibility: 'internal',
++ payload: { caption_id: id, owner: { type: 'user', id: subject }, created_at: iso(inserted.created_at) },
++ });
++ return inserted;
++ });
return { ok: true, caption: wireCaption(row) };
}
ddiff --git a/test/captions.test.js b/test/captions.test.js
iindex 85cedc9..d88c640 100644
---- a/test/captions.test.js
++++ b/test/captions.test.js
@@@ -37,6 +37,18 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(r.json().mine, false);
});
++ await check('only public captions queue a content-free activity event', async () => {
++ const rows = await t.ctx.s.db.many('SELECT envelope FROM event_outbox ORDER BY id');
++ assert.strictEqual(rows.length, 2);
++ for (const { envelope } of rows) {
++ assert.strictEqual(envelope.event_type, 'meme.caption.created');
++ assert.deepStrictEqual(envelope.actor, { type: 'user', id: alice.subject });
++ assert.deepStrictEqual(envelope.payload.owner, { type: 'user', id: alice.subject });
++ assert.ok(envelope.payload.caption_id.startsWith('mcp_'));
++ assert.ok(!JSON.stringify(envelope).includes('when the robot'));
++ }
++ });
++
await check('line count, line length, empty lines, blocked words and the template are enforced', async () => {
const tooMany = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['1', '2', '3', '4', '5'] } });
assert.strictEqual(tooMany.status, 400);
@@@ -68,6 +80,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`, { as: alice })).status, 200);
const mod = t.network.addUser('mod', { role: 'global_mod' });
assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`, { as: mod })).status, 200);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM event_outbox')), 2);
});
await check('meme_captions stores no image and no rendered bytes', async () => {