From 3c0584a057608384979ce6ccba039edfea3dc002 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 12:33:29 +0000 Subject: [PATCH] feat: implement webhook workflow engine for mention-to-audio pipeline --- src/lib/engine.js | 150 ++++++++++++++++++++++++++++++++++++++++++++ test/engine.test.js | 120 +++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 src/lib/engine.js create mode 100644 test/engine.test.js diff --git a/src/lib/engine.js b/src/lib/engine.js new file mode 100644 index 0000000..b4bf62e --- /dev/null +++ b/src/lib/engine.js @@ -0,0 +1,150 @@ +"use strict"; + +const { WalletStore } = require("./wallet"); +const { AudioAccessStore } = require("./access"); +const { calculateCredits } = require("./credits"); +const { extractArticleFromParent } = require("./article"); + +class XArtAudioEngine { + constructor({ creditConfig }) { + this.creditConfig = creditConfig; + this.wallets = new WalletStore(); + this.access = new AudioAccessStore(); + + this.jobs = new Map(); + this.assets = new Map(); + this.mentions = new Map(); + + this.nextJobId = 1; + this.nextAssetId = 1; + } + + topUpCredits(userId, amount, idempotencyKey) { + return this.wallets.applyTransaction({ + userId, + type: "credit", + amount, + reason: "topup", + idempotencyKey, + }); + } + + processMention({ mentionPostId, callerUserId, parentPost }) { + if (!mentionPostId) { + throw new Error("mention_post_id_required"); + } + + if (!callerUserId) { + throw new Error("caller_user_id_required"); + } + + const previousJobId = this.mentions.get(mentionPostId); + if (previousJobId) { + return { + ok: true, + deduped: true, + job: this.jobs.get(previousJobId), + }; + } + + const articleResult = extractArticleFromParent(parentPost); + if (!articleResult.ok) { + return { + ok: false, + status: "not_article", + reason: articleResult.error, + message: "This parent post is not an X Article.", + }; + } + + const creditsNeeded = calculateCredits(articleResult.article.charCount, this.creditConfig); + + const jobId = String(this.nextJobId++); + + this.wallets.applyTransaction({ + userId: callerUserId, + type: "debit", + amount: creditsNeeded, + reason: "article_generation", + idempotencyKey: `mention:${mentionPostId}:charge`, + }); + + const assetId = String(this.nextAssetId++); + const audioText = articleResult.article.content; + const fakeAudioBytes = Buffer.byteLength(audioText, "utf8"); + + const asset = { + id: assetId, + ownerUserId: callerUserId, + creditsCharged: creditsNeeded, + articleTitle: articleResult.article.title, + storageKey: `audio/${assetId}.mp3`, + durationSec: Math.max(10, Math.ceil(audioText.length / 20)), + sizeBytes: fakeAudioBytes, + createdAt: new Date().toISOString(), + }; + + const job = { + id: jobId, + mentionPostId, + callerUserId, + status: "completed", + creditsCharged: creditsNeeded, + article: articleResult.article, + assetId, + createdAt: new Date().toISOString(), + }; + + this.assets.set(assetId, asset); + this.jobs.set(jobId, job); + this.mentions.set(mentionPostId, jobId); + + this.access.grantAccess(assetId, callerUserId); + + return { + ok: true, + deduped: false, + job, + reply: { + message: `Your audiobook is ready: /audio/${assetId}`, + publicLink: `/audio/${assetId}`, + }, + }; + } + + getWalletBalance(userId) { + return this.wallets.getBalance(userId); + } + + getJob(jobId) { + return this.jobs.get(jobId) || null; + } + + getAsset(assetId) { + return this.assets.get(String(assetId)) || null; + } + + checkAudioAccess(assetId, userId) { + const audio = this.getAsset(assetId); + if (!audio) { + return { allowed: false, reason: "not_found" }; + } + return this.access.canAccess({ audio, userId }); + } + + unlockAudio(assetId, userId) { + const audio = this.getAsset(assetId); + if (!audio) { + throw new Error("audio_not_found"); + } + return this.access.unlockWithCredits({ + audio, + userId, + wallet: this.wallets, + }); + } +} + +module.exports = { + XArtAudioEngine, +}; diff --git a/test/engine.test.js b/test/engine.test.js new file mode 100644 index 0000000..d25eef2 --- /dev/null +++ b/test/engine.test.js @@ -0,0 +1,120 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { XArtAudioEngine } = require("../src/lib/engine"); + +function createEngine() { + return new XArtAudioEngine({ + creditConfig: { + baseCredits: 1, + includedChars: 25000, + stepChars: 10000, + stepCredits: 1, + maxCharsPerArticle: 120000, + }, + }); +} + +test("returns not_article and does not charge caller", () => { + const engine = createEngine(); + engine.topUpCredits("u1", 5, "topup-1"); + + const result = engine.processMention({ + mentionPostId: "m1", + callerUserId: "u1", + parentPost: { id: "p1", text: "plain post" }, + }); + + assert.equal(result.ok, false); + assert.equal(result.status, "not_article"); + assert.equal(engine.getWalletBalance("u1"), 5); +}); + +test("charges credits and creates completed job for valid article", () => { + const engine = createEngine(); + engine.topUpCredits("u1", 5, "topup-2"); + + const result = engine.processMention({ + mentionPostId: "m2", + callerUserId: "u1", + parentPost: { + id: "p2", + authorId: "author-1", + article: { + id: "art-2", + title: "Long Form", + body: "hello ".repeat(2000), + }, + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.job.status, "completed"); + assert.equal(result.job.creditsCharged, 1); + assert.equal(engine.getWalletBalance("u1"), 4); + + const ownerAccess = engine.checkAudioAccess(result.job.assetId, "u1"); + assert.equal(ownerAccess.allowed, true); +}); + +test("deduplicates repeated mention post ids", () => { + const engine = createEngine(); + engine.topUpCredits("u1", 5, "topup-3"); + + const payload = { + mentionPostId: "m3", + callerUserId: "u1", + parentPost: { + id: "p3", + authorId: "author-2", + article: { + id: "art-3", + title: "Article", + body: "text", + }, + }, + }; + + const first = engine.processMention(payload); + const second = engine.processMention(payload); + + assert.equal(first.ok, true); + assert.equal(second.ok, true); + assert.equal(second.deduped, true); + assert.equal(engine.getWalletBalance("u1"), 4); +}); + +test("non-owner must pay same credits to unlock, then has permanent access", () => { + const engine = createEngine(); + engine.topUpCredits("u1", 5, "topup-4-owner"); + engine.topUpCredits("u2", 5, "topup-4-viewer"); + + const result = engine.processMention({ + mentionPostId: "m4", + callerUserId: "u1", + parentPost: { + id: "p4", + authorId: "author-2", + article: { + id: "art-4", + title: "Article", + body: "content", + }, + }, + }); + + const checkBefore = engine.checkAudioAccess(result.job.assetId, "u2"); + assert.equal(checkBefore.allowed, false); + assert.equal(checkBefore.reason, "payment_required"); + assert.equal(checkBefore.creditsRequired, result.job.creditsCharged); + + engine.unlockAudio(result.job.assetId, "u2"); + assert.equal(engine.getWalletBalance("u2"), 4); + + const checkAfter = engine.checkAudioAccess(result.job.assetId, "u2"); + assert.equal(checkAfter.allowed, true); + + engine.unlockAudio(result.job.assetId, "u2"); + assert.equal(engine.getWalletBalance("u2"), 4); +});