diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts index f5426da3c..fb9621b85 100644 --- a/packages/backend/src/remote/activitypub/models/note.ts +++ b/packages/backend/src/remote/activitypub/models/note.ts @@ -24,7 +24,7 @@ import { DbResolver } from '../db-resolver.js'; import { apLogger } from '../logger.js'; import { resolvePerson } from './person.js'; import { resolveImage } from './image.js'; -import { extractApHashtags } from './tag.js'; +import { extractApHashtags, extractQuoteUrl } from './tag.js'; import { extractPollFromQuestion } from './question.js'; import { extractApMentions } from './mention.js'; @@ -154,10 +154,10 @@ export async function createNote(value: string | IObject, resolver: Resolver, si }) : null; - // 引用 let quote: Note | undefined | null; + const quoteUrl = extractQuoteUrl(note.tag); - if (note._misskey_quote || note.quoteUri) { + if (quoteUrl || note._misskey_quote || note.quoteUri) { const tryResolveNote = async (uri: string): Promise<{ status: 'ok'; res: Note | null; @@ -184,10 +184,16 @@ export async function createNote(value: string | IObject, resolver: Resolver, si } }; - const uris = unique([note._misskey_quote, note.quoteUri].filter((x): x is string => typeof x === 'string')); - const results = await Promise.all(uris.map(uri => tryResolveNote(uri))); - - quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); + const uris = unique([quoteUrl, note._misskey_quote, note.quoteUri].filter((x): x is string => typeof x === 'string')); + // check the urls sequentially and abort early to not do unnecessary HTTP requests + // picks the first one that works + for (const uri in uris) { + const res = await tryResolveNote(uri); + if (res.status === 'ok') { + quote = res.res; + break; + } + } if (!quote) { if (results.some(x => x.status === 'temperror')) { throw new Error('quote resolve failed'); diff --git a/packages/backend/src/remote/activitypub/models/tag.ts b/packages/backend/src/remote/activitypub/models/tag.ts index 964dabad0..182a23765 100644 --- a/packages/backend/src/remote/activitypub/models/tag.ts +++ b/packages/backend/src/remote/activitypub/models/tag.ts @@ -1,5 +1,5 @@ import { toArray } from '@/prelude/array.js'; -import { IObject, isHashtag, IApHashtag } from '../type.js'; +import { IObject, isHashtag, IApHashtag, isLink, ILink } from '../type.js'; export function extractApHashtags(tags: IObject | IObject[] | null | undefined) { if (tags == null) return []; @@ -16,3 +16,34 @@ export function extractApHashtagObjects(tags: IObject | IObject[] | null | undef if (tags == null) return []; return toArray(tags).filter(isHashtag); } + +// implements FEP-e232: Object Links (2022-12-23 version) +export function extractQuoteUrl(tags: IObject | IObject[] | null | undefined): string | null { + if (tags == null) return null; + + // filter out correct links + let quotes: ILink[] = toArray(tags) + .filter(isLink) + .filter(link => + [ + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'application/activity+json' + ].includes(link.mediaType?.toLowerCase()) + ) + .filter(link => + toArray(link.rel) + .some(rel => + [ + 'https://misskey-hub.net/ns#_misskey_quote', + 'http://fedibird.com/ns#quoteUri', + 'https://www.w3.org/ns/activitystreams#quoteUrl', + ].includes(rel) + ) + ); + + if (quotes.length === 0) return null; + + // Deduplicate by href. + // If there is more than one quote, we just pick the first/a random one. + quotes.filter((x, i, arr) => arr.findIndex(y => x.href === y.href) === i)[0].href; +} diff --git a/packages/backend/src/remote/activitypub/renderer/note.ts b/packages/backend/src/remote/activitypub/renderer/note.ts index 0d53cfe5e..6fd50c158 100644 --- a/packages/backend/src/remote/activitypub/renderer/note.ts +++ b/packages/backend/src/remote/activitypub/renderer/note.ts @@ -111,6 +111,16 @@ export default async function renderNote(note: Note, dive = true, isTalk = false ...apemojis, ]; + if (quote) { + tag.push({ + type: 'Link', + mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + href: quote, + name: `RE: ${quote}`, + rel: 'https://misskey-hub.net/ns#_misskey_quote', + }); + } + const asPoll = poll ? { type: 'Question', content: await toHtml(text, note.mentions), diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts index f5b411639..23b4ccf88 100644 --- a/packages/backend/src/remote/activitypub/type.ts +++ b/packages/backend/src/remote/activitypub/type.ts @@ -45,7 +45,7 @@ export function getOneApId(value: ApObject): string { /** * Get ActivityStreams Object id */ -export function getApId(value: string | IObject): string { +export function getApId(value: string | Object): string { if (typeof value === 'string') return value; if (typeof value.id === 'string') return value.id; throw new Error('cannot detemine id'); @@ -54,7 +54,7 @@ export function getApId(value: string | IObject): string { /** * Get ActivityStreams Object type */ -export function getApType(value: IObject): string { +export function getApType(value: Object): string { if (typeof value.type === 'string') return value.type; if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0]; throw new Error('cannot detect type'); @@ -196,24 +196,6 @@ export const isPropertyValue = (object: IObject): object is IApPropertyValue => typeof object.name === 'string' && typeof (object as any).value === 'string'; -export interface IApMention extends IObject { - type: 'Mention'; - href: string; -} - -export const isMention = (object: IObject): object is IApMention => - getApType(object) === 'Mention' && - typeof object.href === 'string'; - -export interface IApHashtag extends IObject { - type: 'Hashtag'; - name: string; -} - -export const isHashtag = (object: IObject): object is IApHashtag => - getApType(object) === 'Hashtag' && - typeof object.name === 'string'; - export interface IApEmoji extends IObject { type: 'Emoji'; updated: Date; @@ -293,3 +275,34 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; + +export interface ILink { + href: string; + rel?: string | string[]; + mediaType?: string; + name?: string; +} + +export interface IApMention extends ILink { + type: 'Mention'; +} + +export interface IApHashtag extends ILink { + type: 'Hashtag'; + name: string; +} + +export const isLink = (object: Record): object is ILink => + typeof object.href === 'string' + && ( + object.rel == undefined + || typeof object.rel === 'string' + || (Array.isArray(object.rel) && object.rel.every(x => typeof x === 'string')) + ) + && (object.mediaType == undefined || typeof object.mediaType === 'string'); +export const isMention = (object: Record): object is IApMention => + getApType(object) === 'Mention' && isLink(object); +export const isHashtag = (object: Record): object is IApHashtag => + getApType(object) === 'Hashtag' + && isLink(object) + && typeof object.name === 'string';