,
timeout?: number,
size?: number,
+ isLocalAddressAllowed?: boolean,
} = {},
extra: HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: true,
@@ -177,6 +289,8 @@ export class HttpRequestService {
controller.abort();
}, timeout);
+ const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
+
const res = await fetch(url, {
method: args.method ?? 'GET',
headers: {
@@ -185,7 +299,7 @@ export class HttpRequestService {
},
body: args.body,
size: args.size ?? 10 * 1024 * 1024,
- agent: (url) => this.getAgentByUrl(url),
+ agent: (url) => this.getAgentByUrl(url, false, isLocalAddressAllowed),
signal: controller.signal,
});
diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts
index ec630f804e..3d88d0aefe 100644
--- a/packages/backend/src/core/MetaService.ts
+++ b/packages/backend/src/core/MetaService.ts
@@ -52,7 +52,7 @@ export class MetaService implements OnApplicationShutdown {
switch (type) {
case 'metaUpdated': {
this.cache = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
- ...body,
+ ...(body.after),
proxyAccount: null, // joinなカラムは通常取ってこないので
};
break;
@@ -141,7 +141,7 @@ export class MetaService implements OnApplicationShutdown {
});
}
- this.globalEventService.publishInternalEvent('metaUpdated', updated);
+ this.globalEventService.publishInternalEvent('metaUpdated', { before, after: updated });
return updated;
}
diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts
index 74536c68f5..bf06d4457e 100644
--- a/packages/backend/src/core/MfmService.ts
+++ b/packages/backend/src/core/MfmService.ts
@@ -171,6 +171,39 @@ export class MfmService {
break;
}
+ case 'ruby': {
+ let ruby: [string, string][] = [];
+ for (const child of node.childNodes) {
+ if (child.nodeName === 'rp') {
+ continue;
+ }
+ if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
+ ruby.push([child.value, '']);
+ continue;
+ }
+ if (child.nodeName === 'rt' && ruby.length > 0) {
+ const rt = getText(child);
+ if (/\s|\[|\]/.test(rt)) {
+ // If any space is included in rt, it is treated as a normal text
+ ruby = [];
+ appendChildren(node.childNodes);
+ break;
+ } else {
+ ruby.at(-1)![1] = rt;
+ continue;
+ }
+ }
+ // If any other element is included in ruby, it is treated as a normal text
+ ruby = [];
+ appendChildren(node.childNodes);
+ break;
+ }
+ for (const [base, rt] of ruby) {
+ text += `$[ruby ${base} ${rt}]`;
+ }
+ break;
+ }
+
// block code ()
case 'pre': {
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
@@ -239,7 +272,7 @@ export class MfmService {
return null;
}
- const { window } = new Window();
+ const { happyDOM, window } = new Window();
const doc = window.document;
@@ -406,8 +439,10 @@ export class MfmService {
mention: (node) => {
const a = doc.createElement('a');
const { username, host, acct } = node.props;
- const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
- a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`);
+ const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
+ a.setAttribute('href', remoteUserInfo
+ ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
+ : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
a.className = 'u-url mention';
a.textContent = acct;
return a;
@@ -457,6 +492,10 @@ export class MfmService {
appendChildren(nodes, body);
- return new XMLSerializer().serializeToString(body);
+ const serialized = new XMLSerializer().serializeToString(body);
+
+ happyDOM.close().catch(err => {});
+
+ return serialized;
}
}
diff --git a/packages/backend/src/core/ModerationLogService.ts b/packages/backend/src/core/ModerationLogService.ts
index 6c155c9a62..2c02af217d 100644
--- a/packages/backend/src/core/ModerationLogService.ts
+++ b/packages/backend/src/core/ModerationLogService.ts
@@ -9,7 +9,8 @@ import type { ModerationLogsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { IdService } from '@/core/IdService.js';
import { bindThis } from '@/decorators.js';
-import { ModerationLogPayloads, moderationLogTypes } from '@/types.js';
+import type { ModerationLogPayloads } from '@/types.js';
+import { moderationLogTypes } from '@/types.js';
@Injectable()
export class ModerationLogService {
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index a2c3aaa701..8a79908e82 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -8,13 +8,12 @@ import * as mfm from 'mfm-js';
import { In, DataSource, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
-import RE2 from 're2';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
-import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
@@ -23,11 +22,8 @@ import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { IPoll } from '@/models/Poll.js';
import { MiPoll } from '@/models/Poll.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
-import { checkWordMute } from '@/misc/check-word-mute.js';
import type { MiChannel } from '@/models/Channel.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
-import { MemorySingleCache } from '@/misc/cache.js';
-import type { MiUserProfile } from '@/models/UserProfile.js';
import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { DI } from '@/di-symbols.js';
@@ -51,7 +47,6 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { bindThis } from '@/decorators.js';
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { RoleService } from '@/core/RoleService.js';
-import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
@@ -60,6 +55,8 @@ import { UserBlockingService } from '@/core/UserBlockingService.js';
import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { CollapsedQueue } from '@/misc/collapsed-queue.js';
+import { CacheService } from '@/core/CacheService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@@ -151,11 +148,15 @@ type Option = {
@Injectable()
export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController();
+ private updateNotesCountQueue: CollapsedQueue;
constructor(
@Inject(DI.config)
private config: Config,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
@Inject(DI.db)
private db: DataSource,
@@ -210,7 +211,6 @@ export class NoteCreateService implements OnApplicationShutdown {
private apDeliverManagerService: ApDeliverManagerService,
private apRendererService: ApRendererService,
private roleService: RoleService,
- private metaService: MetaService,
private searchService: SearchService,
private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart,
@@ -218,7 +218,10 @@ export class NoteCreateService implements OnApplicationShutdown {
private instanceChart: InstanceChart,
private utilityService: UtilityService,
private userBlockingService: UserBlockingService,
- ) { }
+ private cacheService: CacheService,
+ ) {
+ this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
+ }
@bindThis
public async create(user: {
@@ -251,10 +254,8 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.channel != null) data.visibleUsers = [];
if (data.channel != null) data.localOnly = true;
- const meta = await this.metaService.fetch();
-
if (data.visibility === 'public' && data.channel == null) {
- const sensitiveWords = meta.sensitiveWords;
+ const sensitiveWords = this.meta.sensitiveWords;
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) {
data.visibility = 'home';
} else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) {
@@ -262,17 +263,17 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
- const hasProhibitedWords = await this.checkProhibitedWordsContain({
+ const hasProhibitedWords = this.checkProhibitedWordsContain({
cw: data.cw,
text: data.text,
pollChoices: data.poll?.choices,
- }, meta.prohibitedWords);
+ }, this.meta.prohibitedWords);
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
}
- const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host);
+ const inSilencedInstance = this.utilityService.isSilencedHost(this.meta.silencedHosts, user.host);
if (data.visibility === 'public' && inSilencedInstance && user.host !== null) {
data.visibility = 'home';
@@ -364,6 +365,9 @@ export class NoteCreateService implements OnApplicationShutdown {
mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens);
}
+ // if the host is media-silenced, custom emojis are not allowed
+ if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = [];
+
tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32);
if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {
@@ -503,21 +507,21 @@ export class NoteCreateService implements OnApplicationShutdown {
host: MiUser['host'];
isBot: MiUser['isBot'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
- const meta = await this.metaService.fetch();
-
this.notesChart.update(note, true);
- if (meta.enableChartsForRemoteUser || (user.host == null)) {
+ if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) {
this.perUserNotesChart.update(user, note, true);
}
// Register host
- if (this.userEntityService.isRemoteUser(user)) {
- this.federatedInstanceService.fetch(user.host).then(async i => {
- this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
- if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
- this.instanceChart.updateNote(i.host, note, true);
- }
- });
+ if (this.meta.enableStatsForFederatedInstances) {
+ if (this.userEntityService.isRemoteUser(user)) {
+ this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
+ this.updateNotesCountQueue.enqueue(i.id, 1);
+ if (this.meta.enableChartsForFederatedInstances) {
+ this.instanceChart.updateNote(i.host, note, true);
+ }
+ });
+ }
}
// ハッシュタグ更新
@@ -541,13 +545,21 @@ export class NoteCreateService implements OnApplicationShutdown {
this.followingsRepository.findBy({
followeeId: user.id,
notify: 'normal',
- }).then(followings => {
+ }).then(async followings => {
if (note.visibility !== 'specified') {
+ const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
for (const following of followings) {
// TODO: ワードミュート考慮
- this.notificationService.createNotification(following.followerId, 'note', {
- noteId: note.id,
- }, user.id);
+ let isRenoteMuted = false;
+ if (isPureRenote) {
+ const userIdsWhoMeMutingRenotes = await this.cacheService.renoteMutingsCache.fetch(following.followerId);
+ isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id);
+ }
+ if (!isRenoteMuted) {
+ this.notificationService.createNotification(following.followerId, 'note', {
+ noteId: note.id,
+ }, user.id);
+ }
}
}
});
@@ -602,14 +614,7 @@ export class NoteCreateService implements OnApplicationShutdown {
this.roleService.addNoteToRoleTimeline(noteObj);
- this.webhookService.getActiveWebhooks().then(webhooks => {
- webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
- for (const webhook of webhooks) {
- this.queueService.userWebhookDeliver(webhook, 'note', {
- note: noteObj,
- });
- }
- });
+ this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj });
const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note);
@@ -629,13 +634,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!isThreadMuted) {
nm.push(data.reply.userId, 'reply');
this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj);
-
- const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
- for (const webhook of webhooks) {
- this.queueService.userWebhookDeliver(webhook, 'reply', {
- note: noteObj,
- });
- }
+ this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj });
}
}
}
@@ -652,20 +651,14 @@ export class NoteCreateService implements OnApplicationShutdown {
// Publish event
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj);
-
- const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
- for (const webhook of webhooks) {
- this.queueService.userWebhookDeliver(webhook, 'renote', {
- note: noteObj,
- });
- }
+ this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj });
}
}
nm.notify();
//#region AP deliver
- if (this.userEntityService.isLocalUser(user)) {
+ if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
(async () => {
const noteActivity = await this.renderNoteOrRenoteActivity(data, note);
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
@@ -784,13 +777,7 @@ export class NoteCreateService implements OnApplicationShutdown {
});
this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote);
-
- const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
- for (const webhook of webhooks) {
- this.queueService.userWebhookDeliver(webhook, 'mention', {
- note: detailPackedNote,
- });
- }
+ this.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote });
// Create notification
nm.push(u.id, 'mention');
@@ -850,15 +837,14 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
- const meta = await this.metaService.fetch();
- if (!meta.enableFanoutTimeline) return;
+ if (!this.meta.enableFanoutTimeline) return;
const r = this.redisForTimelines.pipeline();
if (note.channelId) {
this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
- this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
+ this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r);
const channelFollowings = await this.channelFollowingsRepository.find({
where: {
@@ -868,9 +854,9 @@ export class NoteCreateService implements OnApplicationShutdown {
});
for (const channelFollowing of channelFollowings) {
- this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
+ this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) {
- this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
+ this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
}
}
} else {
@@ -908,9 +894,9 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!following.withReplies) continue;
}
- this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
+ this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
if (note.fileIds.length > 0) {
- this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
+ this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
}
}
@@ -927,22 +913,25 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!userListMembership.withReplies) continue;
}
- this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
+ this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax, r);
if (note.fileIds.length > 0) {
- this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
+ this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax / 2, r);
}
}
- if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
- this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
- if (note.fileIds.length > 0) {
- this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
+ // 自分自身のHTL
+ if (note.userHost == null) {
+ if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) {
+ this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
+ if (note.fileIds.length > 0) {
+ this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
+ }
}
}
// 自分自身以外への返信
if (isReply(note)) {
- this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
+ this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r);
if (note.visibility === 'public' && note.userHost == null) {
this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r);
@@ -951,9 +940,9 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
} else {
- this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
+ this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r);
if (note.fileIds.length > 0) {
- this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
+ this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax / 2 : this.meta.perRemoteUserUserTimelineCacheMax / 2, r);
}
if (note.visibility === 'public' && note.userHost == null) {
@@ -1012,9 +1001,9 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
- public async checkProhibitedWordsContain(content: Parameters[0], prohibitedWords?: string[]) {
+ public checkProhibitedWordsContain(content: Parameters[0], prohibitedWords?: string[]) {
if (prohibitedWords == null) {
- prohibitedWords = (await this.metaService.fetch()).prohibitedWords;
+ prohibitedWords = this.meta.prohibitedWords;
}
if (
@@ -1030,12 +1019,23 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
- public dispose(): void {
- this.#shutdownController.abort();
+ private collapseNotesCount(oldValue: number, newValue: number) {
+ return oldValue + newValue;
}
@bindThis
- public onApplicationShutdown(signal?: string | undefined): void {
- this.dispose();
+ private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) {
+ await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy);
+ }
+
+ @bindThis
+ public async dispose(): Promise {
+ this.#shutdownController.abort();
+ await this.updateNotesCountQueue.performAllNow();
+ }
+
+ @bindThis
+ public async onApplicationShutdown(signal?: string | undefined): Promise {
+ await this.dispose();
}
}
diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts
index 801ed02e00..4ecd2592b2 100644
--- a/packages/backend/src/core/NoteDeleteService.ts
+++ b/packages/backend/src/core/NoteDeleteService.ts
@@ -7,7 +7,7 @@ import { Brackets, In } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common';
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
-import type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
+import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js';
import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { DI } from '@/di-symbols.js';
@@ -19,9 +19,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
-import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
@@ -32,6 +30,9 @@ export class NoteDeleteService {
@Inject(DI.config)
private config: Config,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -42,13 +43,11 @@ export class NoteDeleteService {
private instancesRepository: InstancesRepository,
private userEntityService: UserEntityService,
- private noteEntityService: NoteEntityService,
private globalEventService: GlobalEventService,
private relayService: RelayService,
private federatedInstanceService: FederatedInstanceService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
- private metaService: MetaService,
private searchService: SearchService,
private moderationLogService: ModerationLogService,
private notesChart: NotesChart,
@@ -92,7 +91,7 @@ export class NoteDeleteService {
this.deliverToConcerned(user, note, content);
}
- // also deliever delete activity to cascaded notes
+ // also deliver delete activity to cascaded notes
const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes
for (const cascadingNote of federatedLocalCascadingNotes) {
if (!cascadingNote.user) continue;
@@ -102,20 +101,20 @@ export class NoteDeleteService {
}
//#endregion
- const meta = await this.metaService.fetch();
-
this.notesChart.update(note, false);
- if (meta.enableChartsForRemoteUser || (user.host == null)) {
+ if (this.meta.enableChartsForRemoteUser || (user.host == null)) {
this.perUserNotesChart.update(user, note, false);
}
- if (this.userEntityService.isRemoteUser(user)) {
- this.federatedInstanceService.fetch(user.host).then(async i => {
- this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
- if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
- this.instanceChart.updateNote(i.host, note, false);
- }
- });
+ if (this.meta.enableStatsForFederatedInstances) {
+ if (this.userEntityService.isRemoteUser(user)) {
+ this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
+ this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
+ if (this.meta.enableChartsForFederatedInstances) {
+ this.instanceChart.updateNote(i.host, note, false);
+ }
+ });
+ }
}
}
diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts
index 71d663bf90..c3ff2a68d3 100644
--- a/packages/backend/src/core/ProxyAccountService.ts
+++ b/packages/backend/src/core/ProxyAccountService.ts
@@ -4,26 +4,25 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import type { UsersRepository } from '@/models/_.js';
+import type { MiMeta, UsersRepository } from '@/models/_.js';
import type { MiLocalUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
-import { MetaService } from '@/core/MetaService.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class ProxyAccountService {
constructor(
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
-
- private metaService: MetaService,
) {
}
@bindThis
public async fetch(): Promise {
- const meta = await this.metaService.fetch();
- if (meta.proxyAccountId == null) return null;
- return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as MiLocalUser;
+ if (this.meta.proxyAccountId == null) return null;
+ return await this.usersRepository.findOneByOrFail({ id: this.meta.proxyAccountId }) as MiLocalUser;
}
}
diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts
index 6a845b951d..1479bb00d9 100644
--- a/packages/backend/src/core/PushNotificationService.ts
+++ b/packages/backend/src/core/PushNotificationService.ts
@@ -10,8 +10,7 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { Packed } from '@/misc/json-schema.js';
import { getNoteSummary } from '@/misc/get-note-summary.js';
-import type { MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js';
-import { MetaService } from '@/core/MetaService.js';
+import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RedisKVCache } from '@/misc/cache.js';
@@ -54,13 +53,14 @@ export class PushNotificationService implements OnApplicationShutdown {
@Inject(DI.config)
private config: Config,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.swSubscriptionsRepository)
private swSubscriptionsRepository: SwSubscriptionsRepository,
-
- private metaService: MetaService,
) {
this.subscriptionsCache = new RedisKVCache(this.redisClient, 'userSwSubscriptions', {
lifetime: 1000 * 60 * 60 * 1, // 1h
@@ -73,14 +73,12 @@ export class PushNotificationService implements OnApplicationShutdown {
@bindThis
public async pushNotification(userId: string, type: T, body: PushNotificationsTypes[T]) {
- const meta = await this.metaService.fetch();
-
- if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
+ if (!this.meta.enableServiceWorker || this.meta.swPublicKey == null || this.meta.swPrivateKey == null) return;
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
push.setVapidDetails(this.config.url,
- meta.swPublicKey,
- meta.swPrivateKey);
+ this.meta.swPublicKey,
+ this.meta.swPrivateKey);
const subscriptions = await this.subscriptionsCache.fetch(userId);
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 80827a500b..da76dd1284 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -7,13 +7,15 @@ import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
-import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js';
+import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
+import { type SystemWebhookPayload } from '@/core/SystemWebhookService.js';
+import { type UserWebhookPayload } from './UserWebhookService.js';
import type {
DbJobData,
DeliverJobData,
@@ -30,8 +32,8 @@ import type {
ObjectStorageQueue,
RelationshipQueue,
SystemQueue,
- UserWebhookDeliverQueue,
SystemWebhookDeliverQueue,
+ UserWebhookDeliverQueue,
} from './QueueModule.js';
import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq';
@@ -87,6 +89,19 @@ export class QueueService {
repeat: { pattern: '*/5 * * * *' },
removeOnComplete: true,
});
+
+ this.systemQueue.add('bakeBufferedReactions', {
+ }, {
+ repeat: { pattern: '0 0 * * *' },
+ removeOnComplete: true,
+ });
+
+ this.systemQueue.add('checkModeratorsActivity', {
+ }, {
+ // 毎時30分に起動
+ repeat: { pattern: '30 * * * *' },
+ removeOnComplete: true,
+ });
}
@bindThis
@@ -452,10 +467,15 @@ export class QueueService {
/**
* @see UserWebhookDeliverJobData
- * @see WebhookDeliverProcessorService
+ * @see UserWebhookDeliverProcessorService
*/
@bindThis
- public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) {
+ public userWebhookDeliver(
+ webhook: MiWebhook,
+ type: T,
+ content: UserWebhookPayload,
+ opts?: { attempts?: number },
+ ) {
const data: UserWebhookDeliverJobData = {
type,
content,
@@ -468,7 +488,7 @@ export class QueueService {
};
return this.userWebhookDeliverQueue.add(webhook.id, data, {
- attempts: 4,
+ attempts: opts?.attempts ?? 4,
backoff: {
type: 'custom',
},
@@ -479,10 +499,15 @@ export class QueueService {
/**
* @see SystemWebhookDeliverJobData
- * @see WebhookDeliverProcessorService
+ * @see SystemWebhookDeliverProcessorService
*/
@bindThis
- public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) {
+ public systemWebhookDeliver(
+ webhook: MiSystemWebhook,
+ type: T,
+ content: SystemWebhookPayload,
+ opts?: { attempts?: number },
+ ) {
const data: SystemWebhookDeliverJobData = {
type,
content,
@@ -494,7 +519,7 @@ export class QueueService {
};
return this.systemWebhookDeliverQueue.add(webhook.id, data, {
- attempts: 4,
+ attempts: opts?.attempts ?? 4,
backoff: {
type: 'custom',
},
diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts
index 64c7b2ed03..6f9fe53937 100644
--- a/packages/backend/src/core/ReactionService.ts
+++ b/packages/backend/src/core/ReactionService.ts
@@ -4,9 +4,8 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
-import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js';
+import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta } from '@/models/_.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
@@ -21,7 +20,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
-import { MetaService } from '@/core/MetaService.js';
import { bindThis } from '@/decorators.js';
import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
@@ -30,9 +28,10 @@ import { RoleService } from '@/core/RoleService.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
+import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
+import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
const FALLBACK = '\u2764';
-const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
const legacies: Record = {
'like': '👍',
@@ -71,8 +70,8 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
@Injectable()
export class ReactionService {
constructor(
- @Inject(DI.redis)
- private redisClient: Redis.Redis,
+ @Inject(DI.meta)
+ private meta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -87,12 +86,12 @@ export class ReactionService {
private emojisRepository: EmojisRepository,
private utilityService: UtilityService,
- private metaService: MetaService,
private customEmojiService: CustomEmojiService,
private roleService: RoleService,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private userBlockingService: UserBlockingService,
+ private reactionsBufferingService: ReactionsBufferingService,
private idService: IdService,
private featuredService: FeaturedService,
private globalEventService: GlobalEventService,
@@ -148,6 +147,11 @@ export class ReactionService {
if ((note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && emoji.isSensitive) {
reaction = FALLBACK;
}
+
+ // for media silenced host, custom emoji reactions are not allowed
+ if (reacterHost != null && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, reacterHost)) {
+ reaction = FALLBACK;
+ }
} else {
// リアクションとして使う権限がない
reaction = FALLBACK;
@@ -167,7 +171,6 @@ export class ReactionService {
reaction,
};
- // Create reaction
try {
await this.noteReactionsRepository.insert(record);
} catch (e) {
@@ -191,16 +194,20 @@ export class ReactionService {
}
// Increment reactions count
- const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
- await this.notesRepository.createQueryBuilder().update()
- .set({
- reactions: () => sql,
- ...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? {
- reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`,
- } : {}),
- })
- .where('id = :id', { id: note.id })
- .execute();
+ if (this.meta.enableReactionsBuffering) {
+ await this.reactionsBufferingService.create(note.id, user.id, reaction, note.reactionAndUserPairCache);
+ } else {
+ const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
+ await this.notesRepository.createQueryBuilder().update()
+ .set({
+ reactions: () => sql,
+ ...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? {
+ reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`,
+ } : {}),
+ })
+ .where('id = :id', { id: note.id })
+ .execute();
+ }
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
if (
@@ -220,9 +227,7 @@ export class ReactionService {
}
}
- const meta = await this.metaService.fetch();
-
- if (meta.enableChartsForRemoteUser || (user.host == null)) {
+ if (this.meta.enableChartsForRemoteUser || (user.host == null)) {
this.perUserReactionsChart.update(user, note);
}
@@ -300,14 +305,18 @@ export class ReactionService {
}
// Decrement reactions count
- const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
- await this.notesRepository.createQueryBuilder().update()
- .set({
- reactions: () => sql,
- reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`,
- })
- .where('id = :id', { id: note.id })
- .execute();
+ if (this.meta.enableReactionsBuffering) {
+ await this.reactionsBufferingService.delete(note.id, user.id, exist.reaction);
+ } else {
+ const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
+ await this.notesRepository.createQueryBuilder().update()
+ .set({
+ reactions: () => sql,
+ reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`,
+ })
+ .where('id = :id', { id: note.id })
+ .execute();
+ }
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
reaction: this.decodeReaction(exist.reaction).reaction,
@@ -329,8 +338,21 @@ export class ReactionService {
}
/**
- * 文字列タイプのレガシーな形式のリアクションを現在の形式に変換しつつ、
- * データベース上には存在する「0個のリアクションがついている」という情報を削除する。
+ * - 文字列タイプのレガシーな形式のリアクションを現在の形式に変換する
+ * - ローカルのリアクションのホストを `@.` にする(`decodeReaction()`の効果)
+ */
+ @bindThis
+ public convertLegacyReaction(reaction: string): string {
+ reaction = this.decodeReaction(reaction).reaction;
+ if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
+ return reaction;
+ }
+
+ // TODO: 廃止
+ /**
+ * - 文字列タイプのレガシーな形式のリアクションを現在の形式に変換する
+ * - ローカルのリアクションのホストを `@.` にする(`decodeReaction()`の効果)
+ * - データベース上には存在する「0個のリアクションがついている」という情報を削除する
*/
@bindThis
public convertLegacyReactions(reactions: MiNote['reactions']): MiNote['reactions'] {
@@ -343,10 +365,7 @@ export class ReactionService {
return count > 0;
})
.map(([reaction, count]) => {
- // unchecked indexed access
- const convertedReaction = legacies[reaction] as string | undefined;
-
- const key = this.decodeReaction(convertedReaction ?? reaction).reaction;
+ const key = this.convertLegacyReaction(reaction);
return [key, count] as const;
})
@@ -401,11 +420,4 @@ export class ReactionService {
host: undefined,
};
}
-
- @bindThis
- public convertLegacyReaction(reaction: string): string {
- reaction = this.decodeReaction(reaction).reaction;
- if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
- return reaction;
- }
}
diff --git a/packages/backend/src/core/ReactionsBufferingService.ts b/packages/backend/src/core/ReactionsBufferingService.ts
new file mode 100644
index 0000000000..b4207c5106
--- /dev/null
+++ b/packages/backend/src/core/ReactionsBufferingService.ts
@@ -0,0 +1,211 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import { DI } from '@/di-symbols.js';
+import type { MiNote } from '@/models/Note.js';
+import { bindThis } from '@/decorators.js';
+import type { MiUser, NotesRepository } from '@/models/_.js';
+import type { Config } from '@/config.js';
+import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
+import type { OnApplicationShutdown } from '@nestjs/common';
+
+const REDIS_DELTA_PREFIX = 'reactionsBufferDeltas';
+const REDIS_PAIR_PREFIX = 'reactionsBufferPairs';
+
+@Injectable()
+export class ReactionsBufferingService implements OnApplicationShutdown {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.redisForSub)
+ private redisForSub: Redis.Redis,
+
+ @Inject(DI.redisForReactions)
+ private redisForReactions: Redis.Redis, // TODO: 専用のRedisインスタンスにする
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+ ) {
+ this.redisForSub.on('message', this.onMessage);
+ }
+
+ @bindThis
+ private async onMessage(_: string, data: string) {
+ const obj = JSON.parse(data);
+
+ if (obj.channel === 'internal') {
+ const { type, body } = obj.message as GlobalEvents['internal']['payload'];
+ switch (type) {
+ case 'metaUpdated': {
+ // リアクションバッファリングが有効→無効になったら即bake
+ if (body.before != null && body.before.enableReactionsBuffering && !body.after.enableReactionsBuffering) {
+ this.bake();
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ }
+
+ @bindThis
+ public async create(noteId: MiNote['id'], userId: MiUser['id'], reaction: string, currentPairs: string[]): Promise {
+ const pipeline = this.redisForReactions.pipeline();
+ pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, 1);
+ for (let i = 0; i < currentPairs.length; i++) {
+ pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, i, currentPairs[i]);
+ }
+ pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, Date.now(), `${userId}/${reaction}`);
+ pipeline.zremrangebyrank(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -(PER_NOTE_REACTION_USER_PAIR_CACHE_MAX + 1));
+ await pipeline.exec();
+ }
+
+ @bindThis
+ public async delete(noteId: MiNote['id'], userId: MiUser['id'], reaction: string): Promise {
+ const pipeline = this.redisForReactions.pipeline();
+ pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, -1);
+ pipeline.zrem(`${REDIS_PAIR_PREFIX}:${noteId}`, `${userId}/${reaction}`);
+ // TODO: 「消した要素一覧」も持っておかないとcreateされた時に上書きされて復活する
+ await pipeline.exec();
+ }
+
+ @bindThis
+ public async get(noteId: MiNote['id']): Promise<{
+ deltas: Record;
+ pairs: ([MiUser['id'], string])[];
+ }> {
+ const pipeline = this.redisForReactions.pipeline();
+ pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`);
+ pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1);
+ const results = await pipeline.exec();
+
+ const resultDeltas = results![0][1] as Record;
+ const resultPairs = results![1][1] as string[];
+
+ const deltas = {} as Record;
+ for (const [name, count] of Object.entries(resultDeltas)) {
+ deltas[name] = parseInt(count);
+ }
+
+ const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]);
+
+ return {
+ deltas,
+ pairs,
+ };
+ }
+
+ @bindThis
+ public async getMany(noteIds: MiNote['id'][]): Promise