/* * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors * SPDX-License-Identifier: AGPL-3.0-only */ import { setImmediate } from 'node:timers/promises'; import { In, DataSource } from 'typeorm'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; import type { NotesRepository, UsersRepository } from '@/models/_.js'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { RelayService } from '@/core/RelayService.js'; import { DI } from '@/di-symbols.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { SearchService } from '@/core/SearchService.js'; import { normalizeForSearch } from "@/misc/normalize-for-search.js"; import { MiDriveFile } from '@/models/_.js'; import { MiPoll, IPoll } from '@/models/Poll.js'; import * as mfm from "cherrypick-mfm-js"; import { concat } from "@/misc/prelude/array.js"; import { extractHashtags } from "@/misc/extract-hashtags.js"; import { extractCustomEmojisFromMfm } from "@/misc/extract-custom-emojis-from-mfm.js"; import util from 'util'; type MinimumUser = { id: MiUser['id']; host: MiUser['host']; username: MiUser['username']; uri: MiUser['uri']; }; type Option = { updatedAt?: Date | null; files?: MiDriveFile[] | null; name?: string | null; text?: string | null; cw?: string | null; apHashtags?: string[] | null; apEmojis?: string[] | null; poll?: IPoll | null; }; @Injectable() export class NoteUpdateService implements OnApplicationShutdown { #shutdownController = new AbortController(); constructor( @Inject(DI.db) private db: DataSource, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.notesRepository) private notesRepository: NotesRepository, private userEntityService: UserEntityService, private globalEventService: GlobalEventService, private relayService: RelayService, private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, private searchService: SearchService, private activeUsersChart: ActiveUsersChart, ) { } @bindThis public async update(user: { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; isBot: MiUser['isBot']; }, data: Option, note: MiNote, silent = false): Promise<MiNote | null> { if (data.updatedAt == null) data.updatedAt = new Date(); if (data.text) { if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); } data.text = data.text.trim(); } else { data.text = null; } let tags = data.apHashtags; let emojis = data.apEmojis; // Parse MFM if needed if (!tags || !emojis) { const tokens = data.text ? mfm.parse(data.text)! : []; const cwTokens = data.cw ? mfm.parse(data.cw)! : []; const choiceTokens = data.poll && data.poll.choices ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) : []; const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); tags = data.apHashtags ?? extractHashtags(combinedTokens); emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); } tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); const updatedNote = await this.updateNote(user, note, data, tags, emojis); if (updatedNote) { setImmediate('post updated', { signal: this.#shutdownController.signal }).then( () => this.postNoteUpdated(updatedNote, user, silent), () => { /* aborted, ignore this */ }, ); } return updatedNote; } @bindThis private async updateNote(user: { id: MiUser['id']; host: MiUser['host']; }, note: MiNote, data: Option, tags: string[], emojis: string[]): Promise<MiNote | null> { const updatedAtHistory = note.updatedAtHistory ? note.updatedAtHistory : []; const values = new MiNote({ updatedAt: data.updatedAt!, fileIds: data.files ? data.files.map(file => file.id) : [], text: data.text, hasPoll: data.poll != null, cw: data.cw ?? null, tags: tags.map(tag => normalizeForSearch(tag)), emojis, attachedFileTypes: data.files ? data.files.map(file => file.type) : [], updatedAtHistory: [...updatedAtHistory, new Date()], noteEditHistory: [...note.noteEditHistory, (note.cw ? note.cw + '\n' : '') + note.text!], }); // 投稿を更新 try { if (note.hasPoll && values.hasPoll) { // Start transaction await this.db.transaction(async transactionalEntityManager => { await transactionalEntityManager.update(MiNote, { id: note.id }, values); if (values.hasPoll) { const old_poll = await transactionalEntityManager.findOneBy(MiPoll, { noteId: note.id }); if (old_poll!.choices.toString() !== data.poll!.choices.toString() || old_poll!.multiple !== data.poll!.multiple) { await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); const poll = new MiPoll({ noteId: note.id, choices: data.poll!.choices, expiresAt: data.poll!.expiresAt, multiple: data.poll!.multiple, votes: new Array(data.poll!.choices.length).fill(0), noteVisibility: note.visibility, userId: user.id, userHost: user.host, }); await transactionalEntityManager.insert(MiPoll, poll); } } }); } else if (!note.hasPoll && values.hasPoll) { // Start transaction await this.db.transaction(async transactionalEntityManager => { await transactionalEntityManager.update(MiNote, { id: note.id }, values); if (values.hasPoll) { const poll = new MiPoll({ noteId: note.id, choices: data.poll!.choices, expiresAt: data.poll!.expiresAt, multiple: data.poll!.multiple, votes: new Array(data.poll!.choices.length).fill(0), noteVisibility: note.visibility, userId: user.id, userHost: user.host, }); await transactionalEntityManager.insert(MiPoll, poll); } }); } else if (note.hasPoll && !values.hasPoll) { // Start transaction await this.db.transaction(async transactionalEntityManager => { await transactionalEntityManager.update(MiNote, {id: note.id}, values); if (!values.hasPoll) { await transactionalEntityManager.delete(MiPoll, {noteId: note.id}); } }); } else { await this.notesRepository.update({ id: note.id }, values); } return await this.notesRepository.findOneBy({ id: note.id }); } catch (e) { console.error(e); throw e; } } @bindThis private async postNoteUpdated(note: MiNote, user: { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; isBot: MiUser['isBot']; }, silent: boolean) { if (!silent) { if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); this.globalEventService.publishNoteStream(note.id, 'updated', { cw: note.cw, text: note.text }); //#region AP deliver if (this.userEntityService.isLocalUser(user)) { await (async () => { // @ts-ignore const noteActivity = await this.renderNoteActivity(note, user); await this.deliverToConcerned(user, note, noteActivity); })(); } //#endregion } // Register to search database this.reIndex(note); } @bindThis private async renderNoteActivity(note: MiNote, user: MiUser) { const content = this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user); return this.apRendererService.addContext(content); } @bindThis private async getMentionedRemoteUsers(note: MiNote) { const where = [] as any[]; // mention / reply / dm const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); if (uris.length > 0) { where.push( { uri: In(uris) }, ); } // renote / quote if (note.renoteUserId) { where.push({ id: note.renoteUserId, }); } if (where.length === 0) return []; return await this.usersRepository.find({ where, }) as MiRemoteUser[]; } @bindThis private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { console.log('deliverToConcerned', util.inspect(content, { depth: null })); await this.apDeliverManagerService.deliverToFollowers(user, content); await this.relayService.deliverToRelays(user, content); const remoteUsers = await this.getMentionedRemoteUsers(note); for (const remoteUser of remoteUsers) { await this.apDeliverManagerService.deliverToUser(user, content, remoteUser); } } @bindThis private reIndex(note: MiNote) { if (note.text == null && note.cw == null) return; this.searchService.unindexNote(note); this.searchService.indexNote(note); } @bindThis public dispose(): void { this.#shutdownController.abort(); } @bindThis public onApplicationShutdown(signal?: string | undefined): void { this.dispose(); } }