feat: implement webhook workflow engine for mention-to-audio pipeline

This commit is contained in:
Codex
2026-02-18 12:33:29 +00:00
parent b678735238
commit 3c0584a057
2 changed files with 270 additions and 0 deletions

150
src/lib/engine.js Normal file
View 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,
};