From 72f78e2bfbcf000a6f374a92894430cf845fd1fd Mon Sep 17 00:00:00 2001 From: UZQueen <157540577+HanaokaYuzu@users.noreply.github.com> Date: Mon, 3 Feb 2025 08:12:57 -0600 Subject: [PATCH] feat(route): add misskey home timeline (#18244) * feat(route): add misskey home timeline * Update lib/routes/misskey/home-timeline.ts Co-authored-by: Tony * Merge remote-tracking branch 'head/master' * feat(route/misskey): Enhance user timeline with simplifyAuthor parameter and update replyToAuthor in utils --------- --- lib/config.ts | 6 ++ lib/routes/misskey/home-timeline.ts | 101 ++++++++++++++++++++++++++++ lib/routes/misskey/user-timeline.ts | 20 +++--- lib/routes/misskey/utils.ts | 12 ++-- 4 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 lib/routes/misskey/home-timeline.ts diff --git a/lib/config.ts b/lib/config.ts index ae084fab5..2afbce8a7 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -238,6 +238,9 @@ export type Config = { instance?: string; token?: string; }; + misskey: { + accessToken?: string; + }; mox: { cookie: string; }; @@ -669,6 +672,9 @@ const calculateValue = () => { instance: envs.MINIFLUX_INSTANCE || 'https://reader.miniflux.app', token: envs.MINIFLUX_TOKEN || '', }, + misskey: { + accessToken: envs.MISSKEY_ACCESS_TOKEN, + }, mox: { cookie: envs.MOX_COOKIE, }, diff --git a/lib/routes/misskey/home-timeline.ts b/lib/routes/misskey/home-timeline.ts new file mode 100644 index 000000000..8e1ac5dc7 --- /dev/null +++ b/lib/routes/misskey/home-timeline.ts @@ -0,0 +1,101 @@ +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import { Route, ViewType } from '@/types'; +import got from '@/utils/got'; +import { queryToBoolean } from '@/utils/readable-social'; +import querystring from 'querystring'; +import utils from './utils'; + +export const route: Route = { + path: '/timeline/home/:site/:routeParams?', + categories: ['social-media'], + view: ViewType.SocialMedia, + example: '/misskey/timeline/home/misskey.io', + parameters: { + site: 'instance address, domain only, without `http://` or `https://` protocol header', + routeParams: ` + | Key | Description | Accepted Values | Default | + | -------------------- | --------------------------------------- | --------------- | ------- | + | limit | Number of notes to return | integer | 10 | + | withFiles | Only return notes containing files | 0/1/true/false | false | + | withRenotes | Include renotes in the timeline | 0/1/true/false | true | + | allowPartial | Allow partial results | 0/1/true/false | true | + | simplifyAuthor | Simplify author field in feed items | 0/1/true/false | true | + + Note: If \`withFiles\` is set to true, renotes will not be included in the timeline regardless of the value of \`withRenotes\`. + + Examples: + - /misskey/timeline/home/misskey.io/limit=20&withFiles=true + - /misskey/timeline/home/misskey.io/withRenotes=false + `, + }, + features: { + requireConfig: [ + { + name: 'MISSKEY_ACCESS_TOKEN', + optional: false, + description: ` + Access token for Misskey API. Requires \`read:account\` access. + + Visit the specified site's settings page to obtain an access token. E.g. https://misskey.io/settings/api + `, + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['misskey.io'], + }, + ], + name: 'Home Timeline', + maintainers: ['HanaokaYuzu'], + handler, + description: ` + ::: warning + This route is only available for self-hosted instances. + ::: + `, +}; + +async function handler(ctx) { + const access_token = config.misskey.accessToken; + if (!access_token) { + throw new ConfigNotFoundError('Missing access token for Misskey API. Please set `MISSKEY_ACCESS_TOKEN` environment variable.'); + } + + const site = ctx.req.param('site'); + if (!config.feature.allow_user_supply_unsafe_domain && !utils.allowSiteList.includes(site)) { + throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`); + } + + // docs on: https://misskey.io/api-doc#tag/notes/operation/notes___timeline + const url = `https://${site}/api/notes/timeline`; + const routeParams = querystring.parse(ctx.req.param('routeParams')); + const response = await got({ + method: 'post', + url, + headers: { + Authorization: `Bearer ${access_token}`, + }, + json: { + limit: Number(routeParams.limit ?? 10), + withFiles: queryToBoolean(routeParams.withFiles ?? false), + withRenotes: queryToBoolean(routeParams.withRenotes ?? true), + allowPartial: queryToBoolean(routeParams.allowPartial ?? true), + }, + }); + + const list = response.data; + const simplifyAuthor = queryToBoolean(routeParams.simplifyAuthor ?? true); + + return { + title: `Home Timeline on ${site}`, + link: `https://${site}`, + item: utils.parseNotes(list, site, simplifyAuthor), + }; +} diff --git a/lib/routes/misskey/user-timeline.ts b/lib/routes/misskey/user-timeline.ts index 3cbdc24ad..bd6dcd71c 100644 --- a/lib/routes/misskey/user-timeline.ts +++ b/lib/routes/misskey/user-timeline.ts @@ -1,10 +1,10 @@ -import { Route, ViewType, Data } from '@/types'; -import utils from './utils'; import { config } from '@/config'; import ConfigNotFoundError from '@/errors/types/config-not-found'; import InvalidParameterError from '@/errors/types/invalid-parameter'; +import { Data, Route, ViewType } from '@/types'; import { fallback, queryToBoolean } from '@/utils/readable-social'; import querystring from 'querystring'; +import utils from './utils'; export const route: Route = { path: '/users/notes/:username/:routeParams?', @@ -14,10 +14,11 @@ export const route: Route = { parameters: { username: 'Misskey username in the format of username@instance.domain', routeParams: ` -| Key | Description | Accepted Values | Default | -| ----------- | ---------------------------------- | --------------- | ------- | -| withRenotes | Include renotes in the timeline | 0/1/true/false | false | -| mediaOnly | Only return posts containing media | 0/1/true/false | false | +| Key | Description | Accepted Values | Default | +| ----------------- | --------------------------------------- | --------------- | ------- | +| withRenotes | Include renotes in the timeline | 0/1/true/false | false | +| mediaOnly | Only return posts containing media | 0/1/true/false | false | +| simplifyAuthor | Simplify author field in feed items | 0/1/true/false | false | Note: \`withRenotes\` and \`mediaOnly\` are mutually exclusive and cannot both be set to true. @@ -34,7 +35,7 @@ Examples: supportScihub: false, }, name: 'User timeline', - maintainers: ['siygle', 'SnowAgar25'], + maintainers: ['siygle', 'SnowAgar25', 'HanaokaYuzu'], handler, }; @@ -51,6 +52,7 @@ async function handler(ctx): Promise { const routeParams = querystring.parse(ctx.req.param('routeParams')); const withRenotes = fallback(undefined, queryToBoolean(routeParams.withRenotes), false); const mediaOnly = fallback(undefined, queryToBoolean(routeParams.mediaOnly), false); + const simplifyAuthor = fallback(undefined, queryToBoolean(routeParams.simplifyAuthor), false); // Check for conflicting parameters if (withRenotes && mediaOnly) { @@ -65,7 +67,7 @@ async function handler(ctx): Promise { return { title: `User timeline for ${username} on ${site}`, link: `https://${site}/@${pureUsername}`, - image: avatarUrl || '', - item: utils.parseNotes(accountData, site), + image: avatarUrl ?? '', + item: utils.parseNotes(accountData, site, simplifyAuthor), }; } diff --git a/lib/routes/misskey/utils.ts b/lib/routes/misskey/utils.ts index 5d140c3af..e295da074 100644 --- a/lib/routes/misskey/utils.ts +++ b/lib/routes/misskey/utils.ts @@ -1,24 +1,24 @@ import { getCurrentPath } from '@/utils/helpers'; const __dirname = getCurrentPath(import.meta.url); -import { art } from '@/utils/render'; -import { parseDate } from '@/utils/parse-date'; -import path from 'node:path'; import cache from '@/utils/cache'; import got from '@/utils/got'; +import { parseDate } from '@/utils/parse-date'; +import { art } from '@/utils/render'; +import path from 'node:path'; import { MisskeyNote, MisskeyUser } from './types'; const allowSiteList = ['misskey.io', 'madost.one', 'mk.nixnet.social']; -const parseNotes = (data: MisskeyNote[], site: string) => +const parseNotes = (data: MisskeyNote[], site: string, simplifyAuthor: boolean = false) => data.map((item: MisskeyNote) => { const isRenote = item.renote && Object.keys(item.renote).length > 0; const isReply = item.reply && Object.keys(item.reply).length > 0; const noteToUse: MisskeyNote = isRenote ? (item.renote as MisskeyNote) : item; const host = noteToUse.user.host ?? site; - const author = `${noteToUse.user.name} (${noteToUse.user.username}@${host})`; + const author = simplifyAuthor ? String(noteToUse.user.name) : `${noteToUse.user.name} (${noteToUse.user.username}@${host})`; const description = art(path.join(__dirname, 'templates/note.art'), { text: noteToUse.text, @@ -30,7 +30,7 @@ const parseNotes = (data: MisskeyNote[], site: string) => let title = ''; if (isReply && item.reply) { const replyToHost = item.reply.user.host ?? site; - const replyToAuthor = `${item.reply.user.name} (${item.reply.user.username}@${replyToHost})`; + const replyToAuthor = simplifyAuthor ? item.reply.user.name : `${item.reply.user.name} (${item.reply.user.username}@${replyToHost})`; title = `Reply to ${replyToAuthor}: "${noteToUse.text ?? ''}"`; } else if (isRenote) { title = `Renote: ${author}: "${noteToUse.text ?? ''}"`;