Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions apps/bot/src/lib/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ export interface EmbedFlags {

type PostData = Awaited<ReturnType<(typeof Platforms)[keyof typeof Platforms]["transform"]>>;

async function resolveMedia(media: NormalizedPost["media"]) {
const resolved = [];
for (const item of media) {
if (item.type !== "video" || !item.url.includes("tiktok")) {
resolved.push(item);
continue;
}

const response = await fetch(item.url, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

resolveMedia performs a network fetch for each TikTok candidate inside buildEmbed but does not catch fetch/cancel exceptions, so transient network failures (DNS/TLS/socket reset) throw out of embed construction and fail the request instead of degrading gracefully. This turns a best-effort media URL refinement step into a hard failure path; wrap the probe in try/catch and continue with the original item (or skip it) on error.

// apps/bot/src/lib/builder.ts
const response = await fetch(item.url, {
  method: "GET",
  redirect: "follow",
  headers: {

method: "GET",
redirect: "follow",
headers: {
Range: "bytes=0-0",
"User-Agent": "Discordbot/2.0",
},
});
await response.body?.cancel();
if (!response.ok || !response.headers.get("Content-Type")?.startsWith("video/")) continue;
resolved.push({ ...item, url: response.url });
}
return resolved;
}

function buildMediaEmbed(media: NormalizedPost["media"], spoiler?: EmbedFlags["Spoiler"]) {
if (media.length === 0) return null;

Expand All @@ -45,7 +68,7 @@ function buildMediaEmbed(media: NormalizedPost["media"], spoiler?: EmbedFlags["S
return gallery.toJSON();
}

function addPostComponents(embed: ContainerBuilder, post: PostData, headingPrefix?: string) {
async function addPostComponents(embed: ContainerBuilder, post: PostData, headingPrefix?: string) {
const translation =
post.platform === "Twitter" &&
post.translation &&
Expand Down Expand Up @@ -93,8 +116,9 @@ function addPostComponents(embed: ContainerBuilder, post: PostData, headingPrefi
);
}
}
if (post.media.length > 0) {
embed.addMediaGalleryComponents(buildMediaEmbed(post.media)!);
const media = await resolveMedia(post.media);
if (media.length > 0) {
embed.addMediaGalleryComponents(buildMediaEmbed(media)!);
}
embed
.addSeparatorComponents((sep) => sep.setDivider(false).setSpacing(SeparatorSpacingSize.Small))
Expand All @@ -114,9 +138,9 @@ function addPostComponents(embed: ContainerBuilder, post: PostData, headingPrefi
);
}

export function buildEmbed(post: PostData, flags?: Partial<EmbedFlags>) {
export async function buildEmbed(post: PostData, flags?: Partial<EmbedFlags>) {
if (flags?.MediaOnly) {
return buildMediaEmbed(post.media, flags?.Spoiler);
return buildMediaEmbed(await resolveMedia(post.media), flags?.Spoiler);
}

const embed = new ContainerBuilder();
Expand All @@ -126,18 +150,18 @@ export function buildEmbed(post: PostData, flags?: Partial<EmbedFlags>) {
}

if (flags?.SourceOnly) {
addPostComponents(embed, post);
await addPostComponents(embed, post);
return embed.toJSON();
}

if (post.reply_to) {
addPostComponents(embed, post.reply_to);
await addPostComponents(embed, post.reply_to);
embed.addSeparatorComponents((sep) =>
sep.setDivider(true).setSpacing(SeparatorSpacingSize.Large),
);
}

addPostComponents(
await addPostComponents(
embed,
post,
post.reply_to ? getEmojiByName("reply") : post.quote ? getEmojiByName("quote") : undefined,
Expand All @@ -147,7 +171,7 @@ export function buildEmbed(post: PostData, flags?: Partial<EmbedFlags>) {
embed.addSeparatorComponents((sep) =>
sep.setDivider(true).setSpacing(SeparatorSpacingSize.Large),
);
addPostComponents(embed, post.quote);
await addPostComponents(embed, post.quote);
}

return embed.toJSON();
Expand Down
9 changes: 6 additions & 3 deletions packages/platforms/src/platforms/tiktok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ const FOLLOWUP_RE =

async function parseMedia(raw: Record<string, any>): Promise<NormalizedPost["media"]> {
if (raw.video) {
const urls = raw.video.PlayAddrStruct?.UrlList ?? [];
const videoURL = urls.find((url: string) => url.includes("/aweme/v1/play/")) ?? urls[0];
if (videoURL) return [{ url: videoURL, type: "video" }];
const urls = [
raw.video.playAddr,
raw.video.downloadAddr,
...(raw.video.PlayAddrStruct?.UrlList ?? []),
].filter((url) => typeof url === "string" && url.length > 0);
return [...new Set(urls)].map((url) => ({ url, type: "video" }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The transform now maps every candidate video URL into a distinct media item, but downstream rendering treats NormalizedPost.media as gallery items, not fallback URLs for a single asset. When multiple candidates return playable video, one TikTok post can render duplicate/alternate copies instead of a single resolved video URL; keep candidates as fallback metadata and collapse to one selected URL before emitting gallery media.

// packages/platforms/src/platforms/tiktok.ts
const urls = [
  raw.video.playAddr,
  raw.video.downloadAddr,
  ...(raw.video.PlayAddrStruct?.UrlList ?? []),
];
return [...new Set(urls)].map((url) => ({ url, type: "video" }));

}
return [];
}
Expand Down
Loading