199 lines
7.4 KiB
TypeScript
199 lines
7.4 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
import { setTimeout } from 'node:timers/promises';
|
|
import * as Redis from 'ioredis';
|
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
|
import { In } from 'typeorm';
|
|
import { DI } from '@/di-symbols.js';
|
|
import type { UsersRepository } from '@/models/_.js';
|
|
import type { MiUser } from '@/models/User.js';
|
|
import type { MiNotification } from '@/models/Notification.js';
|
|
import { bindThis } from '@/decorators.js';
|
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
|
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
|
|
import { IdService } from '@/core/IdService.js';
|
|
import { CacheService } from '@/core/CacheService.js';
|
|
import type { Config } from '@/config.js';
|
|
import { UserListService } from '@/core/UserListService.js';
|
|
|
|
@Injectable()
|
|
export class NotificationService implements OnApplicationShutdown {
|
|
#shutdownController = new AbortController();
|
|
|
|
constructor(
|
|
@Inject(DI.config)
|
|
private config: Config,
|
|
|
|
@Inject(DI.redis)
|
|
private redisClient: Redis.Redis,
|
|
|
|
@Inject(DI.usersRepository)
|
|
private usersRepository: UsersRepository,
|
|
|
|
private notificationEntityService: NotificationEntityService,
|
|
private idService: IdService,
|
|
private globalEventService: GlobalEventService,
|
|
private pushNotificationService: PushNotificationService,
|
|
private cacheService: CacheService,
|
|
private userListService: UserListService,
|
|
) {
|
|
}
|
|
|
|
@bindThis
|
|
public async readAllNotification(
|
|
userId: MiUser['id'],
|
|
force = false,
|
|
) {
|
|
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
|
|
|
|
const latestNotificationIdsRes = await this.redisClient.xrevrange(
|
|
`notificationTimeline:${userId}`,
|
|
'+',
|
|
'-',
|
|
'COUNT', 1);
|
|
const latestNotificationId = latestNotificationIdsRes[0]?.[0];
|
|
|
|
if (latestNotificationId == null) return;
|
|
|
|
this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId);
|
|
|
|
if (force || latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) {
|
|
return this.postReadAllNotifications(userId);
|
|
}
|
|
}
|
|
|
|
@bindThis
|
|
private postReadAllNotifications(userId: MiUser['id']) {
|
|
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
|
|
this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
|
|
}
|
|
|
|
@bindThis
|
|
public async createNotification(
|
|
notifieeId: MiUser['id'],
|
|
type: MiNotification['type'],
|
|
data: Omit<Partial<MiNotification>, 'notifierId'>,
|
|
notifierId?: MiUser['id'] | null,
|
|
): Promise<MiNotification | null> {
|
|
const profile = await this.cacheService.userProfileCache.fetch(notifieeId);
|
|
|
|
// 古いMisskeyバージョンのキャッシュが残っている可能性がある
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
const recieveConfig = (profile.notificationRecieveConfig ?? {})[type];
|
|
if (recieveConfig?.type === 'never') {
|
|
return null;
|
|
}
|
|
|
|
if (notifierId) {
|
|
if (notifieeId === notifierId) {
|
|
return null;
|
|
}
|
|
|
|
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId);
|
|
if (mutings.has(notifierId)) {
|
|
return null;
|
|
}
|
|
|
|
if (recieveConfig?.type === 'following') {
|
|
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId));
|
|
if (!isFollowing) {
|
|
return null;
|
|
}
|
|
} else if (recieveConfig?.type === 'follower') {
|
|
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId));
|
|
if (!isFollower) {
|
|
return null;
|
|
}
|
|
} else if (recieveConfig?.type === 'mutualFollow') {
|
|
const [isFollowing, isFollower] = await Promise.all([
|
|
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
|
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
|
]);
|
|
if (!isFollowing && !isFollower) {
|
|
return null;
|
|
}
|
|
} else if (recieveConfig?.type === 'list') {
|
|
const isMember = await this.userListService.membersCache.fetch(recieveConfig.userListId).then(members => members.has(notifierId));
|
|
if (!isMember) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
const notification = {
|
|
id: this.idService.gen(),
|
|
createdAt: new Date(),
|
|
type: type,
|
|
notifierId: notifierId,
|
|
...data,
|
|
} as MiNotification;
|
|
|
|
const redisIdPromise = this.redisClient.xadd(
|
|
`notificationTimeline:${notifieeId}`,
|
|
'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(),
|
|
'*',
|
|
'data', JSON.stringify(notification));
|
|
|
|
const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
|
|
|
|
// Publish notification event
|
|
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
|
|
|
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
|
|
setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
|
|
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
|
|
if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return;
|
|
|
|
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
|
|
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
|
|
|
|
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
|
|
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
|
|
}, () => { /* aborted, ignore it */ });
|
|
|
|
return notification;
|
|
}
|
|
|
|
// TODO
|
|
//const locales = await import('../../../../locales/index.js');
|
|
|
|
// TODO: locale ファイルをクライアント用とサーバー用で分けたい
|
|
|
|
@bindThis
|
|
private async emailNotificationFollow(userId: MiUser['id'], follower: MiUser) {
|
|
/*
|
|
const userProfile = await UserProfiles.findOneByOrFail({ userId: userId });
|
|
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return;
|
|
const locale = locales[userProfile.lang ?? 'ja-JP'];
|
|
const i18n = new I18n(locale);
|
|
// TODO: render user information html
|
|
sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
|
|
*/
|
|
}
|
|
|
|
@bindThis
|
|
private async emailNotificationReceiveFollowRequest(userId: MiUser['id'], follower: MiUser) {
|
|
/*
|
|
const userProfile = await UserProfiles.findOneByOrFail({ userId: userId });
|
|
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return;
|
|
const locale = locales[userProfile.lang ?? 'ja-JP'];
|
|
const i18n = new I18n(locale);
|
|
// TODO: render user information html
|
|
sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
|
|
*/
|
|
}
|
|
|
|
@bindThis
|
|
public dispose(): void {
|
|
this.#shutdownController.abort();
|
|
}
|
|
|
|
@bindThis
|
|
public onApplicationShutdown(signal?: string | undefined): void {
|
|
this.dispose();
|
|
}
|
|
}
|