From d68ccc70bf1a30617922c9a5ab71f12d6a34df40 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 13:35:03 +0000 Subject: [PATCH] feat: add polar x tts and storage integration adapters with tests --- src/integrations/polar.js | 134 ++++++++++++++++++++++++ src/integrations/storage-client.js | 71 +++++++++++++ src/integrations/tts-client.js | 47 +++++++++ src/integrations/x-client.js | 79 ++++++++++++++ src/services/audio-generation.js | 66 ++++++++++++ test/audio-generation.test.js | 59 +++++++++++ test/polar-integration.test.js | 71 +++++++++++++ test/storage-client-integration.test.js | 39 +++++++ test/tts-client-integration.test.js | 32 ++++++ test/x-client-integration.test.js | 80 ++++++++++++++ 10 files changed, 678 insertions(+) create mode 100644 src/integrations/polar.js create mode 100644 src/integrations/storage-client.js create mode 100644 src/integrations/tts-client.js create mode 100644 src/integrations/x-client.js create mode 100644 src/services/audio-generation.js create mode 100644 test/audio-generation.test.js create mode 100644 test/polar-integration.test.js create mode 100644 test/storage-client-integration.test.js create mode 100644 test/tts-client-integration.test.js create mode 100644 test/x-client-integration.test.js diff --git a/src/integrations/polar.js b/src/integrations/polar.js new file mode 100644 index 0000000..6b9c393 --- /dev/null +++ b/src/integrations/polar.js @@ -0,0 +1,134 @@ +"use strict"; + +const { Polar } = require("@polar-sh/sdk"); +const { validateEvent, WebhookVerificationError } = require("@polar-sh/sdk/webhooks"); + +function hasStandardWebhookHeaders(headers) { + return Boolean( + headers + && headers["webhook-id"] + && headers["webhook-timestamp"] + && headers["webhook-signature"], + ); +} + +function normalizeTopUpPayload(payload) { + if (!payload || typeof payload !== "object") { + throw new Error("invalid_polar_payload"); + } + + if (payload.userId && payload.credits && payload.eventId) { + const credits = Number.parseInt(String(payload.credits), 10); + if (!Number.isInteger(credits) || credits <= 0) { + throw new Error("invalid_credit_amount"); + } + + return { + userId: String(payload.userId), + credits, + eventId: String(payload.eventId), + }; + } + + if (payload.type && payload.data && payload.data.metadata) { + const metadata = payload.data.metadata; + const userId = metadata.xartaudio_user_id || metadata.user_id || payload.data.externalCustomerId; + const creditsRaw = metadata.xartaudio_credits || metadata.credits; + const eventId = payload.data.id || payload.id; + const credits = Number.parseInt(String(creditsRaw || ""), 10); + + if (!userId || !eventId || !Number.isInteger(credits) || credits <= 0) { + throw new Error("invalid_polar_metadata_for_topup"); + } + + return { + userId: String(userId), + credits, + eventId: String(eventId), + }; + } + + throw new Error("unsupported_polar_payload"); +} + +function createPolarAdapter({ + accessToken, + server = "production", + productIds = [], + webhookSecret, + sdk, +} = {}) { + const polarSdk = sdk || (accessToken ? new Polar({ accessToken, server }) : null); + const configuredProductIds = Array.isArray(productIds) + ? productIds.filter(Boolean) + : []; + + return { + isConfigured() { + return Boolean(polarSdk && configuredProductIds.length > 0); + }, + + async createCheckoutSession({ + userId, + successUrl, + returnUrl, + metadata, + customerEmail, + }) { + if (!polarSdk) { + throw new Error("polar_not_configured"); + } + + if (!userId) { + throw new Error("user_id_required"); + } + + if (configuredProductIds.length === 0) { + throw new Error("polar_product_ids_required"); + } + + const checkout = await polarSdk.checkouts.create({ + products: configuredProductIds, + externalCustomerId: String(userId), + successUrl, + returnUrl, + customerEmail: customerEmail || undefined, + metadata: metadata || undefined, + }); + + return { + id: checkout.id, + url: checkout.url, + }; + }, + + parseWebhookEvent(rawBody, headers) { + if (!webhookSecret) { + throw new Error("polar_webhook_secret_required"); + } + + if (!hasStandardWebhookHeaders(headers)) { + throw new Error("polar_standard_webhook_headers_missing"); + } + + try { + return validateEvent(rawBody, headers, webhookSecret); + } catch (error) { + if (error instanceof WebhookVerificationError) { + throw new Error("invalid_polar_webhook_signature", { cause: error }); + } + throw error; + } + }, + + extractTopUp(payloadOrEvent) { + return normalizeTopUpPayload(payloadOrEvent); + }, + }; +} + +module.exports = { + createPolarAdapter, + normalizeTopUpPayload, + hasStandardWebhookHeaders, +}; diff --git a/src/integrations/storage-client.js b/src/integrations/storage-client.js new file mode 100644 index 0000000..6b87a3b --- /dev/null +++ b/src/integrations/storage-client.js @@ -0,0 +1,71 @@ +"use strict"; + +const { S3Client, PutObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); + +function createStorageAdapter({ + bucket, + region, + endpoint, + accessKeyId, + secretAccessKey, + signedUrlTtlSec = 3600, + client, + signedUrlFactory, +} = {}) { + const s3 = client || (bucket && region && accessKeyId && secretAccessKey + ? new S3Client({ + region, + endpoint: endpoint || undefined, + forcePathStyle: Boolean(endpoint), + credentials: { + accessKeyId, + secretAccessKey, + }, + }) + : null); + + const sign = signedUrlFactory || getSignedUrl; + + return { + isConfigured() { + return Boolean(s3 && bucket); + }, + + async uploadAudio({ key, body, contentType = "audio/mpeg" }) { + if (!s3 || !bucket) { + throw new Error("storage_not_configured"); + } + + if (!key || !body) { + throw new Error("storage_upload_payload_required"); + } + + await s3.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: contentType, + })); + + return { + bucket, + key, + }; + }, + + async getSignedDownloadUrl(key, ttlSec) { + if (!s3 || !bucket) { + throw new Error("storage_not_configured"); + } + + const expiresIn = Number.isInteger(ttlSec) && ttlSec > 0 ? ttlSec : signedUrlTtlSec; + const command = new GetObjectCommand({ Bucket: bucket, Key: key }); + return sign(s3, command, { expiresIn }); + }, + }; +} + +module.exports = { + createStorageAdapter, +}; diff --git a/src/integrations/tts-client.js b/src/integrations/tts-client.js new file mode 100644 index 0000000..72f3585 --- /dev/null +++ b/src/integrations/tts-client.js @@ -0,0 +1,47 @@ +"use strict"; + +const { OpenAI } = require("openai"); + +function createTTSAdapter({ + apiKey, + baseURL, + model = "gpt-4o-mini-tts", + voice = "alloy", + format = "mp3", + client, +} = {}) { + const openai = client || (apiKey ? new OpenAI({ apiKey, baseURL: baseURL || undefined }) : null); + + return { + isConfigured() { + return Boolean(openai); + }, + + async synthesize(text, options) { + if (!openai) { + throw new Error("tts_not_configured"); + } + + if (!text || !String(text).trim()) { + throw new Error("tts_text_required"); + } + + const effectiveModel = options && options.model ? options.model : model; + const effectiveVoice = options && options.voice ? options.voice : voice; + + const response = await openai.audio.speech.create({ + model: effectiveModel, + voice: effectiveVoice, + input: String(text), + format, + }); + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + }, + }; +} + +module.exports = { + createTTSAdapter, +}; diff --git a/src/integrations/x-client.js b/src/integrations/x-client.js new file mode 100644 index 0000000..b4dc25c --- /dev/null +++ b/src/integrations/x-client.js @@ -0,0 +1,79 @@ +"use strict"; + +const { TwitterApi } = require("twitter-api-v2"); + +function findParentReplyId(mentionTweet) { + const refs = mentionTweet && Array.isArray(mentionTweet.referenced_tweets) + ? mentionTweet.referenced_tweets + : []; + + const reply = refs.find((ref) => ref && ref.type === "replied_to"); + return reply ? reply.id : null; +} + +function createXAdapter({ + bearerToken, + botUserId, + client, +} = {}) { + const apiClient = client || (bearerToken ? new TwitterApi(bearerToken) : null); + + return { + isConfigured() { + return Boolean(apiClient && botUserId); + }, + + async listMentions({ sinceId, maxResults = 10 } = {}) { + if (!apiClient || !botUserId) { + throw new Error("x_api_not_configured"); + } + + const timeline = await apiClient.v2.userMentionTimeline(botUserId, { + since_id: sinceId, + max_results: maxResults, + expansions: ["referenced_tweets.id"], + "tweet.fields": ["author_id", "created_at", "referenced_tweets", "article"], + }); + + return timeline && timeline.data ? timeline.data : []; + }, + + async fetchParentPostFromMention(mentionTweetId) { + if (!apiClient) { + throw new Error("x_api_not_configured"); + } + + const mention = await apiClient.v2.singleTweet(mentionTweetId, { + "tweet.fields": ["author_id", "referenced_tweets"], + }); + + const parentId = findParentReplyId(mention && mention.data ? mention.data : mention); + if (!parentId) { + return null; + } + + const parent = await apiClient.v2.singleTweet(parentId, { + "tweet.fields": ["author_id", "created_at", "article"], + }); + + return parent && parent.data ? parent.data : parent; + }, + + async replyToMention({ mentionTweetId, text }) { + if (!apiClient) { + throw new Error("x_api_not_configured"); + } + + if (!mentionTweetId || !text) { + throw new Error("reply_payload_required"); + } + + return apiClient.v2.reply(text, mentionTweetId); + }, + }; +} + +module.exports = { + createXAdapter, + findParentReplyId, +}; diff --git a/src/services/audio-generation.js b/src/services/audio-generation.js new file mode 100644 index 0000000..16b4534 --- /dev/null +++ b/src/services/audio-generation.js @@ -0,0 +1,66 @@ +"use strict"; + +const PQueue = require("p-queue").default; + +function createAudioGenerationService({ + tts, + storage, + logger = console, + concurrency = 2, +}) { + const queue = new PQueue({ concurrency }); + + return { + isConfigured() { + const ttsConfigured = typeof tts.isConfigured === "function" ? tts.isConfigured() : true; + const storageConfigured = typeof storage.isConfigured === "function" ? storage.isConfigured() : true; + return Boolean( + tts + && storage + && ttsConfigured + && storageConfigured + && typeof tts.synthesize === "function" + && typeof storage.uploadAudio === "function", + ); + }, + + enqueueJob({ assetId, text, onCompleted, onFailed }) { + if (!assetId || !text) { + throw new Error("audio_generation_payload_required"); + } + + return queue.add(async () => { + try { + const audioBytes = await tts.synthesize(text); + const key = `audio/${assetId}.mp3`; + await storage.uploadAudio({ key, body: audioBytes, contentType: "audio/mpeg" }); + if (onCompleted) { + onCompleted({ + storageKey: key, + sizeBytes: audioBytes.length, + }); + } + + return { + storageKey: key, + sizeBytes: audioBytes.length, + }; + } catch (error) { + logger.error({ err: error, assetId }, "audio generation failed"); + if (onFailed) { + onFailed(error); + } + throw error; + } + }); + }, + + async onIdle() { + await queue.onIdle(); + }, + }; +} + +module.exports = { + createAudioGenerationService, +}; diff --git a/test/audio-generation.test.js b/test/audio-generation.test.js new file mode 100644 index 0000000..e93d113 --- /dev/null +++ b/test/audio-generation.test.js @@ -0,0 +1,59 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createAudioGenerationService } = require("../src/services/audio-generation"); + +test("enqueueJob generates audio and uploads it", async () => { + const uploaded = []; + const service = createAudioGenerationService({ + tts: { + async synthesize() { + return Buffer.from("bytes"); + }, + }, + storage: { + async uploadAudio(payload) { + uploaded.push(payload); + }, + }, + logger: { error() {} }, + }); + + const result = await service.enqueueJob({ + assetId: "a1", + text: "hello", + }); + + assert.equal(result.storageKey, "audio/a1.mp3"); + assert.equal(uploaded.length, 1); + assert.equal(uploaded[0].key, "audio/a1.mp3"); +}); + +test("enqueueJob invokes onFailed on error", async () => { + let failed = false; + const service = createAudioGenerationService({ + tts: { + async synthesize() { + throw new Error("tts_fail"); + }, + }, + storage: { + async uploadAudio() {}, + }, + logger: { error() {} }, + }); + + await assert.rejects( + () => service.enqueueJob({ + assetId: "a2", + text: "hello", + onFailed() { + failed = true; + }, + }), + /tts_fail/, + ); + + assert.equal(failed, true); +}); diff --git a/test/polar-integration.test.js b/test/polar-integration.test.js new file mode 100644 index 0000000..07fb679 --- /dev/null +++ b/test/polar-integration.test.js @@ -0,0 +1,71 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + createPolarAdapter, + normalizeTopUpPayload, + hasStandardWebhookHeaders, +} = require("../src/integrations/polar"); + +test("detects standard polar webhook headers", () => { + const ok = hasStandardWebhookHeaders({ + "webhook-id": "a", + "webhook-timestamp": "b", + "webhook-signature": "c", + }); + + assert.equal(ok, true); +}); + +test("normalizeTopUpPayload supports legacy simple webhook shape", () => { + const parsed = normalizeTopUpPayload({ userId: "u1", credits: "12", eventId: "evt1" }); + assert.equal(parsed.userId, "u1"); + assert.equal(parsed.credits, 12); + assert.equal(parsed.eventId, "evt1"); +}); + +test("normalizeTopUpPayload supports metadata-based order event shape", () => { + const parsed = normalizeTopUpPayload({ + type: "order.paid", + data: { + id: "ord_1", + metadata: { + xartaudio_user_id: "u2", + xartaudio_credits: "20", + }, + }, + }); + + assert.equal(parsed.userId, "u2"); + assert.equal(parsed.credits, 20); + assert.equal(parsed.eventId, "ord_1"); +}); + +test("createCheckoutSession calls polar sdk with configured products", async () => { + const calls = []; + const adapter = createPolarAdapter({ + productIds: ["prod_1"], + sdk: { + checkouts: { + async create(payload) { + calls.push(payload); + return { id: "chk_1", url: "https://polar.sh/checkout/chk_1" }; + }, + }, + }, + }); + + const checkout = await adapter.createCheckoutSession({ + userId: "u1", + successUrl: "https://app/success", + returnUrl: "https://app/return", + metadata: { xartaudio_user_id: "u1", xartaudio_credits: "50" }, + }); + + assert.equal(checkout.id, "chk_1"); + assert.equal(checkout.url, "https://polar.sh/checkout/chk_1"); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].products, ["prod_1"]); + assert.equal(calls[0].externalCustomerId, "u1"); +}); diff --git a/test/storage-client-integration.test.js b/test/storage-client-integration.test.js new file mode 100644 index 0000000..6040dbf --- /dev/null +++ b/test/storage-client-integration.test.js @@ -0,0 +1,39 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createStorageAdapter } = require("../src/integrations/storage-client"); + +test("uploadAudio sends put command", async () => { + const sent = []; + const adapter = createStorageAdapter({ + bucket: "b1", + client: { + async send(command) { + sent.push(command.input); + }, + }, + signedUrlFactory: async () => "https://signed.example", + }); + + const res = await adapter.uploadAudio({ + key: "audio/1.mp3", + body: Buffer.from("abc"), + }); + + assert.equal(res.bucket, "b1"); + assert.equal(sent.length, 1); + assert.equal(sent[0].Bucket, "b1"); + assert.equal(sent[0].Key, "audio/1.mp3"); +}); + +test("getSignedDownloadUrl uses provided signer", async () => { + const adapter = createStorageAdapter({ + bucket: "b1", + client: { async send() {} }, + signedUrlFactory: async (_client, command, options) => `signed:${command.input.Key}:${options.expiresIn}`, + }); + + const url = await adapter.getSignedDownloadUrl("audio/2.mp3", 120); + assert.equal(url, "signed:audio/2.mp3:120"); +}); diff --git a/test/tts-client-integration.test.js b/test/tts-client-integration.test.js new file mode 100644 index 0000000..1ef7db9 --- /dev/null +++ b/test/tts-client-integration.test.js @@ -0,0 +1,32 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createTTSAdapter } = require("../src/integrations/tts-client"); + +test("synthesize returns buffer from openai response", async () => { + const adapter = createTTSAdapter({ + client: { + audio: { + speech: { + async create() { + return { + async arrayBuffer() { + return Uint8Array.from([1, 2, 3]).buffer; + }, + }; + }, + }, + }, + }, + }); + + const bytes = await adapter.synthesize("hello"); + assert.equal(Buffer.isBuffer(bytes), true); + assert.equal(bytes.length, 3); +}); + +test("throws when tts adapter is not configured", async () => { + const adapter = createTTSAdapter({}); + await assert.rejects(() => adapter.synthesize("hello"), /tts_not_configured/); +}); diff --git a/test/x-client-integration.test.js b/test/x-client-integration.test.js new file mode 100644 index 0000000..19f6010 --- /dev/null +++ b/test/x-client-integration.test.js @@ -0,0 +1,80 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createXAdapter, findParentReplyId } = require("../src/integrations/x-client"); + +test("findParentReplyId returns replied_to reference id", () => { + const id = findParentReplyId({ + referenced_tweets: [ + { type: "quoted", id: "q1" }, + { type: "replied_to", id: "p1" }, + ], + }); + + assert.equal(id, "p1"); +}); + +test("listMentions fetches mention timeline", async () => { + const calls = []; + const adapter = createXAdapter({ + botUserId: "bot-1", + client: { + v2: { + async userMentionTimeline(userId, opts) { + calls.push({ userId, opts }); + return { data: [{ id: "m1" }] }; + }, + }, + }, + }); + + const mentions = await adapter.listMentions({ sinceId: "100" }); + assert.equal(mentions.length, 1); + assert.equal(calls[0].userId, "bot-1"); + assert.equal(calls[0].opts.since_id, "100"); +}); + +test("fetchParentPostFromMention resolves parent tweet", async () => { + const adapter = createXAdapter({ + botUserId: "bot-1", + client: { + v2: { + async singleTweet(id) { + if (id === "mention-1") { + return { + data: { + id, + referenced_tweets: [{ type: "replied_to", id: "parent-1" }], + }, + }; + } + return { data: { id, article: { title: "A", body: "B" } } }; + }, + }, + }, + }); + + const parent = await adapter.fetchParentPostFromMention("mention-1"); + assert.equal(parent.id, "parent-1"); + assert.equal(parent.article.title, "A"); +}); + +test("replyToMention posts a reply", async () => { + const calls = []; + const adapter = createXAdapter({ + botUserId: "bot-1", + client: { + v2: { + async reply(text, mentionId) { + calls.push({ text, mentionId }); + return { data: { id: "reply-1" } }; + }, + }, + }, + }); + + const result = await adapter.replyToMention({ mentionTweetId: "m1", text: "done" }); + assert.equal(result.data.id, "reply-1"); + assert.equal(calls[0].mentionId, "m1"); +});