From 58dcbe68b72fba429276afe6260f2c5c5c211ddc Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Thu, 28 Sep 2023 18:16:11 +0200 Subject: [PATCH] [mastodon-client] GET /notifications --- .../src/server/api/mastodon/converters.ts | 2 +- .../server/api/mastodon/converters/note.ts | 4 +- .../api/mastodon/converters/notification.ts | 82 +++++++++++++++++++ .../api/mastodon/endpoints/notifications.ts | 41 +++++----- .../server/api/mastodon/endpoints/timeline.ts | 7 +- .../api/mastodon/entities/notification.ts | 2 +- .../api/mastodon/helpers/notification.ts | 46 +++++++++++ 7 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/converters/notification.ts create mode 100644 packages/backend/src/server/api/mastodon/helpers/notification.ts diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 6469d9c97..53fdafb83 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -27,7 +27,7 @@ export function convertFeaturedTag(tag: Entity.FeaturedTag) { return simpleConvert(tag); } -export function convertNotification(notification: Entity.Notification) { +export function convertNotification(notification: MastodonEntity.Notification) { notification.account = convertAccount(notification.account); notification.id = convertId(notification.id, IdType.MastodonId); if (notification.status) diff --git a/packages/backend/src/server/api/mastodon/converters/note.ts b/packages/backend/src/server/api/mastodon/converters/note.ts index fd0224a51..f1cd891de 100644 --- a/packages/backend/src/server/api/mastodon/converters/note.ts +++ b/packages/backend/src/server/api/mastodon/converters/note.ts @@ -33,7 +33,7 @@ export class NoteConverter { .map((x) => decodeReaction(x).reaction) .map((x) => x.replace(/:/g, "")); - const noteEmoji = Promise.resolve(host).then(async host => populateEmojis( + const noteEmoji = host.then(async host => populateEmojis( note.emojis.concat(reactionEmojiNames), host, )); @@ -95,7 +95,7 @@ export class NoteConverter { in_reply_to_id: note.replyId, in_reply_to_account_id: note.replyUserId, reblog: Promise.resolve(renote).then(renote => renote && note.text === null ? this.encode(renote, user, cache) : null), - content: Promise.resolve(text).then(text => text !== null ? toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers)) ?? escapeMFM(text) : ""), + content: text.then(text => text !== null ? toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers)) ?? escapeMFM(text) : ""), text: text, created_at: note.createdAt.toISOString(), // Remove reaction emojis with names containing @ from the emojis list. diff --git a/packages/backend/src/server/api/mastodon/converters/notification.ts b/packages/backend/src/server/api/mastodon/converters/notification.ts new file mode 100644 index 000000000..458c694d7 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/notification.ts @@ -0,0 +1,82 @@ +import { ILocalUser, User } from "@/models/entities/user.js"; +import config from "@/config/index.js"; +import { IMentionedRemoteUsers } from "@/models/entities/note.js"; +import { Notification } from "@/models/entities/notification.js"; +import { notificationTypes } from "@/types.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { getNote } from "@/server/api/common/getters.js"; + +type NotificationType = typeof notificationTypes[number]; + +export class NotificationConverter { + public static async encode(notification: Notification, localUser: ILocalUser, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise { + if (notification.notifieeId !== localUser.id) return null; + + //TODO: Test this (poll ended etc) + const account = notification.notifierId + ? UserHelpers.getUserCached(notification.notifierId, cache).then(p => UserConverter.encode(p)) + : UserConverter.encode(localUser); + + let result = { + id: notification.id, + account: account, + created_at: notification.createdAt.toISOString(), + type: this.encodeNotificationType(notification.type), + }; + + if (notification.note) { + const isPureRenote = notification.note.renoteId !== null && notification.note.text === null; + const encodedNote = isPureRenote + ? getNote(notification.note.renoteId!, localUser).then(note => NoteConverter.encode(note, localUser, cache)) + : NoteConverter.encode(notification.note, localUser, cache); + result = Object.assign(result, { + status: encodedNote, + }); + if (result.type === 'poll') { + result = Object.assign(result, { + account: encodedNote.then(p => p.account), + }); + } + if (notification.reaction) { + //FIXME: Implement reactions; + } + } + return awaitAll(result); + } + + public static async encodeMany(notifications: Notification[], localUser: ILocalUser, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise { + const encoded = notifications.map(u => this.encode(u, localUser, cache)); + return Promise.all(encoded) + .then(p => p.filter(n => n !== null) as MastodonEntity.Notification[]); + } + + private static encodeNotificationType(t: NotificationType): MastodonEntity.NotificationType { + //FIXME: Implement custom notification for followRequestAccepted + //FIXME: Implement mastodon notification type 'update' on misskey side + switch (t) { + case "follow": + return 'follow'; + case "mention": + case "reply": + return 'mention' + case "renote": + return 'reblog'; + case "quote": + return 'reblog'; + case "reaction": + return 'favourite'; + case "pollEnded": + return 'poll'; + case "receiveFollowRequest": + return 'follow_request'; + case "followRequestAccepted": + case "pollVote": + case "groupInvited": + case "app": + throw new Error(`Notification type ${t} not supported`); + } + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index c3ada1bbc..9a03bcfa5 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -1,10 +1,13 @@ -import megalodon, { MegalodonInterface } from "megalodon"; import Router from "@koa/router"; -import { koaBody } from "koa-body"; import { convertId, IdType } from "../../index.js"; import { getClient } from "../ApiMastodonCompatibleService.js"; -import { convertTimelinesArgsId } from "./timeline.js"; +import { convertTimelinesArgsId, limitToInt, normalizeUrlQuery } from "./timeline.js"; import { convertNotification } from "../converters.js"; +import authenticate from "@/server/api/authenticate.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { NotificationHelpers } from "@/server/api/mastodon/helpers/notification.js"; +import { NotificationConverter } from "@/server/api/mastodon/converters/notification.js"; + function toLimitToInt(q: any) { if (q.limit) if (typeof q.limit === "string") q.limit = parseInt(q.limit, 10); return q; @@ -12,25 +15,23 @@ function toLimitToInt(q: any) { export function apiNotificationsMastodon(router: Router): void { router.get("/v1/notifications", async (ctx) => { - const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`; - const accessTokens = ctx.request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); const body: any = ctx.request.body; try { - const data = await client.getNotifications( - convertTimelinesArgsId(toLimitToInt(ctx.query)), - ); - const notfs = data.data; - const ret = notfs.map((n) => { - n = convertNotification(n); - if (n.type !== "follow" && n.type !== "follow_request") { - if (n.type === "reaction") n.type = "favourite"; - return n; - } else { - return n; - } - }); - ctx.body = ret; + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const cache = UserHelpers.getFreshAccountCache(); + const args = normalizeUrlQuery(convertTimelinesArgsId(limitToInt(ctx.query)), ['types[]', 'exclude_types[]']); + const data = NotificationHelpers.getNotifications(user, args.max_id, args.since_id, args.min_id, args.limit, args['types[]'], args['exclude_types[]'], args.account_id) + .then(p => NotificationConverter.encodeMany(p, user, cache)) + .then(p => p.map(n => convertNotification(n))); + + ctx.body = await data; } catch (e: any) { console.error(e); ctx.status = 401; diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index bbdae6a63..ba6957487 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -61,11 +61,14 @@ export function convertTimelinesArgsId(q: ParsedUrlQuery) { return q; } -export function normalizeUrlQuery(q: ParsedUrlQuery): any { +export function normalizeUrlQuery(q: ParsedUrlQuery, arrayKeys: string[] = []): any { const dict: any = {}; for (const k in q) { - dict[k] = Array.isArray(q[k]) ? q[k]?.at(-1) : q[k]; + if (arrayKeys.includes(k)) + dict[k] = Array.isArray(q[k]) ? q[k] : [q[k]]; + else + dict[k] = Array.isArray(q[k]) ? q[k]?.at(-1) : q[k]; } return dict; diff --git a/packages/backend/src/server/api/mastodon/entities/notification.ts b/packages/backend/src/server/api/mastodon/entities/notification.ts index 2d3f440c1..e4041d8a6 100644 --- a/packages/backend/src/server/api/mastodon/entities/notification.ts +++ b/packages/backend/src/server/api/mastodon/entities/notification.ts @@ -11,5 +11,5 @@ namespace MastodonEntity { type: NotificationType; }; - export type NotificationType = string; + export type NotificationType = 'follow' | 'favourite' | 'reblog' | 'mention' | 'reaction' | 'follow_request' | 'status' | 'poll'; } diff --git a/packages/backend/src/server/api/mastodon/helpers/notification.ts b/packages/backend/src/server/api/mastodon/helpers/notification.ts new file mode 100644 index 000000000..f1766eef3 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/notification.ts @@ -0,0 +1,46 @@ +import { ILocalUser } from "@/models/entities/user.js"; +import { Notifications } from "@/models/index.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; +import { Notification } from "@/models/entities/notification.js"; +export class NotificationHelpers { + public static async getNotifications(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 15, types: string[] | undefined, excludeTypes: string[] | undefined, accountId: string | undefined): Promise { + if (limit > 30) limit = 30; + if (types && excludeTypes) throw new Error("types and exclude_types can not be used simultaneously"); + + let requestedTypes = types + ? this.decodeTypes(types) + : ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest']; + + if (excludeTypes) { + const excludedTypes = this.decodeTypes(excludeTypes); + requestedTypes = requestedTypes.filter(p => !excludedTypes.includes(p)); + } + + const query = PaginationHelpers.makePaginationQuery( + Notifications.createQueryBuilder("notification"), + sinceId, + maxId, + minId + ) + .andWhere("notification.notifieeId = :userId", { userId: user.id }) + .andWhere("notification.type IN (:...types)", { types: requestedTypes }); + + if (accountId !== undefined) + query.andWhere("notification.notifierId = :notifierId", { notifierId: accountId }); + + query.leftJoinAndSelect("notification.note", "note"); + + return PaginationHelpers.execQuery(query, limit, minId !== undefined); + } + + private static decodeTypes(types: string[]) { + const result: string[] = []; + if (types.includes('follow')) result.push('follow'); + if (types.includes('mention')) result.push('mention', 'reply'); + if (types.includes('reblog')) result.push('renote', 'quote'); + if (types.includes('favourite')) result.push('reaction'); + if (types.includes('poll')) result.push('pollEnded'); + if (types.includes('follow_request')) result.push('receiveFollowRequest'); + return result; + } +}