diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 773131e..e1c1081 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -123,6 +123,7 @@ const app = new Hono<{ Bindings: CloudflareBindings }>() raw = await p.fetch(id, { EMBED_USER_AGENT: c.env.EMBED_USER_AGENT, FACEBOOK_MARKETPLACE_COOKIE: c.env.FACEBOOK_MARKETPLACE_COOKIE, + TRUTH_SOCIAL_ACCESS_TOKEN: c.env.TRUTH_SOCIAL_ACCESS_TOKEN, }); } catch (cause) { const errorContext = getErrorContext(cause); diff --git a/apps/api/worker-configuration.d.ts b/apps/api/worker-configuration.d.ts index e9f5eac..c8a502b 100644 --- a/apps/api/worker-configuration.d.ts +++ b/apps/api/worker-configuration.d.ts @@ -6,6 +6,7 @@ interface __BaseEnv_CloudflareBindings { AUTH_SECRET: string; EMBED_USER_AGENT: string; FACEBOOK_MARKETPLACE_COOKIE?: string; + TRUTH_SOCIAL_ACCESS_TOKEN?: string; OTEL_ENDPOINT: string; } declare namespace Cloudflare { diff --git a/apps/bot/src/lib/builder.ts b/apps/bot/src/lib/builder.ts index 41dc558..8cf76c1 100644 --- a/apps/bot/src/lib/builder.ts +++ b/apps/bot/src/lib/builder.ts @@ -70,6 +70,7 @@ function buildMediaEmbed(media: NormalizedPost["media"], spoiler?: EmbedFlags["S } function addPostComponents(embed: ContainerBuilder, post: PostData, headingPrefix?: string) { + const platformName = post.platform === "TruthSocial" ? "Truth Social" : post.platform; const poll = post.platform === "Twitter" ? post.poll : undefined; const translation = post.platform === "Twitter" && @@ -171,7 +172,7 @@ function addPostComponents(embed: ContainerBuilder, post: PostData, headingPrefi ), (footer) => footer.setContent( - `${getEmojiByName(post.platform)} • ${time(post.timestamp, TimestampStyles.LongDateShortTime)} • ${hyperlink(`View on ${post.platform}`, post.url)}`, + `${getEmojiByName(post.platform)} • ${time(post.timestamp, TimestampStyles.LongDateShortTime)} • ${hyperlink(`View on ${platformName}`, post.url)}`, ), ); } diff --git a/packages/platforms/src/platforms/index.ts b/packages/platforms/src/platforms/index.ts index 019dee1..6db5c06 100644 --- a/packages/platforms/src/platforms/index.ts +++ b/packages/platforms/src/platforms/index.ts @@ -4,3 +4,4 @@ export { Instagram } from "./instagram"; export { TikTok } from "./tiktok"; export { Threads } from "./threads"; export { FacebookMarketplace } from "./facebook-marketplace"; +export { TruthSocial } from "./truth-social"; diff --git a/packages/platforms/src/platforms/truth-social.d.ts b/packages/platforms/src/platforms/truth-social.d.ts new file mode 100644 index 0000000..e4bcfba --- /dev/null +++ b/packages/platforms/src/platforms/truth-social.d.ts @@ -0,0 +1,27 @@ +export interface TruthSocialAccount { + display_name: string; + username: string; + url: string; + avatar: string; +} + +export interface TruthSocialMedia { + type: string; + url: string; + preview_url?: string; + description?: string; +} + +export interface TruthSocialStatus { + id: string; + created_at: string; + url: string; + content: string; + account: TruthSocialAccount; + replies_count: number; + reblogs_count: number; + favourites_count: number; + media_attachments: TruthSocialMedia[]; + quote?: TruthSocialStatus | null; + in_reply_to?: TruthSocialStatus | null; +} diff --git a/packages/platforms/src/platforms/truth-social.ts b/packages/platforms/src/platforms/truth-social.ts new file mode 100644 index 0000000..180c37a --- /dev/null +++ b/packages/platforms/src/platforms/truth-social.ts @@ -0,0 +1,81 @@ +import he from "he"; + +import type { Platform } from "../types"; +import type { TruthSocialStatus } from "./truth-social.d"; + +const MATCH_RE = + /^(?:https?:\/\/)?(?:www\.)?truthsocial\.com\/@[^/?]+\/(?:posts\/)?(\d+)\/?(?:[?#].*)?$/; +const MAX_CONTEXT_DEPTH = 1; + +function stripHtml(html: string) { + return he.decode( + html + .replace(//gi, "\n") + .replace(/<\/p>\s*

/gi, "\n\n") + .replace(/<[^>]+>/g, ""), + ); +} + +export const TruthSocial: Platform<"TruthSocial", TruthSocialStatus, {}> = { + type: "TruthSocial", + async match(url) { + return url.match(MATCH_RE)?.[1] ?? null; + }, + async fetch(id, env) { + if (!/^\d+$/.test(id)) { + throw { code: 400, message: "Invalid Truth Social status ID" }; + } + if (!env?.TRUTH_SOCIAL_ACCESS_TOKEN) { + throw { code: 500, message: "TRUTH_SOCIAL_ACCESS_TOKEN is required" }; + } + + const response = await fetch(`https://truthsocial.com/api/v1/statuses/${id}`, { + headers: { + Authorization: `Bearer ${env.TRUTH_SOCIAL_ACCESS_TOKEN}`, + Accept: "application/json", + "User-Agent": env.EMBED_USER_AGENT, + }, + }); + + if (!response.ok) throw { code: response.status, message: response.statusText }; + + // SAFETY: Truth Social's status endpoint follows its observed Mastodon-compatible shape. + return (await response.json()) as TruthSocialStatus; + }, + async transform(raw, options) { + const depth = options?.depth ?? 0; + const includeContext = depth < MAX_CONTEXT_DEPTH; + const text = stripHtml(raw.content); + + return { + platform: this.type, + author: { + name: raw.account.display_name, + handle: raw.account.username, + url: raw.account.url, + avatar: raw.account.avatar, + }, + url: raw.url, + text: text || undefined, + timestamp: Math.floor(Date.parse(raw.created_at) / 1000), + stats: { + comments: raw.replies_count, + reposts: raw.reblogs_count, + likes: raw.favourites_count, + }, + media: raw.media_attachments.map((media) => ({ + url: media.url, + type: media.type, + description: media.description, + })), + quote: + includeContext && raw.quote + ? await this.transform(raw.quote, { depth: depth + 1 }) + : undefined, + reply_to: + includeContext && raw.in_reply_to + ? await this.transform(raw.in_reply_to, { depth: depth + 1 }) + : undefined, + }; + }, +} as const; diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 1fe9a31..de46482 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -30,6 +30,7 @@ interface TransformOptions { interface FetchEnv { EMBED_USER_AGENT: string; FACEBOOK_MARKETPLACE_COOKIE?: string; + TRUTH_SOCIAL_ACCESS_TOKEN?: string; REDDIT_CLIENT_ID?: string; REDDIT_CLIENT_SECRET?: string; }