diff --git a/package.json b/package.json index d091490dc446..f78ce66c263c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2024.10.0-resonite-love.0", + "version": "2024.10.0-resonite-love.1", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 4edd4cf2a157..ecfb1d0ae665 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -10,7 +10,7 @@ import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; export type FanoutTimelineName = - // home timeline +// home timeline | `homeTimeline:${string}` | `homeTimelineWithFiles:${string}` // only notes with files are included // local timeline @@ -42,6 +42,7 @@ export type FanoutTimelineName = | 'vmimiRelayTimeline' // replies are not included | 'vmimiRelayTimelineWithFiles' // only non-reply notes with files are included | 'vmimiRelayTimelineWithReplies' // only replies are included + | `vmimiRelayTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id. @Injectable() export class FanoutTimelineService { @@ -53,6 +54,11 @@ export class FanoutTimelineService { ) { } + @bindThis + public remove(tl: FanoutTimelineName, id: string, pipeline: Redis.ChainableCommander) { + pipeline.lrem('list:' + tl, 0, id); + } + @bindThis public push(tl: FanoutTimelineName, id: string, maxlen: number, pipeline: Redis.ChainableCommander) { // リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、 diff --git a/packages/backend/src/core/FeaturedService.ts b/packages/backend/src/core/FeaturedService.ts index b3335e38da47..1af2e15db45b 100644 --- a/packages/backend/src/core/FeaturedService.ts +++ b/packages/backend/src/core/FeaturedService.ts @@ -77,6 +77,50 @@ export class FeaturedService { return Array.from(ranking.keys()); } + // TODO: find better place? + @bindThis + public shouldBeIncludedInGlobalOrUserFeatured(note: MiNote): boolean { + if (note.visibility !== 'public') return false; // non-public note + if (note.userHost != null) return false; // remote + if (note.replyId != null) return false; // reply + // Channels are checked outside + + // In nirila misskey, it was very common to notes with `:ohayo_nirila_misskey:` or `:oyasumi_nirila_misskey:` + // Will get many reaction`:ohayo_nirila_misskey:` or `:oyasumi_nirila_misskey:` so exclude them + // if they don't have any images. + if (note.fileIds.length === 0) { + for (const exclusion of ["おはよう", "おやすみ", ":ohayo_nirila_misskey:", ":oyasumi_nirila_misskey:", ":kon_nirila_misskey:"]) { + if (note.text?.includes(exclusion)) return false; + if (note.cw?.includes(exclusion)) return false; + } + } + + return true; + } + + @bindThis + private removeNoteFromRankingOf(name: string, windowRange: number, element: string, redisPipeline: Redis.ChainableCommander) { + // removing from current & previous window is enough + const currentWindow = this.getCurrentWindow(windowRange); + const previousWindow = currentWindow - 1; + + redisPipeline.zrem(`${name}:${currentWindow}`, element); + redisPipeline.zrem(`${name}:${previousWindow}`, element); + } + + @bindThis + public async removeNote(note: MiNote): Promise { + const redisPipeline = this.redisClient.pipeline(); + this.removeNoteFromRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, note.id, redisPipeline); + this.removeNoteFromRankingOf(`featuredPerUserNotesRanking:${note.userId}`, PER_USER_NOTES_RANKING_WINDOW, note.id, redisPipeline); + + if (note.channelId) { + this.removeNoteFromRankingOf(`featuredInChannelNotesRanking:${note.channelId}`, GLOBAL_NOTES_RANKING_WINDOW, note.id, redisPipeline); + } + + await redisPipeline.exec(); + } + @bindThis private async removeFromRanking(name: string, windowRange: number, element: string): Promise { const currentWindow = this.getCurrentWindow(windowRange); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 42f87e98dd1a..5ee0128eb4a3 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -57,6 +57,8 @@ 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 { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -147,6 +149,7 @@ type Option = { @Injectable() export class NoteCreateService implements OnApplicationShutdown { + private logger: Logger; #shutdownController = new AbortController(); private updateNotesCountQueue: CollapsedQueue; @@ -219,8 +222,10 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, + private loggerService: LoggerService, ) { this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount); + this.logger = this.loggerService.getLogger('note:create'); } @bindThis @@ -368,6 +373,34 @@ export class NoteCreateService implements OnApplicationShutdown { // if the host is media-silenced, custom emojis are not allowed if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = []; + const willCauseNotification = mentionedUsers.some(u => u.host === null) + || (data.visibility === 'specified' && data.visibleUsers?.some(u => u.host === null)) + || data.reply?.userHost === null || (this.isRenote(data) && this.isQuote(data) && data.renote?.userHost === null) || false; + + const isAllowedToCreateNotification = () => { + const targetUserIds: string[] = [ + ...mentionedUsers.filter(x => x.host == null).map(x => x.id), + ...(data.visibility === 'specified' && data.visibleUsers != null ? data.visibleUsers.filter(x => x.host == null).map(x => x.id) : []), + ...(data.reply != null && data.reply.userHost == null ? [data.reply.userId] : []), + ...(this.isRenote(data) && this.isQuote(data) && data.renote.userHost === null ? [data.renote.userId] : []), + ]; + const allowedIds = new Set(this.meta.nirilaAllowedUnfamiliarRemoteUserIds); + for (const targetUserId of targetUserIds) { + if (!allowedIds.has(targetUserId)) { + return false; + } + } + return true; + }; + + if (this.meta.nirilaBlockMentionsFromUnfamiliarRemoteUsers && user.host !== null && willCauseNotification && !isAllowedToCreateNotification()) { + const userEntity = await this.usersRepository.findOneBy({ id: user.id }); + if ((userEntity?.followersCount ?? 0) === 0) { + this.logger.error('Request rejected because user has no local followers', { user: user.id, note: data }); + throw new IdentifiableError('e11b3a16-f543-4885-8eb1-66cad131dbfd', 'Notes including mentions, replies, or renotes from remote users are not allowed until user has at least one local follower.'); + } + } + 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)) { @@ -605,7 +638,8 @@ 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')); + const userNoteEvent = `note@${user.username}` as const; + webhooks = webhooks.filter(x => (x.userId === user.id && x.on.includes('note')) || x.on.includes(userNoteEvent)); for (const webhook of webhooks) { this.queueService.userWebhookDeliver(webhook, 'note', { note: noteObj, @@ -734,7 +768,7 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private isQuote(note: Option & { renote: MiNote }): note is Option & { renote: MiNote } & ( { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } - ) { + ) { // NOTE: SYNC WITH misc/is-quote.ts return note.text != null || note.reply != null || @@ -752,14 +786,14 @@ export class NoteCreateService implements OnApplicationShutdown { .where('id = :id', { id: renote.id }) .execute(); - // 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新 - if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) { + // ~~30%の確率、~~3日以内に投稿されたノートの場合ハイライト用ランキング更新 + if ((Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) { if (renote.channelId != null) { if (renote.replyId == null) { this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5); } } else { - if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) { + if (this.featuredService.shouldBeIncludedInGlobalOrUserFeatured(renote)) { this.featuredService.updateGlobalNotesRanking(renote.id, 5); this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5); } @@ -954,8 +988,11 @@ export class NoteCreateService implements OnApplicationShutdown { this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r); } } - if (note.visibility === 'public' && this.vmimiRelayTimelineService.isRelayedInstance(note.userHost)) { - this.fanoutTimelineService.push('vmimiRelayTimelineWithReplies', note.id, meta.vmimiRelayTimelineCacheMax, r); + if (note.visibility === 'public' && this.vmimiRelayTimelineService.isRelayedInstance(note.userHost) && !note.localOnly) { + this.fanoutTimelineService.push('vmimiRelayTimelineWithReplies', note.id, this.meta.vmimiRelayTimelineCacheMax, r); + if (note.replyUserHost == null) { + this.fanoutTimelineService.push(`vmimiRelayTimelineWithReplyTo:${note.replyUserId}`, note.id, this.meta.vmimiRelayTimelineCacheMax / 10, r); + } } } else { this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); @@ -969,10 +1006,10 @@ export class NoteCreateService implements OnApplicationShutdown { this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); } } - if (note.visibility === 'public' && this.vmimiRelayTimelineService.isRelayedInstance(note.userHost)) { - this.fanoutTimelineService.push('vmimiRelayTimeline', note.id, meta.vmimiRelayTimelineCacheMax, r); + if (note.visibility === 'public' && this.vmimiRelayTimelineService.isRelayedInstance(note.userHost) && !note.localOnly) { + this.fanoutTimelineService.push('vmimiRelayTimeline', note.id, this.meta.vmimiRelayTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push('vmimiRelayTimelineWithFiles', note.id, meta.vmimiRelayTimelineCacheMax / 2, r); + this.fanoutTimelineService.push('vmimiRelayTimelineWithFiles', note.id, this.meta.vmimiRelayTimelineCacheMax / 2, r); } } } diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index d70dec3f3629..d28ec87ec61a 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -357,7 +357,7 @@ export class MiMeta { @Column('varchar', { length: 1024, - default: 'https://github.com/misskey-dev/misskey', + default: 'https://github.com/anatawa12/misskey/tree/vmimi-relay-timeline-releases?tab=readme-ov-file#vmimi-relay-timeline', nullable: true, }) public repositoryUrl: string | null; @@ -648,4 +648,16 @@ export class MiMeta { default: '{}', }) public federationHosts: string[]; + + @Column('boolean', { + default: false, + }) + public nirilaBlockMentionsFromUnfamiliarRemoteUsers: boolean; + + @Column('varchar', { + length: 32, + array: true, + default: '{}', + }) + public nirilaAllowedUnfamiliarRemoteUserIds: string[]; } diff --git a/packages/backend/src/models/Webhook.ts b/packages/backend/src/models/Webhook.ts index b4cab4edc8ec..6b054e45f37e 100644 --- a/packages/backend/src/models/Webhook.ts +++ b/packages/backend/src/models/Webhook.ts @@ -8,7 +8,7 @@ import { id } from './util/id.js'; import { MiUser } from './User.js'; export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const; -export type WebhookEventTypes = typeof webhookEventTypes[number]; +export type WebhookEventTypes = typeof webhookEventTypes[number] | `note@${string}`; @Entity('webhook') export class MiWebhook { @@ -38,7 +38,7 @@ export class MiWebhook { @Column('varchar', { length: 128, array: true, default: '{}', }) - public on: (typeof webhookEventTypes)[number][]; + public on: WebhookEventTypes[]; @Column('varchar', { length: 1024,