initial commit
This commit is contained in:
43
fluxer_api/src/unfurler/resolvers/AudioResolver.ts
Normal file
43
fluxer_api/src/unfurler/resolvers/AudioResolver.ts
Normal 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),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
70
fluxer_api/src/unfurler/resolvers/BaseResolver.ts
Normal file
70
fluxer_api/src/unfurler/resolvers/BaseResolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
182
fluxer_api/src/unfurler/resolvers/BlueskyResolver.ts
Normal file
182
fluxer_api/src/unfurler/resolvers/BlueskyResolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
371
fluxer_api/src/unfurler/resolvers/DefaultResolver.ts
Normal file
371
fluxer_api/src/unfurler/resolvers/DefaultResolver.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
150
fluxer_api/src/unfurler/resolvers/HackerNewsResolver.ts
Normal file
150
fluxer_api/src/unfurler/resolvers/HackerNewsResolver.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
42
fluxer_api/src/unfurler/resolvers/ImageResolver.ts
Normal file
42
fluxer_api/src/unfurler/resolvers/ImageResolver.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
75
fluxer_api/src/unfurler/resolvers/TenorResolver.ts
Normal file
75
fluxer_api/src/unfurler/resolvers/TenorResolver.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
42
fluxer_api/src/unfurler/resolvers/VideoResolver.ts
Normal file
42
fluxer_api/src/unfurler/resolvers/VideoResolver.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
166
fluxer_api/src/unfurler/resolvers/WikipediaResolver.ts
Normal file
166
fluxer_api/src/unfurler/resolvers/WikipediaResolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
84
fluxer_api/src/unfurler/resolvers/XkcdResolver.ts
Normal file
84
fluxer_api/src/unfurler/resolvers/XkcdResolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
194
fluxer_api/src/unfurler/resolvers/YouTubeResolver.ts
Normal file
194
fluxer_api/src/unfurler/resolvers/YouTubeResolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
154
fluxer_api/src/unfurler/resolvers/bluesky/BlueskyApiClient.ts
Normal file
154
fluxer_api/src/unfurler/resolvers/bluesky/BlueskyApiClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
149
fluxer_api/src/unfurler/resolvers/bluesky/BlueskyTypes.ts
Normal file
149
fluxer_api/src/unfurler/resolvers/bluesky/BlueskyTypes.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '\\-');
|
||||
}
|
||||
Reference in New Issue
Block a user