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; }