openvibers/openvibe.meme Commits
Publish Meme caption activity for guided quests (#164)
Changes
@@@ -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@@@ -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 captionboxes, crop and type/outline controls, emoji stickers, optional signed-out caption ideas from OpenVibe.AI's cheapestroute, and PNG/GIF/MP4 downloads. Captions save their editor state as text and geometry, never as a rendered image.nnew file mode 100644@@@ -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;@@@ -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,@@@ -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,@@@ -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(() => {});@@@ -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) };}@@@ -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 () => {
@@ -33,6 +33,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -33,6 +33,8 @@ OV_OAUTH_CLIENT_SECRET=
# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).
# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).
MEME_EVENTS_SECRET=
MEME_EVENTS_SECRET=
MEME_EVENTS_URL=
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
#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.
# 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
#MEME_SEARCH_URL=http://127.0.0.1:4710
@@ -4,6 +4,8 @@ What changed in OpenVibe.Meme, newest first.
@@ -4,6 +4,8 @@ What changed in OpenVibe.Meme, newest first.
## Unreleased
## 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
- **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
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.
route, and PNG/GIF/MP4 downloads. Captions save their editor state as text and geometry, never as a rendered image.
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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;
@@ -56,6 +56,7 @@ const { createGenerateAi } = require('./generate-ai');
@@ -56,6 +56,7 @@ const { createGenerateAi } = require('./generate-ai');
const { createGenerateMedia } = require('./generate-media');
const { createGenerateMedia } = require('./generate-media');
const { createGenerator } = require('./generate');
const { createGenerator } = require('./generate');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { createServiceOutbox } = require('openvibe-sdk/events');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
@@ -86,7 +87,11 @@ async function createApp(opts = {}) {
@@ -86,7 +87,11 @@ async function createApp(opts = {}) {
}) : null,
}) : null,
});
});
const search = createSearchIndex({ config, s, onQueued: () => searchRelay.kick() });
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 upstreams = createUpstreams({ config, fetchImpl, log });
const importer = createImporter({ config, s, entries, upstreams, fetchImpl, log });
const importer = createImporter({ config, s, entries, upstreams, fetchImpl, log });
const reverifier = createReverifier({ config, s, entries, upstreams, log });
const reverifier = createReverifier({ config, s, entries, upstreams, log });
@@ -118,7 +123,7 @@ async function createApp(opts = {}) {
@@ -118,7 +123,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Meme cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Meme cannot answer account events'); });
const ctx = {
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,
fetchImpl,
ai: aiRunner, judgeAi: ai, context, judge, judgeJob,
ai: aiRunner, judgeAi: ai, context, judge, judgeJob,
media, trends, reddit, sourceRegistration,
media, trends, reddit, sourceRegistration,
@@ -59,6 +59,7 @@ function load(env = process.env) {
@@ -59,6 +59,7 @@ function load(env = process.env) {
endpoint: env.MEME_EVENTS_ENDPOINT || '',
endpoint: env.MEME_EVENTS_ENDPOINT || '',
intervalMs: Math.max(500, int(env.MEME_EVENTS_INTERVAL_MS, 5000)),
intervalMs: Math.max(500, int(env.MEME_EVENTS_INTERVAL_MS, 5000)),
scope: env.MEME_EVENTS_SCOPE || 'events.event.publish',
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,
// OpenVibe.Search's owner API: the documents queued in meme_outbox are delivered there (server/app.js,
@@ -24,6 +24,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
@@ -24,6 +24,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
() => ctx.judgeJob.stop(),
() => ctx.judgeJob.stop(),
() => ctx.reddit.stop(),
() => ctx.reddit.stop(),
() => ctx.searchRelay.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(); },
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
() => ctx.s.close(),
() => ctx.s.close(),
...extra,
...extra,
@@ -57,6 +58,7 @@ async function start() {
@@ -57,6 +58,7 @@ async function start() {
ctx.reddit.start();
ctx.reddit.start();
// Queued Search documents → OpenVibe.Search (a tick a few seconds after boot, then every minute).
// Queued Search documents → OpenVibe.Search (a tick a few seconds after boot, then every minute).
if (!config.drill) ctx.searchRelay.start();
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).
// The Reddit source is created disabled in Sources, once, when the owner asks (MEME_SOURCES_REGISTER=1).
if (!config.drill) ctx.sourceRegistration.ensure().catch(() => {});
if (!config.drill) ctx.sourceRegistration.ensure().catch(() => {});
@@ -495,7 +495,7 @@ function captionLines(raw) {
@@ -495,7 +495,7 @@ function captionLines(raw) {
return { ok: true, lines };
return { ok: true, lines };
}
}
-function createStore(s, { search }) {
+function createStore(s, { search, eventOutbox = null }) {
const db = s.db;
const db = s.db;
// Every write function takes an optional transaction handle; without one it runs on the pool and is its own
// 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()).
// statement (no multi-statement atomicity unless the caller opens s.tx()).
@@ -1233,11 +1233,19 @@ function createStore(s, { search }) {
@@ -1233,11 +1233,19 @@ function createStore(s, { search }) {
const tpl = await db.maybe('SELECT id FROM meme_templates WHERE id = $1', [String(templateId || '')]);
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.');
if (!tpl) return refusal(404, 'meme.template_not_found', 'No such template.');
const id = s.newId('mcp');
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) };
return { ok: true, caption: wireCaption(row) };
}
}
@@ -37,6 +37,18 @@ const { boot, check, done } = require('./helpers/boot');
@@ -37,6 +37,18 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(r.json().mine, false);
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 () => {
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'] } });
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);
assert.strictEqual(tooMany.status, 400);
@@ -68,6 +80,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -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);
assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`, { as: alice })).status, 200);
const mod = t.network.addUser('mod', { role: 'global_mod' });
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((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 () => {
await check('meme_captions stores no image and no rendered bytes', async () => {