diff --git a/src/io/channels/social-posting/XquikSocialPostingService.ts b/src/io/channels/social-posting/XquikSocialPostingService.ts new file mode 100644 index 00000000000..37703a23bb7 --- /dev/null +++ b/src/io/channels/social-posting/XquikSocialPostingService.ts @@ -0,0 +1,169 @@ +import type { SocialPost, SocialPostPlatformResult } from "./SocialPostManager"; +import { + SocialAbstractService, + type SocialRequestOptions, + type SocialServiceConfig, +} from "./SocialAbstractService"; + +export interface XquikSocialPostingConfig extends SocialServiceConfig { + apiKey: string; + account: string; + baseUrl?: string; + platform?: string; +} + +export interface XquikPublishInput { + text: string; + account?: string; + attachmentUrl?: string; + communityId?: string; + isNoteTweet?: boolean; + mediaUrls?: string[]; + replyToTweetId?: string; +} + +interface XquikCreateTweetBody { + account: string; + text?: string; + attachment_url?: string; + community_id?: string; + is_note_tweet?: boolean; + media?: string[]; + reply_to_tweet_id?: string; +} + +interface XquikCreateTweetSuccess { + success: true; + tweetId: string; + writeActionId?: string; +} + +interface XquikCreateTweetPending { + error: "x_write_unconfirmed"; + status: "pending_confirmation"; + writeActionId: string; +} + +type XquikCreateTweetResponse = + | XquikCreateTweetSuccess + | XquikCreateTweetPending; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isXquikCreateTweetSuccess = ( + response: unknown, +): response is XquikCreateTweetSuccess => + isRecord(response) && + response.success === true && + typeof response.tweetId === "string"; + +const isXquikCreateTweetPending = ( + response: unknown, +): response is XquikCreateTweetPending => + isRecord(response) && + response.error === "x_write_unconfirmed" && + response.status === "pending_confirmation" && + typeof response.writeActionId === "string"; + +export class XquikSocialPostingService extends SocialAbstractService { + private readonly account: string; + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly platform: string; + + constructor(config: XquikSocialPostingConfig) { + super(config); + this.account = config.account; + this.apiKey = config.apiKey; + this.baseUrl = (config.baseUrl ?? "https://xquik.com").replace(/\/+$/, ""); + this.platform = config.platform ?? "twitter"; + } + + async publish( + input: XquikPublishInput, + options: SocialRequestOptions = {}, + ): Promise { + const response = await this.fetchJson( + `${this.baseUrl}/api/v1/x/tweets`, + { + body: JSON.stringify(this.createRequestBody(input)), + headers: { + "content-type": "application/json", + "x-api-key": this.apiKey, + }, + method: "POST", + }, + options, + ); + + if (isXquikCreateTweetSuccess(response)) { + return { + platform: this.platform, + postId: response.tweetId, + publishedAt: new Date().toISOString(), + status: "success", + url: `https://x.com/i/web/status/${response.tweetId}`, + }; + } + + if (!isXquikCreateTweetPending(response)) { + throw new Error("Unexpected Xquik create tweet response."); + } + + return { + platform: this.platform, + status: "pending", + }; + } + + publishPost( + post: SocialPost, + options: SocialRequestOptions = {}, + ): Promise { + const input: XquikPublishInput = { + text: + post.adaptations[this.platform] ?? + post.adaptations.twitter ?? + post.baseContent, + }; + + if (post.mediaUrls) { + input.mediaUrls = [...post.mediaUrls]; + } + + return this.publish(input, options); + } + + private createRequestBody(input: XquikPublishInput): XquikCreateTweetBody { + const body: XquikCreateTweetBody = { + account: input.account ?? this.account, + }; + + if (input.text) { + body.text = input.text; + } + + if (input.mediaUrls?.length) { + body.media = [...input.mediaUrls]; + } + + if (input.replyToTweetId) { + body.reply_to_tweet_id = input.replyToTweetId; + } + + if (input.attachmentUrl) { + body.attachment_url = input.attachmentUrl; + } + + if (input.communityId) { + body.community_id = input.communityId; + } + + if (input.isNoteTweet !== undefined) { + body.is_note_tweet = input.isNoteTweet; + } + + return body; + } +} diff --git a/src/io/channels/social-posting/index.ts b/src/io/channels/social-posting/index.ts index c42e13c7da8..c44990bd85e 100644 --- a/src/io/channels/social-posting/index.ts +++ b/src/io/channels/social-posting/index.ts @@ -14,18 +14,24 @@ export { type SocialPostStatus, type SocialPostPlatformResult, type CreateDraftInput, -} from './SocialPostManager'; +} from "./SocialPostManager"; // Platform-specific content adaptation export { ContentAdaptationEngine, type PlatformConstraints, type AdaptedContent, -} from './ContentAdaptationEngine'; +} from "./ContentAdaptationEngine"; // Shared HTTP base class for channel service implementations export { SocialAbstractService, type SocialRequestOptions, type SocialServiceConfig, -} from './SocialAbstractService'; +} from "./SocialAbstractService"; + +export { + XquikSocialPostingService, + type XquikPublishInput, + type XquikSocialPostingConfig, +} from "./XquikSocialPostingService"; diff --git a/tests/social-posting/XquikSocialPostingService.spec.ts b/tests/social-posting/XquikSocialPostingService.spec.ts new file mode 100644 index 00000000000..f9ab8aec403 --- /dev/null +++ b/tests/social-posting/XquikSocialPostingService.spec.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { XquikSocialPostingService } from "../../src/io/channels/social-posting/XquikSocialPostingService.js"; +import type { SocialPost } from "../../src/io/channels/social-posting/SocialPostManager.js"; + +describe("XquikSocialPostingService", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("publishes a tweet through the Xquik create tweet endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, tweetId: "12345" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + }); + const result = await service.publish({ text: "hello" }); + + expect(result).toMatchObject({ + platform: "twitter", + postId: "12345", + status: "success", + url: "https://x.com/i/web/status/12345", + }); + expect(result.publishedAt).toEqual(expect.any(String)); + expect(Number.isNaN(Date.parse(result.publishedAt ?? ""))).toBe(false); + + const call = fetchMock.mock.calls[0]; + expect(call?.[0]).toBe("https://xquik.com/api/v1/x/tweets"); + + const init = call?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(new Headers(init.headers).get("x-api-key")).toBe("test-key"); + expect(init.body).toBe( + JSON.stringify({ account: "@agent", text: "hello" }), + ); + }); + + it("maps pending confirmation responses to a pending platform result", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: "x_write_unconfirmed", + status: "pending_confirmation", + writeActionId: "42", + }), + { + headers: { "content-type": "application/json" }, + status: 202, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + platform: "x", + }); + const result = await service.publish({ + mediaUrls: ["https://example.com/image.png"], + text: "", + }); + + expect(result).toEqual({ platform: "x", status: "pending" }); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.body).toBe( + JSON.stringify({ + account: "@agent", + media: ["https://example.com/image.png"], + }), + ); + }); + + it("maps optional Xquik request fields to the create tweet body", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, tweetId: "67890" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + }); + + await service.publish({ + account: "@override", + attachmentUrl: "https://example.com/card", + communityId: "community-1", + isNoteTweet: true, + mediaUrls: ["https://example.com/image.png"], + replyToTweetId: "tweet-1", + text: "hello", + }); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.body).toBe( + JSON.stringify({ + account: "@override", + text: "hello", + media: ["https://example.com/image.png"], + reply_to_tweet_id: "tweet-1", + attachment_url: "https://example.com/card", + community_id: "community-1", + is_note_tweet: true, + }), + ); + }); + + it("rejects unexpected Xquik create tweet responses", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("accepted", { + headers: { "content-type": "text/plain" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + }); + + await expect(service.publish({ text: "hello" })).rejects.toThrow( + "Unexpected Xquik create tweet response.", + ); + }); + + it("publishes the adapted platform content from a social post", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, tweetId: "67890" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + platform: "x", + }); + const post = { + adaptations: { twitter: "fallback text", x: "adapted x text" }, + baseContent: "base text", + createdAt: "2026-01-01T00:00:00.000Z", + id: "post-1", + platforms: ["x"], + results: { x: { platform: "x", status: "pending" } }, + retryCount: 0, + maxRetries: 3, + seedId: "seed-1", + status: "publishing", + updatedAt: "2026-01-01T00:00:00.000Z", + } satisfies SocialPost; + + await service.publishPost(post); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.body).toBe( + JSON.stringify({ account: "@agent", text: "adapted x text" }), + ); + }); + + it("falls back from platform adaptation to twitter adaptation and base content", async () => { + const fetchMock = vi.fn().mockImplementation(() => + new Response(JSON.stringify({ success: true, tweetId: "67890" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + platform: "x", + }); + const postWithTwitterFallback = { + adaptations: { twitter: "fallback text" }, + baseContent: "base text", + createdAt: "2026-01-01T00:00:00.000Z", + id: "post-2", + platforms: ["x"], + results: { x: { platform: "x", status: "pending" } }, + retryCount: 0, + maxRetries: 3, + seedId: "seed-2", + status: "publishing", + updatedAt: "2026-01-01T00:00:00.000Z", + } satisfies SocialPost; + const postWithBaseFallback = { + ...postWithTwitterFallback, + adaptations: {}, + baseContent: "base only", + id: "post-3", + seedId: "seed-3", + } satisfies SocialPost; + + await service.publishPost(postWithTwitterFallback); + await service.publishPost(postWithBaseFallback); + + const twitterFallbackInit = fetchMock.mock.calls[0]?.[1] as RequestInit; + const baseFallbackInit = fetchMock.mock.calls[1]?.[1] as RequestInit; + expect(twitterFallbackInit.body).toBe( + JSON.stringify({ account: "@agent", text: "fallback text" }), + ); + expect(baseFallbackInit.body).toBe( + JSON.stringify({ account: "@agent", text: "base only" }), + ); + }); +});