summaryrefslogtreecommitdiff
path: root/packages/backend/src/core/NoteCreateService.ts
diff options
context:
space:
mode:
authorMarie <marie@kaifa.ch>2023-10-14 03:41:32 +0200
committerGitHub <noreply@github.com>2023-10-14 03:41:32 +0200
commit2d78ec494090671478b0b888c2bee782ba135815 (patch)
tree7526f4f8ec85e310ba3dc01afc028135770dff17 /packages/backend/src/core/NoteCreateService.ts
parentupd: delete reactions properly in the DB (diff)
parentupd: fix getting renote for logged-in user (diff)
downloadsharkey-2d78ec494090671478b0b888c2bee782ba135815.tar.gz
sharkey-2d78ec494090671478b0b888c2bee782ba135815.tar.bz2
sharkey-2d78ec494090671478b0b888c2bee782ba135815.zip
merge: Upstream & New Changes
Pulled in latest changes Changed renote to boost Added Sponsors Added limit for renote/boost now you can only boost once.
Diffstat (limited to 'packages/backend/src/core/NoteCreateService.ts')
-rw-r--r--packages/backend/src/core/NoteCreateService.ts266
1 files changed, 214 insertions, 52 deletions
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index f20727ce41..64d2880ba1 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -5,7 +5,7 @@
import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
-import { In, DataSource } from 'typeorm';
+import { In, DataSource, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import RE2 from 're2';
@@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
-import type { ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, 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';
@@ -53,8 +53,8 @@ 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';
-
-const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
+import { FeaturedService } from '@/core/FeaturedService.js';
+import { RedisTimelineService } from '@/core/RedisTimelineService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@@ -157,8 +157,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.db)
private db: DataSource,
- @Inject(DI.redis)
- private redisClient: Redis.Redis,
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -175,8 +175,8 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
- @Inject(DI.mutedNotesRepository)
- private mutedNotesRepository: MutedNotesRepository,
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@@ -187,11 +187,15 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
+ @Inject(DI.channelFollowingsRepository)
+ private channelFollowingsRepository: ChannelFollowingsRepository,
+
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private idService: IdService,
private globalEventService: GlobalEventService,
private queueService: QueueService,
+ private redisTimelineService: RedisTimelineService,
private noteReadService: NoteReadService,
private notificationService: NotificationService,
private relayService: RelayService,
@@ -199,6 +203,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private hashtagService: HashtagService,
private antennaService: AntennaService,
private webhookService: WebhookService,
+ private featuredService: FeaturedService,
private remoteUserResolveService: RemoteUserResolveService,
private apDeliverManagerService: ApDeliverManagerService,
private apRendererService: ApRendererService,
@@ -251,19 +256,30 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
- // Renote対象が「ホームまたは全体」以外の公開範囲ならreject
- if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
- throw new Error('Renote target is not public or home');
- }
-
- // Renote対象がpublicではないならhomeにする
- if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
- data.visibility = 'home';
- }
+ if (data.renote) {
+ switch (data.renote.visibility) {
+ case 'public':
+ // public noteは無条件にrenote可能
+ break;
+ case 'home':
+ // home noteはhome以下にrenote可能
+ if (data.visibility === 'public') {
+ data.visibility = 'home';
+ }
+ break;
+ case 'followers':
+ // 他人のfollowers noteはreject
+ if (data.renote.userId !== user.id) {
+ throw new Error('Renote target is not public or home');
+ }
- // Renote対象がfollowersならfollowersにする
- if (data.renote && data.renote.visibility === 'followers') {
- data.visibility = 'followers';
+ // Renote対象がfollowersならfollowersにする
+ data.visibility = 'followers';
+ break;
+ case 'specified':
+ // specified / direct noteはreject
+ throw new Error('Renote target is not public or home');
+ }
}
// 返信対象がpublicではないならhomeにする
@@ -333,14 +349,6 @@ export class NoteCreateService implements OnApplicationShutdown {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
- if (data.channel) {
- this.redisClient.xadd(
- `channelTimeline:${data.channel.id}`,
- 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
- '*',
- 'note', note.id);
- }
-
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
@@ -480,26 +488,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// Increment notes count (user)
this.incNotesCountOfUser(user);
- // Word mute
- mutedWordsCache.fetch(() => this.userProfilesRepository.find({
- where: {
- enableWordMute: true,
- },
- select: ['userId', 'mutedWords'],
- })).then(us => {
- for (const u of us) {
- checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
- if (shouldMute) {
- this.mutedNotesRepository.insert({
- id: this.idService.genId(),
- userId: u.userId,
- noteId: note.id,
- reason: 'word',
- });
- }
- });
- }
- });
+ this.pushToTl(note, user);
this.antennaService.addNoteToAntennas(note, user);
@@ -508,11 +497,13 @@ export class NoteCreateService implements OnApplicationShutdown {
}
if (data.reply == null) {
+ // TODO: キャッシュ
this.followingsRepository.findBy({
followeeId: user.id,
notify: 'normal',
}).then(followings => {
for (const following of followings) {
+ // TODO: ワードミュート考慮
this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id,
}, user.id);
@@ -520,9 +511,8 @@ export class NoteCreateService implements OnApplicationShutdown {
});
}
- // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
- if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
- if (!user.isBot) this.incRenoteCount(data.renote);
+ if (data.renote && data.renote.userId !== user.id && !user.isBot) {
+ this.incRenoteCount(data.renote);
}
if (data.poll && data.poll.expiresAt) {
@@ -722,10 +712,23 @@ export class NoteCreateService implements OnApplicationShutdown {
this.notesRepository.createQueryBuilder().update()
.set({
renoteCount: () => '"renoteCount" + 1',
- score: () => '"score" + 1',
})
.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) {
+ 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) {
+ this.featuredService.updateGlobalNotesRanking(renote.id, 5);
+ this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5);
+ }
+ }
+ }
}
@bindThis
@@ -812,6 +815,165 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
+ private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
+ const meta = await this.metaService.fetch();
+
+ const r = this.redisForTimelines.pipeline();
+
+ if (note.channelId) {
+ this.redisTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
+
+ this.redisTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
+
+ const channelFollowings = await this.channelFollowingsRepository.find({
+ where: {
+ followeeId: note.channelId,
+ },
+ select: ['followerId'],
+ });
+
+ for (const channelFollowing of channelFollowings) {
+ this.redisTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
+ if (note.fileIds.length > 0) {
+ this.redisTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
+ }
+ }
+ } else {
+ // TODO: キャッシュ?
+ // eslint-disable-next-line prefer-const
+ let [followings, userListMemberships] = await Promise.all([
+ this.followingsRepository.find({
+ where: {
+ followeeId: user.id,
+ followerHost: IsNull(),
+ isFollowerHibernated: false,
+ },
+ select: ['followerId', 'withReplies'],
+ }),
+ this.userListMembershipsRepository.find({
+ where: {
+ userId: user.id,
+ },
+ select: ['userListId', 'userListUserId', 'withReplies'],
+ }),
+ ]);
+
+ if (note.visibility === 'followers') {
+ // TODO: 重そうだから何とかしたい Set 使う?
+ userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId));
+ }
+
+ // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
+ for (const following of followings) {
+ // 基本的にvisibleUserIdsには自身のidが含まれている前提であること
+ if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
+
+ // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合
+ if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) {
+ if (!following.withReplies) continue;
+ }
+
+ this.redisTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
+ if (note.fileIds.length > 0) {
+ this.redisTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
+ }
+ }
+
+ for (const userListMembership of userListMemberships) {
+ // ダイレクトのとき、そのリストが対象外のユーザーの場合
+ if (
+ note.visibility === 'specified' &&
+ !note.visibleUserIds.some(v => v === userListMembership.userListUserId)
+ ) continue;
+
+ // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合
+ if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) {
+ if (!userListMembership.withReplies) continue;
+ }
+
+ this.redisTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
+ if (note.fileIds.length > 0) {
+ this.redisTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
+ }
+ }
+
+ if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
+ this.redisTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
+ if (note.fileIds.length > 0) {
+ this.redisTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
+ }
+ }
+
+ // 自分自身以外への返信
+ if (note.replyId && note.replyUserId !== note.userId) {
+ this.redisTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
+
+ if (note.visibility === 'public' && note.userHost == null) {
+ this.redisTimelineService.push('localTimelineWithReplies', note.id, 300, r);
+ }
+ } else {
+ this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
+ if (note.fileIds.length > 0) {
+ this.redisTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
+ }
+
+ if (note.visibility === 'public' && note.userHost == null) {
+ this.redisTimelineService.push('localTimeline', note.id, 1000, r);
+ if (note.fileIds.length > 0) {
+ this.redisTimelineService.push('localTimelineWithFiles', note.id, 500, r);
+ }
+ }
+ }
+
+ if (Math.random() < 0.1) {
+ process.nextTick(() => {
+ this.checkHibernation(followings);
+ });
+ }
+ }
+
+ r.exec();
+ }
+
+ @bindThis
+ public async checkHibernation(followings: MiFollowing[]) {
+ if (followings.length === 0) return;
+
+ const shuffle = (array: MiFollowing[]) => {
+ for (let i = array.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [array[i], array[j]] = [array[j], array[i]];
+ }
+ return array;
+ };
+
+ // ランダムに最大1000件サンプリング
+ const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000));
+
+ const hibernatedUsers = await this.usersRepository.find({
+ where: {
+ id: In(samples.map(x => x.followerId)),
+ lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))),
+ },
+ select: ['id'],
+ });
+
+ if (hibernatedUsers.length > 0) {
+ this.usersRepository.update({
+ id: In(hibernatedUsers.map(x => x.id)),
+ }, {
+ isHibernated: true,
+ });
+
+ this.followingsRepository.update({
+ followerId: In(hibernatedUsers.map(x => x.id)),
+ }, {
+ isFollowerHibernated: true,
+ });
+ }
+ }
+
+ @bindThis
public dispose(): void {
this.#shutdownController.abort();
}