initial commit
This commit is contained in:
@@ -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