initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers';
export class AudioResolver extends BaseResolver {
match(_url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('audio/');
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: Buffer.from(content).toString('base64'),
isNSFWAllowed,
});
return [
{
type: 'audio',
url: url.href,
audio: buildEmbedMediaPayload(url.href, metadata),
},
];
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import type {IMediaService} from '~/infrastructure/IMediaService';
import {Logger} from '~/Logger';
import {URLType} from '~/Schema';
import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers';
export abstract class BaseResolver {
constructor(protected mediaService: IMediaService) {}
abstract match(url: URL, mimeType: string, content: Uint8Array): boolean;
abstract resolve(url: URL, content: Uint8Array, isNSFWAllowed?: boolean): Promise<Array<MessageEmbedResponse>>;
protected resolveRelativeURL(baseUrl: string, relativeUrl?: string): string | null {
if (!relativeUrl) {
return null;
}
try {
return new URL(relativeUrl, baseUrl).href;
} catch (error) {
Logger.error({error}, 'Failed to resolve relative URL');
return relativeUrl;
}
}
protected async resolveMediaURL(
url: URL,
mediaUrl?: string | null,
isNSFWAllowed: boolean = false,
): Promise<MessageEmbedResponse['image']> {
if (!mediaUrl) {
return null;
}
const resolvedUrl = this.resolveRelativeURL(url.href, mediaUrl);
if (resolvedUrl && URLType.safeParse(resolvedUrl).success) {
try {
const metadata = await this.mediaService.getMetadata({
type: 'external',
url: resolvedUrl,
isNSFWAllowed,
});
return buildEmbedMediaPayload(resolvedUrl, metadata);
} catch (error) {
Logger.error({error}, 'Failed to resolve media URL metadata');
return null;
}
}
return null;
}
}

View File

@@ -0,0 +1,182 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import {Logger} from '~/Logger';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import {BlueskyApiClient} from './bluesky/BlueskyApiClient';
import {BlueskyEmbedProcessor} from './bluesky/BlueskyEmbedProcessor';
import {BlueskyTextFormatter} from './bluesky/BlueskyTextFormatter';
export class BlueskyResolver extends BaseResolver {
private static readonly BLUESKY_COLOR = 0x1185fe;
private static readonly BLUESKY_ICON = 'https://bsky.app/static/apple-touch-icon.png';
private static readonly PATH_SEPARATOR = '/';
private static readonly COUNTER_DISPLAY_THRESHOLD = 100;
private apiClient: BlueskyApiClient;
private textFormatter: BlueskyTextFormatter;
private embedProcessor: BlueskyEmbedProcessor;
constructor(cacheService: ICacheService, mediaService: IMediaService) {
super(mediaService);
this.apiClient = new BlueskyApiClient(cacheService);
this.textFormatter = new BlueskyTextFormatter();
this.embedProcessor = new BlueskyEmbedProcessor(mediaService, this.apiClient, this.textFormatter);
}
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
const isMatch = url.hostname === 'bsky.app' && mimeType.startsWith('text/html');
Logger.debug({url: url.toString(), mimeType, isMatch}, 'BlueskyResolver match check');
return isMatch;
}
async resolve(url: URL, _content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
try {
Logger.debug({url: url.toString()}, 'Starting URL resolution');
if (this.isPostUrl(url)) {
Logger.debug({url: url.toString()}, 'Resolving post URL');
const atUri = await this.getAtUri(url);
if (!atUri) return [];
const thread = await this.apiClient.fetchPost(atUri);
if (!thread) return [];
const {post} = thread.thread;
const {image, thumbnail, video, quotedText, galleryImages} = await this.embedProcessor.processPostEmbed(
post,
isNSFWAllowed,
);
let processedText = this.textFormatter.formatPostContent(post, thread);
if (quotedText) processedText += `\n\n${quotedText}`;
Logger.debug(
{
url: url.toString(),
embedType: post.embed?.$type,
hasImage: !!image,
hasThumbnail: !!thumbnail,
hasVideo: !!video,
hasImageAltText: !!image?.description,
isReply: !!post.record.reply,
replyCount: post.replyCount,
repostCount: post.repostCount,
likeCount: post.likeCount,
quoteCount: post.quoteCount,
},
'Processed post embeds',
);
const fields: Array<{name: string; value: string; inline: boolean}> = [];
if (post.replyCount > BlueskyResolver.COUNTER_DISPLAY_THRESHOLD)
fields.push({name: 'Replies', value: post.replyCount.toString(), inline: true});
if (post.repostCount > BlueskyResolver.COUNTER_DISPLAY_THRESHOLD)
fields.push({name: 'Reposts', value: post.repostCount.toString(), inline: true});
if (post.likeCount > BlueskyResolver.COUNTER_DISPLAY_THRESHOLD)
fields.push({name: 'Likes', value: post.likeCount.toString(), inline: true});
if (post.quoteCount > BlueskyResolver.COUNTER_DISPLAY_THRESHOLD)
fields.push({name: 'Quotes', value: post.quoteCount.toString(), inline: true});
const embed: MessageEmbedResponse = {
type: 'rich',
url: url.href,
description: processedText,
color: BlueskyResolver.BLUESKY_COLOR,
timestamp: new Date(post.record.createdAt).toISOString(),
author: {
name: `${post.author.displayName || post.author.handle} (@${post.author.handle})`,
url: `https://bsky.app/profile/${post.author.handle}`,
icon_url: post.author.avatar,
},
...(image ? {image} : {}),
...(video ? {thumbnail, video} : {}),
fields,
footer: {text: 'Bluesky', icon_url: BlueskyResolver.BLUESKY_ICON},
};
const galleryEmbeds =
galleryImages?.map((galleryImage) => ({
type: 'rich',
url: url.href,
image: galleryImage,
})) ?? [];
return [embed, ...galleryEmbeds];
}
if (this.isProfileUrl(url)) {
Logger.debug({url: url.toString()}, 'Resolving profile URL');
const handle = this.parsePathParts(url)[1];
const profile = await this.apiClient.fetchProfile(handle);
if (!profile) return [];
const embed: MessageEmbedResponse = {
type: 'rich',
url: url.href,
title: profile.displayName ? `${profile.displayName} (@${profile.handle})` : `@${profile.handle}`,
description: profile.description,
color: BlueskyResolver.BLUESKY_COLOR,
footer: {text: 'Bluesky', icon_url: BlueskyResolver.BLUESKY_ICON},
};
return [embed];
}
Logger.debug({url: url.toString()}, 'URL does not match any supported patterns');
return [];
} catch (error) {
Logger.error({error, url: url.toString()}, 'Failed to resolve URL');
return [];
}
}
private parsePathParts(url: URL): Array<string> {
return url.pathname.replace(/^\/+|\/+$/g, '').split(BlueskyResolver.PATH_SEPARATOR);
}
private isProfileUrl(url: URL): boolean {
const parts = this.parsePathParts(url);
const isProfile = parts.length === 2 && parts[0] === 'profile';
Logger.debug({url: url.toString(), parts, isProfile}, 'Profile URL check');
return isProfile;
}
private isPostUrl(url: URL): boolean {
const parts = this.parsePathParts(url);
const isPost = parts.length === 4 && parts[0] === 'profile' && parts[2] === 'post' && parts[3].length > 0;
Logger.debug({url: url.toString(), parts, isPost}, 'Post URL check');
return isPost;
}
private async getAtUri(url: URL): Promise<string | null> {
const parts = this.parsePathParts(url);
if (parts.length !== 4) throw new Error('Invalid URL format for AT URI conversion');
const handle = parts[1];
const postId = parts[3];
const did = await this.apiClient.resolveDid(handle);
if (!did) return null;
const atUri = `at://${did}/app.bsky.feed.post/${postId}`;
Logger.debug({url: url.toString(), handle, did, postId, atUri}, 'Generated AT URI');
return atUri;
}
}

View File

@@ -0,0 +1,371 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {selectAll, selectOne} from 'css-select';
import type {Document, Element, Text} from 'domhandler';
import {parseDocument} from 'htmlparser2';
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import {Logger} from '~/Logger';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import * as FetchUtils from '~/utils/FetchUtils';
import {parseString} from '~/utils/StringUtils';
import {ActivityPubResolver} from './subresolvers/ActivityPubResolver';
interface OEmbedResponse {
provider_name?: string;
provider_url?: string;
author_name?: string;
author_url?: string;
}
const COLOR_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
export class DefaultResolver extends BaseResolver {
private activityPubResolver: ActivityPubResolver;
private static readonly MAX_GALLERY_IMAGES = 10;
constructor(
private cacheService: ICacheService,
mediaService: IMediaService,
) {
super(mediaService);
this.activityPubResolver = new ActivityPubResolver(this.cacheService, mediaService);
}
match(_url: URL, mimeType: string, _content: Uint8Array): boolean {
Logger.debug({mimeType}, 'Checking if content type matches HTML');
const matches = mimeType.startsWith('text/html');
Logger.debug({matches}, 'Content type match result');
return matches;
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
Logger.debug({url: url.href}, 'Starting HTML resolution');
let document: Document;
let htmlString: string;
try {
htmlString = Buffer.from(content).toString('utf-8');
document = parseDocument(htmlString);
Logger.debug('Successfully parsed HTML document');
} catch (error) {
Logger.error({error}, 'Failed to parse HTML document');
throw error;
}
const activityPubLink = this.findActivityPubLink(document);
let activityPubUrl: string | null = null;
if (activityPubLink) {
activityPubUrl = activityPubLink.startsWith('http')
? activityPubLink
: new URL(activityPubLink, url.origin).toString();
Logger.debug({activityPubUrl}, 'Found ActivityPub link in HTML');
try {
const activityPubEmbeds = await this.activityPubResolver.resolveActivityPub(url, activityPubUrl, htmlString);
if (activityPubEmbeds && activityPubEmbeds.length > 0) {
Logger.debug({url: url.href}, 'Resolved as ActivityPub');
return activityPubEmbeds;
}
} catch (error) {
Logger.error({error, url: url.href}, 'Failed to resolve as ActivityPub');
}
}
const title = this.extractTitle(document);
Logger.debug({title}, 'Extracted title');
const description = this.extractDescription(document);
Logger.debug({description}, 'Extracted description');
const rawColor = this.extractMetaField(document, 'theme-color');
const color = this.extractColor(rawColor);
Logger.debug({rawColor, color}, 'Extracted and parsed color');
const oEmbedData = await this.fetchOEmbedData(url, document);
Logger.debug({oEmbedData}, 'Fetched oEmbed data');
const siteName = oEmbedData.providerName ?? this.extractSiteName(document);
Logger.debug({siteName}, 'Determined site name');
const imageUrls = this.extractImageURLs(document);
Logger.debug({imageUrls}, 'Extracted image URLs');
const resolvedImages: Array<MessageEmbedResponse['image']> = [];
for (const imageUrl of imageUrls) {
if (resolvedImages.length >= DefaultResolver.MAX_GALLERY_IMAGES) break;
const media = await this.resolveMediaURL(url, imageUrl, isNSFWAllowed);
if (media) {
resolvedImages.push(media);
}
}
const imageMedia = resolvedImages.shift();
if (imageMedia) {
const imageDescription =
this.extractMetaField(document, 'og:image:alt') ??
this.extractMetaField(document, 'twitter:image:alt') ??
this.extractMetaField(document, 'og:image:description');
if (imageDescription) {
imageMedia.description = parseString(imageDescription, 4096);
Logger.debug({imageDescription: imageMedia.description}, 'Applied description to image media');
}
}
Logger.debug({imageMedia}, 'Resolved image media');
const videoUrl = this.extractMediaURL(document, 'video');
const videoMedia = await this.resolveMediaURL(url, videoUrl, isNSFWAllowed);
Logger.debug({videoUrl, videoMedia}, 'Resolved video media');
const audioUrl = this.extractMediaURL(document, 'audio');
const audioMedia = await this.resolveMediaURL(url, audioUrl, isNSFWAllowed);
Logger.debug({audioUrl, audioMedia}, 'Resolved audio media');
const embed: MessageEmbedResponse = {
type: 'link',
url: url.href,
...(title && {title: parseString(title, 70)}),
...(description && {description: parseString(description, 350)}),
...(color !== undefined && {color}),
...(oEmbedData.authorName &&
oEmbedData.authorURL && {
author: {name: parseString(oEmbedData.authorName, 256), url: oEmbedData.authorURL},
}),
...(siteName && {provider: {name: parseString(siteName, 256), url: oEmbedData.providerURL ?? url.origin}}),
...(imageMedia && {thumbnail: imageMedia}),
...(videoMedia && {video: videoMedia}),
...(audioMedia && {audio: audioMedia}),
};
const extraImageEmbeds = resolvedImages.map((image) => ({
type: 'rich' as const,
url: url.href,
image,
}));
Logger.debug({embed, galleryImages: extraImageEmbeds.length}, 'Successfully created link embed');
return [embed, ...extraImageEmbeds];
}
private findActivityPubLink(document: Document): string | null {
const linkElement = selectOne(
'link[rel="alternate"][type="application/activity+json"]',
document,
) as Element | null;
return linkElement?.attribs.href || null;
}
private extractMetaField(document: Document, property: string, attribute = 'content'): string | undefined {
Logger.debug({property, attribute}, 'Extracting meta field');
const values = this.extractMetaFieldValues(document, property, attribute);
return values.length > 0 ? values[values.length - 1] : undefined;
}
private extractMetaFieldValues(document: Document, property: string, attribute = 'content'): Array<string> {
const selectors = [
`meta[property="${property}"]`,
`meta[name="${property}"]`,
`meta[property="twitter:${property.replace('og:', '')}"]`,
`meta[name="twitter:${property.replace('og:', '')}"]`,
];
const values: Array<string> = [];
for (const selector of selectors) {
const nodes = selectAll(selector, document) as Array<Element | Document>;
const elements = nodes.flatMap((node) => (node && 'attribs' in (node as Element) ? [node as Element] : []));
for (const element of elements) {
if (element?.attribs[attribute]) {
Logger.debug({selector, value: element.attribs[attribute]}, 'Found meta value');
values.push(element.attribs[attribute]);
}
}
}
return values;
}
private extractTitle(document: Document): string | undefined {
const ogTitle = this.extractMetaField(document, 'og:title');
if (ogTitle) {
Logger.debug({ogTitle}, 'Found OpenGraph title');
return ogTitle;
}
const twitterTitle = this.extractMetaField(document, 'twitter:title');
if (twitterTitle) {
Logger.debug({twitterTitle}, 'Found Twitter title');
return twitterTitle;
}
const titleElement = selectOne('title', document) as Element | null;
if (titleElement?.children[0]) {
const titleText = (titleElement.children[0] as Text).data?.trim();
if (titleText) {
Logger.debug({titleText}, 'Found HTML title');
return titleText;
}
}
const metaTitle = this.extractMetaField(document, 'title');
if (metaTitle) {
Logger.debug({metaTitle}, 'Found meta title');
return metaTitle;
}
return;
}
private extractDescription(document: Document): string | undefined {
Logger.debug('Extracting description');
const description =
this.extractMetaField(document, 'og:description') ||
this.extractMetaField(document, 'description') ||
this.extractMetaField(document, 'twitter:description');
Logger.debug({description}, 'Found description');
return description;
}
private extractSiteName(document: Document): string | undefined {
Logger.debug('Extracting site name');
const siteName =
this.extractMetaField(document, 'og:site_name') ||
this.extractMetaField(document, 'twitter:site:name') ||
this.extractMetaField(document, 'application-name');
Logger.debug({siteName}, 'Found site name');
return siteName;
}
private extractImageURLs(document: Document): Array<string> {
Logger.debug('Extracting image URLs');
const properties = ['og:image', 'og:image:secure_url', 'twitter:image', 'twitter:image:src', 'image'];
const seen = new Set<string>();
const values: Array<string> = [];
for (const property of properties) {
const metaValues = this.extractMetaFieldValues(document, property);
for (const metaValue of metaValues) {
const normalized = this.normalizeUrl(metaValue);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
values.push(metaValue);
}
}
return values;
}
private normalizeUrl(value: string): string | null {
try {
return new URL(value).href.replace(/\/$/, '');
} catch (error) {
Logger.debug({error, value}, 'Failed to normalize URL');
return null;
}
}
private extractMediaURL(document: Document, type: 'video' | 'audio'): string | undefined {
Logger.debug({type}, 'Extracting media URL');
const mediaUrl =
this.extractMetaField(document, `og:${type}`) ||
this.extractMetaField(document, `og:${type}:url`) ||
this.extractMetaField(document, `og:${type}:secure_url`) ||
this.extractMetaField(document, `twitter:${type}`) ||
this.extractMetaField(document, `twitter:${type}:url`) ||
(type === 'video' ? this.extractMetaField(document, 'twitter:player') : undefined) ||
(type === 'video' ? this.extractMetaField(document, 'twitter:player:stream') : undefined);
Logger.debug({mediaUrl}, `Found ${type} URL`);
return mediaUrl;
}
private extractColor(color: string | undefined): number | undefined {
if (!color) return;
const normalizedColor = color.toLowerCase();
if (!COLOR_REGEX.test(normalizedColor)) {
Logger.debug({color}, 'Invalid color format');
return;
}
try {
const parsed = Number.parseInt(normalizedColor.slice(1), 16);
Logger.debug({color, parsed}, 'Successfully parsed color');
return parsed;
} catch (error) {
Logger.debug({error, color}, 'Failed to parse color');
return;
}
}
private async fetchOEmbedData(
url: URL,
document: Document,
): Promise<{
providerName?: string;
providerURL?: string;
authorName?: string;
authorURL?: string;
}> {
Logger.debug({url: url.href}, 'Attempting to fetch oEmbed data');
const oEmbedLink = selectOne('link[type="application/json+oembed"]', document) as Element | null;
if (!oEmbedLink?.attribs.href) {
Logger.debug('No oEmbed link found');
return {};
}
const oEmbedUrl = this.resolveRelativeURL(url.href, oEmbedLink.attribs.href);
if (!oEmbedUrl) {
Logger.debug('Could not resolve oEmbed URL');
return {};
}
try {
Logger.debug({url: oEmbedUrl}, 'Fetching oEmbed data');
const response = await FetchUtils.sendRequest({url: oEmbedUrl});
if (response.status !== 200) {
Logger.debug({status: response.status}, 'Failed to fetch oEmbed data');
return {};
}
const responseText = await FetchUtils.streamToString(response.stream);
const oEmbedJson = JSON.parse(responseText) as OEmbedResponse;
Logger.debug({oEmbedJson}, 'Successfully parsed oEmbed response');
return {
providerName: oEmbedJson.provider_name,
providerURL: oEmbedJson.provider_url,
authorName: oEmbedJson.author_name,
authorURL: oEmbedJson.author_url,
};
} catch (error) {
Logger.error({error, url: oEmbedUrl}, 'Failed to fetch oEmbed JSON');
return {};
}
}
}

View File

@@ -0,0 +1,150 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/ChannelModel';
import {Logger} from '~/Logger';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import {htmlToMarkdown} from '~/utils/DOMUtils';
import * as FetchUtils from '~/utils/FetchUtils';
import {parseString} from '~/utils/StringUtils';
interface HnItem {
id: number;
type: 'story' | 'comment' | 'job' | 'poll' | 'pollopt';
by?: string;
time: number;
text?: string;
dead?: boolean;
deleted?: boolean;
url?: string;
title?: string;
score?: number;
descendants?: number;
kids?: Array<number>;
parent?: number;
parts?: Array<number>;
poll?: number;
}
export class HackerNewsResolver extends BaseResolver {
private readonly API_BASE = 'https://hacker-news.firebaseio.com/v0';
private readonly SITE_BASE = 'https://news.ycombinator.com';
private readonly HN_COLOR = 0xff6600;
private readonly HN_ICON = 'https://fluxerstatic.com/embeds/icons/hn.webp';
private readonly MAX_DESCRIPTION_LENGTH = 400;
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
return (
url.hostname === 'news.ycombinator.com' && url.pathname.startsWith('/item') && mimeType.startsWith('text/html')
);
}
async resolve(url: URL, _content: Uint8Array, _isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
try {
const itemId = new URLSearchParams(url.search).get('id');
if (!itemId) return [];
const item = await this.fetchItem(itemId);
if (!item) return [];
if (item.deleted || item.dead) {
Logger.debug({itemId}, 'Skipping deleted or dead HN item');
return [];
}
const embed = this.buildEmbed(item);
return [embed];
} catch (error) {
Logger.error({error, url: url.toString()}, 'Failed to resolve Hacker News item');
return [];
}
}
private async fetchItem(itemId: string): Promise<HnItem | null> {
try {
const response = await FetchUtils.sendRequest({
url: `${this.API_BASE}/item/${itemId}.json`,
timeout: 5000,
});
if (response.status !== 200) {
Logger.debug({itemId, status: response.status}, 'Failed to fetch HN item');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
return JSON.parse(responseText) as HnItem;
} catch (error) {
Logger.error({error, itemId}, 'Failed to fetch or parse HN item');
return null;
}
}
private buildEmbed(item: HnItem): MessageEmbedResponse {
const embed: MessageEmbedResponse = {
type: 'rich',
url: this.getItemUrl(item.id),
color: this.HN_COLOR,
timestamp: this.formatTimestamp(item.time),
footer: {
text: 'Hacker News',
icon_url: this.HN_ICON,
},
};
if (this.hasTitle(item) && item.title) {
embed.title = parseString(item.title, 256);
}
if (item.by) {
embed.author = {
name: parseString(item.by, 256),
};
}
const description = this.buildDescription(item);
if (description) {
embed.description = description;
}
return embed;
}
private buildDescription(item: HnItem): string | undefined {
if (!item.text) return undefined;
const markdown = htmlToMarkdown(item.text);
const singleLine = markdown.replace(/\s+/g, ' ').trim();
if (!singleLine) return undefined;
return parseString(singleLine, this.MAX_DESCRIPTION_LENGTH);
}
private formatTimestamp(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toISOString();
}
private hasTitle(item: HnItem): boolean {
return item.type === 'story' || item.type === 'job' || item.type === 'poll';
}
private getItemUrl(id: number): string {
return `${this.SITE_BASE}/item?id=${id}`;
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers';
export class ImageResolver extends BaseResolver {
match(_url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('image/');
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: Buffer.from(content).toString('base64'),
isNSFWAllowed,
});
const embed: MessageEmbedResponse = {
type: 'image',
url: url.href,
thumbnail: buildEmbedMediaPayload(url.href, metadata),
};
return [embed];
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {selectOne} from 'css-select';
import type {Document, Element, Text} from 'domhandler';
import {parseDocument} from 'htmlparser2';
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import {Logger} from '~/Logger';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
interface TenorJsonLd {
image?: {thumbnailUrl?: string};
video?: {contentUrl?: string};
}
export class TenorResolver extends BaseResolver {
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('text/html') && url.hostname === 'tenor.com';
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const document = parseDocument(Buffer.from(content).toString('utf-8'));
const jsonLdContent = this.extractJsonLdContent(document);
if (!jsonLdContent) {
return [];
}
const {thumbnailURL, videoURL} = this.extractURLsFromJsonLd(jsonLdContent);
const thumbnail = thumbnailURL ? await this.resolveMediaURL(url, thumbnailURL, isNSFWAllowed) : undefined;
const video = videoURL ? await this.resolveMediaURL(url, videoURL, isNSFWAllowed) : undefined;
const embed: MessageEmbedResponse = {
type: 'gifv',
url: url.href,
provider: {name: 'Tenor', url: 'https://tenor.com'},
thumbnail: thumbnail ?? undefined,
video: video ?? undefined,
};
return [embed];
}
private extractJsonLdContent(document: Document): TenorJsonLd | null {
const scriptElement = selectOne('script.dynamic[type="application/ld+json"]', document) as Element | null;
if (scriptElement && scriptElement.children.length > 0) {
const scriptContentNode = scriptElement.children[0] as Text;
const scriptContent = scriptContentNode.data;
try {
return JSON.parse(scriptContent) as TenorJsonLd;
} catch (error) {
Logger.error({error}, 'Failed to parse JSON-LD content');
}
}
return null;
}
private extractURLsFromJsonLd(jsonLdContent: TenorJsonLd): {thumbnailURL?: string; videoURL?: string} {
const thumbnailUrl = jsonLdContent.image?.thumbnailUrl;
const videoUrl = jsonLdContent.video?.contentUrl;
return {thumbnailURL: thumbnailUrl, videoURL: videoUrl};
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers';
export class VideoResolver extends BaseResolver {
match(_url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('video/');
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: Buffer.from(content).toString('base64'),
isNSFWAllowed,
});
const embed: MessageEmbedResponse = {
type: 'video',
url: url.href,
video: buildEmbedMediaPayload(url.href, metadata),
};
return [embed];
}
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import {Logger} from '~/Logger';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers';
import * as FetchUtils from '~/utils/FetchUtils';
import {parseString} from '~/utils/StringUtils';
interface WikiSummaryResponse {
type: string;
title: string;
extract: string;
thumbnail?: {
source: string;
width: number;
height: number;
};
originalimage?: {
source: string;
width: number;
height: number;
};
description?: string;
pageid: number;
}
type ProcessedThumbnail = NonNullable<MessageEmbedResponse['image']>;
export class WikipediaResolver extends BaseResolver {
private readonly SUPPORTED_DOMAINS = [
'wikipedia.org',
'www.wikipedia.org',
...['en', 'de', 'fr', 'es', 'it', 'ja', 'ru', 'zh'].map((lang) => `${lang}.wikipedia.org`),
];
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
return (
this.SUPPORTED_DOMAINS.includes(url.hostname) &&
url.pathname.startsWith('/wiki/') &&
mimeType.startsWith('text/html')
);
}
private getLanguageFromURL(url: URL): string {
const subdomain = url.hostname.split('.')[0];
return this.SUPPORTED_DOMAINS.includes(`${subdomain}.wikipedia.org`) ? subdomain : 'en';
}
private async fetchArticleSummary(title: string, baseUrl: string): Promise<WikiSummaryResponse | null> {
const apiUrl = `${baseUrl}/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
try {
const response = await FetchUtils.sendRequest({
url: apiUrl,
});
if (response.status !== 200) {
Logger.debug({title, status: response.status}, 'Failed to fetch Wikipedia article summary');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
return JSON.parse(responseText) as WikiSummaryResponse;
} catch (error) {
Logger.error({error, title}, 'Failed to fetch or parse Wikipedia response');
return null;
}
}
private async processThumbnail(
thumbnailData: WikiSummaryResponse['thumbnail'],
isNSFWAllowed: boolean,
): Promise<ProcessedThumbnail | null> {
if (!thumbnailData) return null;
const thumbnailMetadata = await this.mediaService.getMetadata({
type: 'external',
url: thumbnailData.source,
isNSFWAllowed,
});
return buildEmbedMediaPayload(thumbnailData.source, thumbnailMetadata, {
width: thumbnailData.width,
height: thumbnailData.height,
}) as ProcessedThumbnail;
}
async resolve(url: URL, _content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
try {
const title = decodeURIComponent(url.pathname.split('/wiki/')[1]);
if (!title) return [];
const language = this.getLanguageFromURL(url);
const baseUrl = `https://${language}.wikipedia.org`;
const article = await this.fetchArticleSummary(title, baseUrl);
if (!article) return [];
const thumbnail = await this.processThumbnail(article.thumbnail, isNSFWAllowed);
const originalImage = await this.processThumbnail(article.originalimage, isNSFWAllowed);
const uniqueImages = this.deduplicateThumbnails([thumbnail, originalImage]);
const primaryThumbnail = uniqueImages[0];
const embed: MessageEmbedResponse = {
type: 'article',
url: url.href,
title: parseString(article.title, 256),
description: parseString(article.extract, 350),
thumbnail: primaryThumbnail ?? undefined,
};
const extraImageEmbeds = uniqueImages.slice(1).map((image) => ({
type: 'rich' as const,
url: url.href,
image,
}));
return [embed, ...extraImageEmbeds];
} catch (error) {
Logger.error({error, url: url.toString()}, 'Failed to resolve Wikipedia article');
return [];
}
}
private deduplicateThumbnails(images: Array<ProcessedThumbnail | null>): Array<ProcessedThumbnail> {
const seen = new Set<string>();
const unique: Array<ProcessedThumbnail> = [];
for (const image of images) {
if (!image) continue;
const normalized = this.normalizeUrl(image.url);
if (!normalized) {
unique.push(image);
continue;
}
if (seen.has(normalized)) continue;
seen.add(normalized);
unique.push(image);
}
return unique;
}
private normalizeUrl(url?: string): string | null {
if (!url) return null;
try {
return new URL(url).href.replace(/\/$/, '');
} catch (error) {
Logger.debug({error, url}, 'Failed to normalize Wikipedia image URL');
return null;
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {selectOne} from 'css-select';
import type {Document, Element, Text} from 'domhandler';
import {parseDocument} from 'htmlparser2';
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import {parseString} from '~/utils/StringUtils';
export class XkcdResolver extends BaseResolver {
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('text/html') && url.hostname === 'xkcd.com';
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const document = parseDocument(Buffer.from(content).toString('utf-8'));
const title = this.extractTitle(document);
const imageUrl = this.extractImageURL(document);
const imageMedia = await this.resolveMediaURL(url, imageUrl, isNSFWAllowed);
const imageAlt = this.extractImageAlt(document);
const footerText = this.extractFooterText(document);
if (imageMedia) {
imageMedia.description = imageAlt;
}
const embed: MessageEmbedResponse = {
type: 'rich',
url: url.href,
title: title ? parseString(title, 70) : undefined,
color: 0x000000,
image: imageMedia ?? undefined,
footer: footerText ? {text: footerText} : undefined,
};
return [embed];
}
private extractTitle(document: Document): string | undefined {
const ogTitle = this.extractMetaField(document, 'og:title');
if (ogTitle) {
return ogTitle;
}
const titleElement = selectOne('title', document) as Element | null;
if (titleElement && titleElement.children.length > 0) {
const titleText = titleElement.children[0] as Text;
return titleText.data;
}
return;
}
private extractImageURL(document: Document): string | undefined {
return this.extractMetaField(document, 'og:image');
}
private extractImageAlt(document: Document): string | undefined {
const imageElement = selectOne('#comic img', document) as Element | null;
return imageElement ? imageElement.attribs.title : undefined;
}
private extractFooterText(document: Document): string | undefined {
const imageElement = selectOne('#comic img', document) as Element | null;
return imageElement ? imageElement.attribs.title : undefined;
}
private extractMetaField(document: Document, property: string, attribute = 'content'): string | undefined {
const element = selectOne(`meta[property="${property}"], meta[name="${property}"]`, document) as Element | null;
return element?.attribs[attribute] ?? undefined;
}
}

View File

@@ -0,0 +1,194 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {URL} from 'node:url';
import {Config} from '~/Config';
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import {Logger} from '~/Logger';
import {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers';
import * as FetchUtils from '~/utils/FetchUtils';
import {parseString} from '~/utils/StringUtils';
interface YouTubeApiResponse {
items?: Array<{
snippet: {
title: string;
description: string;
channelTitle: string;
channelId: string;
thumbnails: {
maxres?: {
url: string;
width: number;
height: number;
};
high: {
url: string;
width: number;
height: number;
};
};
};
player: {
embedHtml: string;
};
status: {
uploadStatus?: string;
privacyStatus?: string;
embeddable?: boolean;
publicStatsViewable?: boolean;
madeForKids?: boolean;
};
}>;
}
export class YouTubeResolver extends BaseResolver {
private readonly API_BASE = 'https://www.googleapis.com/youtube/v3';
private readonly YOUTUBE_COLOR = 0xff0000;
match(url: URL, _mimeType: string, _content: Uint8Array): boolean {
if (!['www.youtube.com', 'youtube.com', 'youtu.be'].includes(url.hostname)) {
return false;
}
return (
url.pathname.startsWith('/watch') ||
url.pathname.startsWith('/shorts') ||
url.pathname.startsWith('/v/') ||
url.hostname === 'youtu.be'
);
}
async resolve(url: URL, _content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
if (!Config.youtube.apiKey) {
Logger.debug('No Google API key configured');
return [];
}
const videoId = this.extractVideoId(url);
if (!videoId) {
Logger.error('No video ID found in URL');
return [];
}
try {
const timestamp = this.extractTimestamp(url);
const apiUrl = new URL(`${this.API_BASE}/videos`);
apiUrl.searchParams.set('key', Config.youtube.apiKey);
apiUrl.searchParams.set('id', videoId);
apiUrl.searchParams.set('part', 'snippet,player,status');
const response = await FetchUtils.sendRequest({
url: apiUrl.toString(),
});
if (response.status !== 200) {
Logger.error({videoId, status: response.status}, 'Failed to fetch YouTube API data');
return [];
}
const responseText = await FetchUtils.streamToString(response.stream);
const data = JSON.parse(responseText) as YouTubeApiResponse;
const video = data.items?.[0];
if (!video) {
Logger.error({videoId}, 'No video data found');
return [];
}
const thumbnailUrl = video.snippet.thumbnails.maxres?.url || video.snippet.thumbnails.high.url;
const thumbnailMetadata = await this.mediaService.getMetadata({
type: 'external',
url: thumbnailUrl,
isNSFWAllowed,
});
const embedHtmlMatch = video.player.embedHtml.match(/width="(\d+)"\s+height="(\d+)"/);
const embedWidth = embedHtmlMatch ? Number.parseInt(embedHtmlMatch[1], 10) : 1280;
const embedHeight = embedHtmlMatch ? Number.parseInt(embedHtmlMatch[2], 10) : 720;
const mainUrl = new URL('https://www.youtube.com/watch');
mainUrl.searchParams.set('v', videoId);
if (timestamp !== undefined) {
mainUrl.searchParams.set('start', timestamp.toString());
}
const embedUrl = new URL(`https://www.youtube.com/embed/${videoId}`);
if (timestamp !== undefined) {
embedUrl.searchParams.set('start', timestamp.toString());
}
const embed: MessageEmbedResponse = {
type: 'video',
url: mainUrl.toString(),
title: video.snippet.title,
description: parseString(video.snippet.description, 350),
color: this.YOUTUBE_COLOR,
author: video.snippet.channelTitle
? {
name: video.snippet.channelTitle,
url: `https://www.youtube.com/channel/${video.snippet.channelId}`,
}
: undefined,
provider: {
name: 'YouTube',
url: 'https://www.youtube.com',
},
thumbnail: buildEmbedMediaPayload(thumbnailUrl, thumbnailMetadata, {
width: video.snippet.thumbnails.maxres?.width || video.snippet.thumbnails.high.width,
height: video.snippet.thumbnails.maxres?.height || video.snippet.thumbnails.high.height,
}),
video: {
url: embedUrl.toString(),
width: embedWidth,
height: embedHeight,
flags: 0,
},
};
return [embed];
} catch (error) {
Logger.error({error, videoId: this.extractVideoId(url)}, 'Failed to resolve YouTube URL');
return [];
}
}
private extractVideoId(url: URL): string {
if (url.pathname.startsWith('/shorts/')) {
return url.pathname.split('/shorts/')[1];
}
if (url.pathname.startsWith('/v/')) {
return url.pathname.split('/v/')[1];
}
if (url.hostname === 'youtu.be') {
return url.pathname.slice(1);
}
return url.searchParams.get('v') || '';
}
private extractTimestamp(url: URL): number | undefined {
const tParam = url.searchParams.get('t');
if (tParam) {
if (tParam.endsWith('s')) {
return Number.parseInt(tParam.slice(0, -1), 10);
}
return Number.parseInt(tParam, 10);
}
return;
}
}

View File

@@ -0,0 +1,154 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ICacheService} from '~/infrastructure/ICacheService';
import {Logger} from '~/Logger';
import * as FetchUtils from '~/utils/FetchUtils';
import type {BlueskyPostThread, BlueskyProfile, HandleResolution} from './BlueskyTypes';
export class BlueskyApiClient {
private static readonly API_BASE = 'https://api.bsky.app/xrpc';
constructor(private cacheService: ICacheService) {}
async resolveDid(handle: string): Promise<string | null> {
Logger.debug({handle}, 'Resolving handle to DID');
if (handle.startsWith('did:')) {
Logger.debug({handle}, 'Handle is already a DID');
return handle;
}
try {
const response = await FetchUtils.sendRequest({
url: `${BlueskyApiClient.API_BASE}/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
method: 'GET',
});
if (response.status !== 200) {
Logger.debug({handle, status: response.status}, 'Failed to resolve handle to DID');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
const resolution = JSON.parse(responseText) as HandleResolution;
Logger.debug({handle, did: resolution.did}, 'Successfully resolved handle to DID');
return resolution.did;
} catch (error) {
Logger.error({error, handle}, 'Failed to resolve handle to DID');
return null;
}
}
async getServiceEndpoint(did: string): Promise<string> {
const cacheKey = `bluesky:service-endpoint:${did}`;
const cached = await this.cacheService.get<string>(cacheKey);
if (cached) return cached;
try {
let url: string;
if (did.startsWith('did:web:')) {
url = `https://${did.split(':')[2]}/.well-known/did.json`;
} else {
url = `https://plc.directory/${did}`;
}
const response = await FetchUtils.sendRequest({url, method: 'GET'});
if (response.status !== 200) {
Logger.debug({did, status: response.status}, 'Failed to fetch service endpoint');
return 'https://bsky.social';
}
const responseText = await FetchUtils.streamToString(response.stream);
const didDoc = JSON.parse(responseText);
let serviceEndpoint = 'https://bsky.social';
for (const service of didDoc.service || []) {
if (service.type === 'AtprotoPersonalDataServer') {
serviceEndpoint = service.serviceEndpoint;
break;
}
}
await this.cacheService.set(cacheKey, serviceEndpoint, 3600);
Logger.debug({did, serviceEndpoint}, 'Retrieved and cached service endpoint');
return serviceEndpoint;
} catch (error) {
Logger.error({error, did}, 'Failed to fetch service endpoint');
return 'https://bsky.social';
}
}
async fetchPost(atUri: string): Promise<BlueskyPostThread | null> {
Logger.debug({atUri}, 'Fetching post');
try {
const response = await FetchUtils.sendRequest({
url: `${BlueskyApiClient.API_BASE}/app.bsky.feed.getPostThread?uri=${encodeURIComponent(atUri)}&depth=0`,
method: 'GET',
});
if (response.status !== 200) {
Logger.debug({atUri, status: response.status}, 'Failed to fetch post');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
const thread = JSON.parse(responseText) as BlueskyPostThread;
Logger.debug(
{
atUri,
author: thread.thread.post.author.handle,
hasEmbed: !!thread.thread.post.embed,
isReply: !!thread.thread.post.record.reply,
hasParent: !!thread.thread.parent,
},
'Post fetched and parsed successfully',
);
return thread;
} catch (error) {
Logger.error({error, atUri}, 'Failed to fetch post');
return null;
}
}
async fetchProfile(handle: string): Promise<BlueskyProfile | null> {
Logger.debug({handle}, 'Fetching profile');
try {
const response = await FetchUtils.sendRequest({
url: `${BlueskyApiClient.API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`,
method: 'GET',
});
if (response.status !== 200) {
Logger.debug({handle, status: response.status}, 'Failed to fetch profile');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
const profile = JSON.parse(responseText) as BlueskyProfile;
Logger.debug(
{handle, did: profile.did, hasAvatar: !!profile.avatar, hasBanner: !!profile.banner},
'Profile fetched and parsed successfully',
);
return profile;
} catch (error) {
Logger.error({error, handle}, 'Failed to fetch profile');
return null;
}
}
}

View File

@@ -0,0 +1,249 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IMediaService} from '~/infrastructure/IMediaService';
import {Logger} from '~/Logger';
import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers';
import {parseString} from '~/utils/StringUtils';
import type {BlueskyApiClient} from './BlueskyApiClient';
import type {BlueskyTextFormatter} from './BlueskyTextFormatter';
import type {
BlueskyAspectRatio,
BlueskyPost,
BlueskyPostEmbed,
ProcessedMedia,
ProcessedVideoResult,
} from './BlueskyTypes';
export class BlueskyEmbedProcessor {
private static readonly MAX_ALT_TEXT_LENGTH = 4096;
private static readonly MAX_GALLERY_IMAGES = 10;
constructor(
private mediaService: IMediaService,
private apiClient: BlueskyApiClient,
private textFormatter: BlueskyTextFormatter,
) {}
async processImage(
imageUrl?: string,
aspectRatio?: BlueskyAspectRatio,
altText?: string,
isNSFWAllowed: boolean = false,
): Promise<ProcessedMedia | undefined> {
if (!imageUrl) {
Logger.debug('No image URL provided to process');
return;
}
try {
Logger.debug({imageUrl, aspectRatio, hasAltText: !!altText}, 'Processing image');
const metadata = await this.mediaService.getMetadata({type: 'external', url: imageUrl, isNSFWAllowed});
let description: string | undefined;
if (altText) {
description = parseString(altText, BlueskyEmbedProcessor.MAX_ALT_TEXT_LENGTH);
Logger.debug(
{imageUrl, altTextLength: altText.length, processedLength: description.length},
'Added alt text as description to image',
);
}
const result = buildEmbedMediaPayload(imageUrl, metadata, {
width: aspectRatio?.width,
height: aspectRatio?.height,
description,
}) as ProcessedMedia;
Logger.debug({imageUrl, metadata: result}, 'Image processed successfully');
return result;
} catch (error) {
Logger.error({error, imageUrl}, 'Failed to process image');
return;
}
}
async processVideoEmbed(
embed: BlueskyPostEmbed['video'],
did: string,
isNSFWAllowed: boolean,
): Promise<ProcessedVideoResult> {
if (!embed || embed.$type !== 'app.bsky.embed.video#view') {
Logger.debug({embedType: embed?.$type}, 'Not a video embed');
return {};
}
Logger.debug(
{embedType: embed.$type, hasThumbnail: !!embed.thumbnail, cid: embed.cid, aspectRatio: embed.aspectRatio},
'Processing video embed',
);
try {
const thumbnail = await this.processImage(embed.thumbnail, embed.aspectRatio, undefined, isNSFWAllowed);
if (!thumbnail || !embed.cid) {
Logger.debug(
{embedType: embed.$type, hasThumbnail: !!thumbnail, hasCid: !!embed.cid},
'Missing required video data',
);
return {};
}
const serviceEndpoint = await this.apiClient.getServiceEndpoint(did);
const directUrl = `${serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${embed.cid}`;
const videoMetadata = await this.mediaService.getMetadata({type: 'external', url: directUrl, isNSFWAllowed});
const video = buildEmbedMediaPayload(directUrl, videoMetadata, {
width: videoMetadata?.width ?? thumbnail.width,
height: videoMetadata?.height ?? thumbnail.height,
}) as ProcessedMedia;
Logger.debug(
{thumbnailProcessed: !!thumbnail, videoMetadata, aspectRatio: embed.aspectRatio, serviceEndpoint},
'Successfully processed video embed',
);
return {thumbnail, video};
} catch (error) {
Logger.error({error, embedType: embed.$type}, 'Failed to process video embed');
return {};
}
}
async processPostEmbed(
post: BlueskyPost,
isNSFWAllowed: boolean,
): Promise<{
image?: ProcessedMedia;
thumbnail?: ProcessedMedia;
video?: ProcessedMedia;
quotedText?: string;
galleryImages?: Array<ProcessedMedia>;
}> {
let image: ProcessedMedia | undefined;
let thumbnail: ProcessedMedia | undefined;
let video: ProcessedMedia | undefined;
let quotedText: string | undefined;
if (!post.embed) return {image, thumbnail, video, quotedText};
Logger.debug({embedType: post.embed.$type, hasEmbed: true, authorDid: post.author.did}, 'Processing post embed');
const processedImages = await this.processEmbedImages(post.embed, isNSFWAllowed);
if (processedImages.length > 0) {
image = processedImages[0];
if (post.embed.$type === 'app.bsky.embed.images#view') {
const firstImage = post.embed.images?.[0];
Logger.debug(
{imageUrl: firstImage?.thumb, hasAltText: !!firstImage?.alt, altTextLength: firstImage?.alt?.length},
'Processed image with alt text',
);
} else if (post.embed.$type === 'app.bsky.embed.recordWithMedia#view') {
const firstMediaImage = post.embed.media?.images?.[0];
Logger.debug(
{
imageUrl: firstMediaImage?.thumb,
hasAltText: !!firstMediaImage?.alt,
altTextLength: firstMediaImage?.alt?.length,
},
'Processed media image with alt text',
);
}
}
if (post.embed.$type === 'app.bsky.embed.video#view') {
const processed = await this.processVideoEmbed(post.embed, post.author.did, isNSFWAllowed);
thumbnail = processed.thumbnail;
video = processed.video;
}
if (post.embed.$type === 'app.bsky.embed.recordWithMedia#view') {
if (post.embed.media?.$type === 'app.bsky.embed.video#view') {
const processed = await this.processVideoEmbed(post.embed.media, post.author.did, isNSFWAllowed);
thumbnail = processed.thumbnail;
video = processed.video;
}
if (post.embed.record?.record) {
const quoteAuthor = post.embed.record.record.author;
const quoteText = post.embed.record.record.value.text;
if (quoteAuthor && quoteText) {
const formattedAuthor = this.textFormatter.formatAuthor(quoteAuthor);
quotedText = `>>> ${formattedAuthor}\n${this.textFormatter.embedLinksInText(quoteText, post.embed.record.record.value.facets)}`;
}
}
}
const galleryImages = processedImages.length > 1 ? processedImages.slice(1) : undefined;
return {image, thumbnail, video, quotedText, galleryImages};
}
private async processEmbedImages(embed: BlueskyPostEmbed, isNSFWAllowed: boolean): Promise<Array<ProcessedMedia>> {
const imageEntries = this.collectImageEntries(embed);
if (imageEntries.length === 0) return [];
const processedImages: Array<ProcessedMedia> = [];
const seenUrls = new Set<string>();
for (const entry of imageEntries) {
const normalizedUrl = this.normalizeUrl(entry.url);
if (!normalizedUrl) continue;
if (seenUrls.has(normalizedUrl)) continue;
seenUrls.add(normalizedUrl);
const processedImage = await this.processImage(entry.url, entry.aspectRatio, entry.alt, isNSFWAllowed);
if (processedImage) {
processedImages.push(processedImage);
if (processedImages.length >= BlueskyEmbedProcessor.MAX_GALLERY_IMAGES) break;
}
}
return processedImages;
}
private collectImageEntries(embed: BlueskyPostEmbed): Array<{
url: string;
aspectRatio?: BlueskyAspectRatio;
alt?: string;
}> {
const entries: Array<{url: string; aspectRatio?: BlueskyAspectRatio; alt?: string}> = [];
const addImages = (
images?: Array<{thumb: string; fullsize?: string; alt?: string; aspectRatio?: BlueskyAspectRatio}>,
) => {
if (!images) return;
for (const image of images) {
const resolvedUrl = image.fullsize ?? image.thumb;
if (!resolvedUrl) continue;
entries.push({url: resolvedUrl, aspectRatio: image.aspectRatio, alt: image.alt});
}
};
addImages(embed.images);
if (embed.media?.$type === 'app.bsky.embed.images#view') {
addImages(embed.media.images);
}
return entries;
}
private normalizeUrl(url: string): string | null {
try {
return new URL(url).href.replace(/\/$/, '');
} catch (error) {
Logger.error({error, url}, 'Failed to normalize image URL');
return null;
}
}
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AppBskyRichtextFacet, RichText} from '@atproto/api';
import {Logger} from '~/Logger';
import type {BlueskyAuthor, BlueskyPost, BlueskyPostThread, Facet, ReplyContext} from './BlueskyTypes';
export class BlueskyTextFormatter {
embedLinksInText(text: string, facets?: Array<Facet>): string {
if (!facets) return text;
const richText = new RichText({text, facets});
let result = '';
let lastIndex = 0;
const utf8ToUtf16Map = new Map<number, number>();
for (let i = 0; i < text.length; i++) {
const utf8Index = richText.unicodeText.utf16IndexToUtf8Index(i);
utf8ToUtf16Map.set(utf8Index, i);
}
const sortedFacets = [...(richText.facets || [])].sort((a, b) => a.index.byteStart - b.index.byteStart);
for (const facet of sortedFacets) {
const startUtf16 = utf8ToUtf16Map.get(facet.index.byteStart) ?? lastIndex;
const endUtf16 = utf8ToUtf16Map.get(facet.index.byteEnd) ?? text.length;
result += text.slice(lastIndex, startUtf16);
const facetText = text.slice(startUtf16, endUtf16);
const feature = facet.features[0];
if (AppBskyRichtextFacet.isLink(feature)) {
result += `[${this.getLinkDisplayText(feature.uri)}](${feature.uri})`;
} else if (AppBskyRichtextFacet.isMention(feature)) {
result += `[${facetText}](https://bsky.app/profile/${feature.did})`;
} else if (AppBskyRichtextFacet.isTag(feature)) {
const tagText = facetText.startsWith('#') ? facetText.slice(1) : facetText;
result += `[${facetText}](https://bsky.app/search?q=%23${encodeURIComponent(tagText)})`;
} else {
result += facetText;
}
lastIndex = endUtf16;
}
result += text.slice(lastIndex);
return result;
}
getLinkDisplayText(uri: string): string {
const url = new URL(uri);
const hostname = url.hostname;
const path = url.pathname;
const pathParts = path.split('/');
const pathPart = pathParts[pathParts.length - 1];
const truncatedPath = pathPart.length > 12 ? `${pathPart.slice(0, 12)}...` : pathPart;
return `${hostname}${pathPart ? `/${truncatedPath}` : ''}`;
}
formatAuthor(author: BlueskyAuthor): string {
const displayName = author.displayName || author.handle;
const handle = author.handle;
const profileUrl = `https://bsky.app/profile/${handle}`;
return `**[${displayName} (@${handle})](${profileUrl})**`;
}
formatPostContent(post: BlueskyPost, thread: BlueskyPostThread): string {
let processedText = this.embedLinksInText(post.record.text, post.record.facets);
const replyContext = this.extractReplyContext(post, thread);
if (replyContext) {
processedText = `-# ↩ [${replyContext.authorName} (@${replyContext.authorHandle})](${replyContext.postUrl})\n${processedText}`;
Logger.debug(
{postUri: post.uri, replyingTo: replyContext.authorName, replyingToHandle: replyContext.authorHandle},
'Added reply indicator to post content',
);
}
return processedText;
}
extractReplyContext(post: BlueskyPost, thread: BlueskyPostThread): ReplyContext | null {
if (!post.record.reply) {
Logger.debug({postUri: post.uri}, 'Post is not a reply');
return null;
}
if (thread.thread.parent?.post) {
const parentPost = thread.thread.parent.post;
const authorName = parentPost.author.displayName || parentPost.author.handle;
const authorHandle = parentPost.author.handle;
const postUrl = `https://bsky.app/profile/${authorHandle}/post/${parentPost.uri.split('/').pop()}`;
Logger.debug({parentAuthor: authorName, parentHandle: authorHandle, postUrl}, 'Found parent post in thread data');
return {authorName, authorHandle, postUrl};
}
Logger.debug({postUri: post.uri}, 'Parent post not found in thread data');
return null;
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
type FacetFeatureType =
| 'app.bsky.richtext.facet#link'
| 'app.bsky.richtext.facet#mention'
| 'app.bsky.richtext.facet#tag';
interface FacetFeature {
$type: FacetFeatureType;
uri?: string;
did?: string;
tag?: string;
}
interface FacetBytePosition {
byteStart: number;
byteEnd: number;
}
export interface Facet {
index: FacetBytePosition;
features: [FacetFeature];
}
export interface BlueskyAuthor {
did: string;
handle: string;
displayName?: string;
avatar?: string;
}
interface BlueskyImageEmbed {
alt?: string;
image: {ref: {$link: string}};
}
interface BlueskyVideoEmbed {
$type: string;
ref: {$link: string};
mimeType: string;
size: number;
}
export interface BlueskyAspectRatio {
width: number;
height: number;
}
interface BlueskyRecordEmbed {
$type: string;
aspectRatio?: BlueskyAspectRatio;
video?: BlueskyVideoEmbed;
images?: Array<BlueskyImageEmbed>;
}
export interface BlueskyPostEmbed {
$type: 'app.bsky.embed.images#view' | 'app.bsky.embed.video#view' | 'app.bsky.embed.recordWithMedia#view';
images?: Array<{thumb: string; fullsize: string; alt?: string; aspectRatio?: BlueskyAspectRatio}>;
video?: {$type: string; cid?: string; aspectRatio?: BlueskyAspectRatio; thumbnail?: string; playlist?: string};
media?: {
$type: 'app.bsky.embed.images#view' | 'app.bsky.embed.video#view';
images?: Array<{thumb: string; fullsize: string; alt?: string; aspectRatio?: BlueskyAspectRatio}>;
video?: {$type: string; cid?: string; aspectRatio?: BlueskyAspectRatio; thumbnail?: string; playlist?: string};
};
record?: {
$type?: 'app.bsky.embed.record#view';
record?: {
$type: 'app.bsky.embed.record#viewRecord';
uri: string;
cid: string;
author: BlueskyAuthor;
value: {$type: string; text: string; createdAt: string; facets?: Array<Facet>};
labels?: Array<Record<string, unknown>>;
indexedAt: string;
embeds?: Array<Record<string, unknown>>;
};
};
}
interface BlueskyRecord {
text: string;
createdAt: string;
facets?: Array<Facet>;
embed?: BlueskyRecordEmbed;
reply?: {parent: {cid: string; uri: string}; root: {cid: string; uri: string}};
}
export interface BlueskyPost {
uri: string;
author: BlueskyAuthor;
record: BlueskyRecord;
embed?: BlueskyPostEmbed;
indexedAt: string;
replyCount: number;
repostCount: number;
likeCount: number;
quoteCount: number;
}
export interface BlueskyPostThread {
thread: {post: BlueskyPost; parent?: {post: BlueskyPost}; replies?: Array<{post: BlueskyPost}>};
}
export interface BlueskyProfile {
did: string;
handle: string;
displayName?: string;
description?: string;
avatar?: string;
banner?: string;
indexedAt: string;
}
export interface HandleResolution {
did: string;
}
export interface ProcessedMedia {
url: string;
width: number;
height: number;
placeholder?: string;
flags: number;
description?: string;
content_type?: string;
content_hash?: string;
duration?: number;
}
export interface ProcessedVideoResult {
thumbnail?: ProcessedMedia;
video?: ProcessedMedia;
}
export interface ReplyContext {
authorName: string;
authorHandle: string;
postUrl: string;
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {MessageAttachmentFlags} from '~/Constants';
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import type {MediaProxyMetadataResponse} from '~/infrastructure/IMediaService';
interface BuildMediaOptions {
width?: number;
height?: number;
description?: string;
}
export function buildEmbedMediaPayload(
url: string,
metadata: MediaProxyMetadataResponse | null,
options: BuildMediaOptions = {},
): MessageEmbedResponse['image'] {
const flags =
(metadata?.animated ? MessageAttachmentFlags.IS_ANIMATED : 0) |
(metadata?.nsfw ? MessageAttachmentFlags.CONTAINS_EXPLICIT_MEDIA : 0);
return {
url,
width: options.width ?? metadata?.width ?? undefined,
height: options.height ?? metadata?.height ?? undefined,
description: options.description ?? undefined,
placeholder: metadata?.placeholder ?? undefined,
flags,
content_hash: metadata?.content_hash ?? undefined,
content_type: metadata?.content_type ?? undefined,
duration: metadata?.duration ?? undefined,
};
}

View File

@@ -0,0 +1,103 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ICacheService} from '~/infrastructure/ICacheService';
import {Logger} from '~/Logger';
import * as FetchUtils from '~/utils/FetchUtils';
import type {ActivityPubPost, MastodonInstance, MastodonPost} from './ActivityPubTypes';
export class ActivityPubFetcher {
constructor(private cacheService: ICacheService) {}
async fetchInstanceInfo(baseUrl: string): Promise<MastodonInstance | null> {
const cacheKey = `activitypub:instance:${baseUrl}`;
const cached = await this.cacheService.get<MastodonInstance>(cacheKey);
if (cached) return cached;
try {
const apiUrl = `${baseUrl}/api/v2/instance`;
Logger.debug({apiUrl}, 'Fetching instance info');
const response = await FetchUtils.sendRequest({
url: apiUrl,
method: 'GET',
timeout: 5000,
headers: {Accept: 'application/json'},
});
if (response.status !== 200) {
Logger.debug({apiUrl, status: response.status}, 'Instance info request failed');
return null;
}
const data = await FetchUtils.streamToString(response.stream);
const instanceInfo = JSON.parse(data) as MastodonInstance;
await this.cacheService.set(cacheKey, JSON.stringify(instanceInfo), 3600);
return instanceInfo;
} catch (error) {
Logger.error({error, baseUrl}, 'Failed to fetch instance info');
return null;
}
}
async tryFetchActivityPubData(url: string): Promise<ActivityPubPost | null> {
try {
const response = await FetchUtils.sendRequest({
url,
method: 'GET',
timeout: 5000,
headers: {
Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
});
if (response.status !== 200) {
Logger.debug({url, status: response.status}, 'Failed to fetch ActivityPub data');
return null;
}
const data = await FetchUtils.streamToString(response.stream);
const parsedData = JSON.parse(data);
if (!parsedData || typeof parsedData !== 'object' || !('id' in parsedData) || !('type' in parsedData)) {
Logger.debug({url}, 'Response is not a valid ActivityPub object');
return null;
}
return parsedData as ActivityPubPost;
} catch (error) {
Logger.error({error, url}, 'Failed to fetch or parse ActivityPub data');
return null;
}
}
async tryFetchMastodonApi(baseUrl: string, postId: string): Promise<MastodonPost | null> {
try {
const apiUrl = `${baseUrl}/api/v1/statuses/${postId}`;
Logger.debug({apiUrl}, 'Attempting to fetch from Mastodon API');
const response = await FetchUtils.sendRequest({
url: apiUrl,
method: 'GET',
timeout: 5000,
headers: {Accept: 'application/json'},
});
if (response.status !== 200) {
Logger.debug({apiUrl, status: response.status}, 'Mastodon API request failed');
return null;
}
const data = await FetchUtils.streamToString(response.stream);
return JSON.parse(data) as MastodonPost;
} catch (error) {
Logger.error({error, baseUrl, postId}, 'Failed to fetch or parse Mastodon API data');
return null;
}
}
}

View File

@@ -0,0 +1,326 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/ChannelModel';
import type {IMediaService} from '~/infrastructure/IMediaService';
import {Logger} from '~/Logger';
import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers';
import * as DOMUtils from '~/utils/DOMUtils';
import {parseString} from '~/utils/StringUtils';
import type {
ActivityPubAttachment,
ActivityPubAuthor,
ActivityPubContext,
ActivityPubPost,
MastodonMediaAttachment,
MastodonPost,
ProcessedMedia,
} from './ActivityPubTypes';
import {escapeMarkdownChars} from './ActivityPubUtils';
export class ActivityPubFormatter {
private static readonly DEFAULT_COLOR = 0x6364ff;
private static readonly FAVORITE_THRESHOLD = 100;
private static readonly MAX_ALT_TEXT_LENGTH = 4096;
constructor(private mediaService: IMediaService) {}
async processMedia(attachment: MastodonMediaAttachment | ActivityPubAttachment): Promise<ProcessedMedia | undefined> {
try {
if (!('url' in attachment) || attachment.url == null) return;
const url = attachment.url;
let altText: string | null = null;
if ('description' in attachment) {
altText = attachment.description;
Logger.debug({url, hasAltText: !!altText}, 'Found Mastodon alt text');
} else if ('name' in attachment) {
altText = attachment.name || null;
Logger.debug({url, hasAltText: !!altText}, 'Found ActivityPub alt text');
}
const metadata = await this.mediaService.getMetadata({type: 'external', url: url, isNSFWAllowed: false});
if (!metadata) {
Logger.debug({url}, 'Failed to get media metadata');
return;
}
let description: string | undefined;
if (altText) {
description = parseString(altText, ActivityPubFormatter.MAX_ALT_TEXT_LENGTH);
Logger.debug(
{url, originalAltLength: altText.length, processedAltLength: description.length},
'Added sanitized alt text as description',
);
}
const widthOverride = this.getAttachmentDimension(attachment, 'width') ?? metadata.width;
const heightOverride = this.getAttachmentDimension(attachment, 'height') ?? metadata.height;
return buildEmbedMediaPayload(url, metadata, {
width: widthOverride,
height: heightOverride,
description,
}) as ProcessedMedia;
} catch (error) {
Logger.error({error}, 'Failed to process media');
return;
}
}
formatMastodonContent(post: MastodonPost, context?: ActivityPubContext): string {
let text = DOMUtils.htmlToMarkdown(post.content);
if (post.spoiler_text) {
text = `**${post.spoiler_text}**\n\n${text}`;
}
if (post.reblog) {
const reblogAuthor = post.reblog.account.display_name || post.reblog.account.username;
const reblogText = DOMUtils.htmlToMarkdown(post.reblog.content);
text = `**Boosted from ${reblogAuthor}**\n\n${reblogText}`;
}
if (context?.inReplyTo) {
text = `-# ↩ [${context.inReplyTo.author}](${context.inReplyTo.url})\n${text}`;
}
return escapeMarkdownChars(text);
}
formatActivityPubContent(post: ActivityPubPost, context?: ActivityPubContext): string {
let text = post.content ? DOMUtils.htmlToMarkdown(post.content) : '';
if (post.summary) {
text = `**${post.summary}**\n\n${text}`;
}
if (context?.inReplyTo) {
text = `-# ↩ [${context.inReplyTo.author}](${context.inReplyTo.url})\n${text}`;
}
return escapeMarkdownChars(text);
}
async buildMastodonEmbed(post: MastodonPost, url: URL, context: ActivityPubContext): Promise<MessageEmbedResponse> {
const authorName = post.account.display_name || post.account.username;
const authorFullName = `${authorName} (@${post.account.username}@${context.serverDomain})`;
const authorUrl = post.account.url;
const content = this.formatMastodonContent(post, context);
let image: ProcessedMedia | undefined;
let video: ProcessedMedia | undefined;
let thumbnail: ProcessedMedia | undefined;
if (post.media_attachments?.length > 0) {
const firstMedia = post.media_attachments[0];
if (firstMedia.type === 'image' || firstMedia.type === 'gifv') {
image = await this.processMedia(firstMedia);
Logger.debug(
{
mediaType: firstMedia.type,
url: firstMedia.url,
hasAltText: !!firstMedia.description,
hasProcessedDescription: !!image?.description,
},
'Processed image media attachment',
);
} else if (firstMedia.type === 'video') {
video = await this.processMedia(firstMedia);
if (firstMedia.preview_url) {
const previewAttachment = {...firstMedia, url: firstMedia.preview_url};
thumbnail = await this.processMedia(previewAttachment);
}
Logger.debug(
{
mediaType: firstMedia.type,
url: firstMedia.url,
hasAltText: !!firstMedia.description,
hasVideoDescription: !!video?.description,
hasThumbnailDescription: !!thumbnail?.description,
},
'Processed video media attachment',
);
}
}
const fields = [];
if (post.favourites_count >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Favorites', value: post.favourites_count.toString(), inline: true});
if (post.reblogs_count >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Boosts', value: post.reblogs_count.toString(), inline: true});
if (post.replies_count >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Replies', value: post.replies_count.toString(), inline: true});
if (post.poll) {
const pollOptions = post.poll.options
.map((option) => {
const votes = option.votes_count != null ? `: ${option.votes_count}` : '';
return `${option.title}${votes}`;
})
.join('\n');
fields.push({name: `Poll (${post.poll.votes_count} votes)`, value: pollOptions, inline: false});
}
const embed: MessageEmbedResponse = {
type: 'rich',
url: url.toString(),
description: content,
color: ActivityPubFormatter.DEFAULT_COLOR,
timestamp: new Date(post.created_at).toISOString(),
author: {name: authorFullName, url: authorUrl, icon_url: post.account.avatar},
footer: {text: context.serverTitle, icon_url: context.serverIcon},
fields: fields.length > 0 ? fields : undefined,
};
if (image) {
embed.image = image;
}
if (video) {
embed.video = video;
if (thumbnail) {
embed.thumbnail = thumbnail;
}
}
return embed;
}
async buildActivityPubEmbed(
post: ActivityPubPost,
url: URL,
context: ActivityPubContext,
fetchAuthorData: (url: string) => Promise<ActivityPubPost | null>,
): Promise<MessageEmbedResponse> {
const isActivityPubAuthor = (data: unknown): data is ActivityPubAuthor =>
typeof data === 'object' &&
data !== null &&
('name' in data || 'preferredUsername' in data || 'url' in data || 'icon' in data);
let authorName = '';
let authorUrl = '';
let authorIcon = '';
if (typeof post.attributedTo === 'string') {
const authorData = await fetchAuthorData(post.attributedTo);
if (authorData) {
if (isActivityPubAuthor(authorData)) {
authorName = authorData.name || authorData.preferredUsername || '';
authorUrl = authorData.url || post.attributedTo;
authorIcon = authorData.icon?.url || '';
} else {
const authorUrlObj = new URL(post.attributedTo);
authorName = authorUrlObj.pathname.split('/').pop() || '';
authorUrl = post.attributedTo;
}
} else {
const authorUrlObj = new URL(post.attributedTo);
authorName = authorUrlObj.pathname.split('/').pop() || '';
authorUrl = post.attributedTo;
}
} else if (post.attributedTo && typeof post.attributedTo === 'object') {
const author = post.attributedTo as ActivityPubAuthor;
authorName = author.name || author.preferredUsername || '';
authorUrl = author.url || '';
authorIcon = author.icon?.url || '';
}
let authorFullName = authorName;
const authorUsername = authorUrl.split('/').pop() || '';
authorFullName = `${authorName} (@${authorUsername}@${context.serverDomain})`;
const content = this.formatActivityPubContent(post, context);
let image: ProcessedMedia | undefined;
let video: ProcessedMedia | undefined;
let thumbnail: ProcessedMedia | undefined;
if (post.attachment && post.attachment.length > 0) {
const firstMedia = post.attachment[0];
if (firstMedia.mediaType.startsWith('image/')) {
image = await this.processMedia(firstMedia);
Logger.debug(
{
mediaType: firstMedia.mediaType,
url: firstMedia.url,
hasAltText: !!firstMedia.name,
hasProcessedDescription: !!image?.description,
},
'Processed ActivityPub image attachment',
);
} else if (firstMedia.mediaType.startsWith('video/')) {
video = await this.processMedia(firstMedia);
const thumbnailAttachment = post.attachment?.find(
(a) => a.type === 'Image' && a.mediaType.startsWith('image/'),
);
if (thumbnailAttachment) thumbnail = await this.processMedia(thumbnailAttachment);
Logger.debug(
{
mediaType: firstMedia.mediaType,
url: firstMedia.url,
hasAltText: !!firstMedia.name,
hasVideoDescription: !!video?.description,
hasThumbnailDescription: !!thumbnail?.description,
},
'Processed ActivityPub video attachment',
);
}
}
const fields = [];
const likesCount = typeof post.likes === 'number' ? post.likes : 0;
const sharesCount = typeof post.shares === 'number' ? post.shares : 0;
const repliesCount = post.replies?.totalItems || 0;
if (likesCount >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Likes', value: likesCount.toString(), inline: true});
if (sharesCount >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Shares', value: sharesCount.toString(), inline: true});
if (repliesCount >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Replies', value: repliesCount.toString(), inline: true});
const embed: MessageEmbedResponse = {
type: 'rich',
url: url.toString(),
description: content,
color: ActivityPubFormatter.DEFAULT_COLOR,
timestamp: new Date(post.published).toISOString(),
author: {name: authorFullName, url: authorUrl, icon_url: authorIcon},
footer: {text: context.serverTitle, icon_url: context.serverIcon},
fields: fields.length > 0 ? fields : undefined,
};
if (image) embed.image = image;
if (video) {
embed.video = video;
if (thumbnail) embed.thumbnail = thumbnail;
}
return embed;
}
private getAttachmentDimension(
attachment: MastodonMediaAttachment | ActivityPubAttachment,
dimension: 'width' | 'height',
): number | undefined {
if (dimension === 'width' && 'width' in attachment && typeof attachment.width === 'number') {
return attachment.width;
}
if (dimension === 'height' && 'height' in attachment && typeof attachment.height === 'number') {
return attachment.height;
}
if (this.isMastodonMediaAttachment(attachment)) {
return attachment.meta.original?.[dimension] ?? attachment.meta.small?.[dimension];
}
return undefined;
}
private isMastodonMediaAttachment(
attachment: MastodonMediaAttachment | ActivityPubAttachment,
): attachment is MastodonMediaAttachment {
return 'meta' in attachment;
}
}

View File

@@ -0,0 +1,153 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/ChannelModel';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import {Logger} from '~/Logger';
import * as DOMUtils from '~/utils/DOMUtils';
import * as FetchUtils from '~/utils/FetchUtils';
import {ActivityPubFetcher} from './ActivityPubFetcher';
import {ActivityPubFormatter} from './ActivityPubFormatter';
import type {ActivityPubContext, MastodonPost} from './ActivityPubTypes';
import {extractAppleTouchIcon, extractPostId} from './ActivityPubUtils';
export class ActivityPubResolver {
private fetcher: ActivityPubFetcher;
private formatter: ActivityPubFormatter;
constructor(cacheService: ICacheService, mediaService: IMediaService) {
this.fetcher = new ActivityPubFetcher(cacheService);
this.formatter = new ActivityPubFormatter(mediaService);
}
private async tryFetchParentPost(inReplyToUrl: string): Promise<ActivityPubContext['inReplyTo'] | undefined> {
try {
const parentPost = await this.fetcher.tryFetchActivityPubData(inReplyToUrl);
if (!parentPost) return;
let authorName = '';
if (typeof parentPost.attributedTo === 'string') {
const authorUrl = new URL(parentPost.attributedTo);
authorName = authorUrl.pathname.split('/').pop() || '';
} else if (parentPost.attributedTo) {
authorName = parentPost.attributedTo.preferredUsername || parentPost.attributedTo.name || '';
}
const content = parentPost.content ? DOMUtils.htmlToMarkdown(parentPost.content) : '';
const urlObj = new URL(parentPost.url);
const idMatch = extractPostId(urlObj);
return {author: authorName, content, url: parentPost.url, id: idMatch || undefined};
} catch (error) {
Logger.error({error, inReplyToUrl}, 'Failed to fetch parent post');
return;
}
}
async resolveActivityPub(
url: URL,
activityPubUrl: string | null,
html: string,
): Promise<Array<MessageEmbedResponse> | null> {
try {
Logger.debug({url: url.toString()}, 'Resolving ActivityPub URL');
const postId = extractPostId(url);
if (!postId) {
Logger.debug({url: url.toString()}, 'No post ID found in URL');
return null;
}
const instanceInfo = await this.fetcher.fetchInstanceInfo(url.origin);
const appleTouchIcon = extractAppleTouchIcon(html, url);
const cleanedHostname = url.hostname.replace(/^(?:www\.|social\.|mstdn\.)/, '');
const context: ActivityPubContext = {
serverDomain: instanceInfo?.domain || cleanedHostname,
serverName: instanceInfo?.domain || cleanedHostname,
serverTitle: instanceInfo?.title || `${cleanedHostname} Mastodon`,
serverIcon: appleTouchIcon,
};
const mastodonPost = await this.fetcher.tryFetchMastodonApi(url.origin, postId);
if (mastodonPost) {
Logger.debug({url: url.toString(), postId}, 'Successfully fetched Mastodon API data');
if (mastodonPost.in_reply_to_id && mastodonPost.in_reply_to_account_id) {
try {
const parentPostUrl = `${url.origin}/api/v1/statuses/${mastodonPost.in_reply_to_id}`;
const response = await FetchUtils.sendRequest({
url: parentPostUrl,
method: 'GET',
timeout: 5000,
headers: {Accept: 'application/json'},
});
if (response.status === 200) {
const data = await FetchUtils.streamToString(response.stream);
const parentPost = JSON.parse(data) as MastodonPost;
const parentAuthor = parentPost.account.display_name || parentPost.account.username;
context.inReplyTo = {
author: parentAuthor,
content: DOMUtils.htmlToMarkdown(parentPost.content),
url: parentPost.url,
id: mastodonPost.in_reply_to_id,
};
}
} catch (error) {
Logger.error({error, inReplyToId: mastodonPost.in_reply_to_id}, 'Failed to fetch parent post for Mastodon');
}
}
const embed = await this.formatter.buildMastodonEmbed(mastodonPost, url, context);
return [embed];
}
if (activityPubUrl) {
Logger.debug({url: url.toString(), activityPubUrl}, 'Found ActivityPub link');
const activityPubPost = await this.fetcher.tryFetchActivityPubData(activityPubUrl);
if (activityPubPost) {
Logger.debug({url: url.toString(), postId}, 'Successfully fetched ActivityPub data');
if (activityPubPost.inReplyTo) {
const parentUrl = typeof activityPubPost.inReplyTo === 'string' ? activityPubPost.inReplyTo : null;
if (parentUrl) context.inReplyTo = await this.tryFetchParentPost(parentUrl);
}
const embed = await this.formatter.buildActivityPubEmbed(
activityPubPost,
url,
context,
this.fetcher.tryFetchActivityPubData.bind(this.fetcher),
);
return [embed];
}
}
if (url.pathname.includes('/notice/')) {
const noticeId = url.pathname.split('/notice/')[1]?.split('/')[0];
if (noticeId) {
const pleromaApiUrl = `${url.origin}/api/v1/statuses/${noticeId}`;
Logger.debug({pleromaApiUrl}, 'Trying Pleroma-compatible API endpoint');
const pleromaPost = await this.fetcher.tryFetchMastodonApi(url.origin, noticeId);
if (pleromaPost) {
Logger.debug({url: url.toString(), noticeId}, 'Successfully fetched Pleroma API data');
const embed = await this.formatter.buildMastodonEmbed(pleromaPost, url, context);
return [embed];
}
}
}
Logger.debug({url: url.toString()}, 'Could not resolve as ActivityPub');
return null;
} catch (error) {
Logger.error({error, url: url.toString()}, 'Failed to resolve ActivityPub URL');
return null;
}
}
}

View File

@@ -0,0 +1,198 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface ActivityPubAuthor {
id: string;
type: string;
name: string;
preferredUsername: string;
url: string;
icon?: {type: string; mediaType: string; url: string};
}
export interface ActivityPubAttachment {
type: string;
mediaType: string;
url: string;
width?: number;
height?: number;
name?: string;
blurhash?: string;
}
export interface ActivityPubPost {
id: string;
type: string;
url: string;
published: string;
attributedTo: string | ActivityPubAuthor;
content: string;
summary?: string;
sensitive?: boolean;
attachment?: Array<ActivityPubAttachment>;
tag?: Array<{type: string; name: string; href?: string}>;
to?: Array<string>;
cc?: Array<string>;
inReplyTo?: string | null;
likes?: number;
shares?: number;
replies?: {totalItems?: number};
}
export interface MastodonPost {
id: string;
created_at: string;
in_reply_to_id: string | null;
in_reply_to_account_id: string | null;
sensitive: boolean;
spoiler_text: string;
visibility: string;
language: string | null;
uri: string;
url: string;
replies_count: number;
reblogs_count: number;
favourites_count: number;
edited_at: string | null;
content: string;
reblog: MastodonPost | null;
application: {name: string} | null;
account: MastodonAccount;
media_attachments: Array<MastodonMediaAttachment>;
mentions: Array<MastodonMention>;
tags: Array<MastodonTag>;
emojis: Array<MastodonEmoji>;
card: MastodonCard | null;
poll: MastodonPoll | null;
}
export interface MastodonInstance {
domain: string;
title: string;
version: string;
source_url: string;
description: string;
usage: {users: {active_month: number}};
thumbnail: {url: string};
languages: Array<string>;
configuration: {urls: {streaming: string; status: string | null}};
registrations: {enabled: boolean; approval_required: boolean; message: string; url: string | null};
contact: {email: string; account: MastodonAccount};
rules: Array<{id: string; text: string; hint: string}>;
}
interface MastodonAccount {
id: string;
username: string;
acct: string;
url: string;
display_name: string;
note: string;
avatar: string;
avatar_static: string;
header: string;
header_static: string;
locked: boolean;
fields: Array<{name: string; value: string}>;
emojis: Array<MastodonEmoji>;
bot: boolean;
group: boolean;
discoverable: boolean | null;
}
export interface MastodonMediaAttachment {
id: string;
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio';
url: string;
preview_url: string;
remote_url: string | null;
meta: {
original?: {width: number; height: number; size: string; aspect: number};
small?: {width: number; height: number; size: string; aspect: number};
};
description: string | null;
blurhash: string | null;
}
interface MastodonMention {
id: string;
username: string;
url: string;
acct: string;
}
interface MastodonTag {
name: string;
url: string;
}
interface MastodonEmoji {
shortcode: string;
url: string;
static_url: string;
visible_in_picker: boolean;
}
interface MastodonCard {
url: string;
title: string;
description: string;
type: string;
author_name: string;
author_url: string;
provider_name: string;
provider_url: string;
html: string;
width: number;
height: number;
image: string | null;
embed_url: string;
}
interface MastodonPoll {
id: string;
expires_at: string | null;
expired: boolean;
multiple: boolean;
votes_count: number;
voters_count: number | null;
voted: boolean | null;
own_votes: Array<number> | null;
options: Array<{title: string; votes_count: number | null}>;
emojis: Array<MastodonEmoji>;
}
export interface ProcessedMedia {
url: string;
proxy_url?: string;
width: number;
height: number;
placeholder?: string;
flags: number;
description?: string;
content_type?: string;
content_hash?: string;
duration?: number;
}
export interface ActivityPubContext {
inReplyTo?: {author: string; content: string; url: string; id?: string};
serverName: string;
serverTitle: string;
serverIcon?: string;
serverDomain: string;
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {selectOne} from 'css-select';
import type {Element} from 'domhandler';
import {parseDocument} from 'htmlparser2';
import {Logger} from '~/Logger';
export function extractPostId(url: URL): string | null {
const mastodonMatch = url.pathname.match(/\/@([^/]+)\/(\w+)/);
if (mastodonMatch) return mastodonMatch[2];
const altMastodonMatch = url.pathname.match(/\/users\/[^/]+\/status(?:es)?\/(\w+)/);
if (altMastodonMatch) return altMastodonMatch[1];
const genericMatch = url.pathname.match(/\/[^/]+\/status(?:es)?\/(\w+)/);
if (genericMatch) return genericMatch[1];
const pleromaMatch = url.pathname.match(/\/notice\/([a-zA-Z0-9]+)/);
if (pleromaMatch) return pleromaMatch[1];
const misskeyMatch = url.pathname.match(/\/notes\/([a-zA-Z0-9]+)/);
if (misskeyMatch) return misskeyMatch[1];
Logger.debug({url: url.toString()}, 'Could not extract post ID from URL');
return null;
}
export function extractAppleTouchIcon(html: string, url: URL): string | undefined {
Logger.debug({url: url.toString()}, 'Attempting to extract apple touch icon');
try {
const document = parseDocument(html);
const appleTouchIcon180 = selectOne('link[rel="apple-touch-icon"][sizes="180x180"]', document) as Element | null;
if (appleTouchIcon180?.attribs.href) {
const iconPath = appleTouchIcon180.attribs.href;
const fullPath = iconPath.startsWith('http') ? iconPath : new URL(iconPath, url.origin).toString();
Logger.debug({iconPath, fullPath}, 'Found 180x180 apple touch icon');
return fullPath;
}
const anyAppleTouchIcon = selectOne('link[rel="apple-touch-icon"]', document) as Element | null;
if (anyAppleTouchIcon?.attribs.href) {
const iconPath = anyAppleTouchIcon.attribs.href;
const fullPath = iconPath.startsWith('http') ? iconPath : new URL(iconPath, url.origin).toString();
Logger.debug({iconPath, fullPath}, 'Found fallback apple touch icon');
return fullPath;
}
Logger.debug('No apple touch icon found');
return;
} catch (error) {
Logger.error({error}, 'Error parsing HTML for apple touch icon');
return;
}
}
export function escapeMarkdownChars(text: string): string {
return text
.replace(/\\\[/g, '\\[')
.replace(/\\\]/g, '\\]')
.replace(/\\\(/g, '\\(')
.replace(/\\\)/g, '\\)')
.replace(/\\-/g, '\\-');
}