feat: implement webhook workflow engine for mention-to-audio pipeline
This commit is contained in:
150
src/lib/engine.js
Normal file
150
src/lib/engine.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
120
test/engine.test.js
Normal file
120
test/engine.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user