From 857608590680ee8e0dc785a132eb721b3f102472 Mon Sep 17 00:00:00 2001
From: Nya Candy <dev@candinya.com>
Date: Fri, 26 Jan 2024 09:28:05 +0800
Subject: [PATCH] feat(wip): ap update note

---
 .../backend/src/core/NoteUpdateService.ts     | 146 ++++++++++++++++++
 .../src/core/activitypub/ApInboxService.ts    |   3 +
 .../src/core/activitypub/ApRendererService.ts |  12 ++
 .../core/activitypub/models/ApNoteService.ts  |  46 +++++-
 .../src/server/api/endpoints/notes/update.ts  |  22 +--
 5 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 packages/backend/src/core/NoteUpdateService.ts

diff --git a/packages/backend/src/core/NoteUpdateService.ts b/packages/backend/src/core/NoteUpdateService.ts
new file mode 100644
index 000000000..5bb50916c
--- /dev/null
+++ b/packages/backend/src/core/NoteUpdateService.ts
@@ -0,0 +1,146 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+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 { RelayService } from '@/core/RelayService.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
+import { DI } from '@/di-symbols.js';
+import type { Config } from '@/config.js';
+import NotesChart from '@/core/chart/charts/notes.js';
+import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
+import InstanceChart from '@/core/chart/charts/instance.js';
+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 { isPureRenote } from '@/misc/is-pure-renote.js';
+import { IdService } from '@/core/IdService.js';
+
+@Injectable()
+export class NoteUpdateService {
+	constructor(
+		@Inject(DI.config)
+		private config: Config,
+
+		@Inject(DI.usersRepository)
+		private usersRepository: UsersRepository,
+
+		@Inject(DI.notesRepository)
+		private notesRepository: NotesRepository,
+
+		@Inject(DI.instancesRepository)
+		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,
+		private perUserNotesChart: PerUserNotesChart,
+		private instanceChart: InstanceChart,
+		private idService: IdService,
+	) {}
+
+	/**
+	 * Update note
+	 * @param user Note creator
+	 * @param note Note to update
+	 * @param ps New note info
+	 */
+	async update(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, ps: Pick<MiNote, 'text' | 'cw'>, quiet = false, updater?: MiUser) {
+		const newNote = {
+			...note,
+			...ps, // Overwrite updated fields
+		};
+
+		if (!quiet) {
+			this.globalEventService.publishNoteStream(note.id, 'updated', {
+				cw: ps.cw,
+				text: ps.text ?? '', // prevent null
+			});
+
+			if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
+				const content = this.apRendererService.renderUpdateNote(await this.apRendererService.renderNote(newNote, false), newNote);
+				this.deliverToConcerned(user, note, content);
+			}
+		}
+
+		this.searchService.indexNote(newNote);
+
+		await this.notesRepository.update({ id: note.id }, {
+			updatedAt: new Date(),
+			history: [...(note.history || []), {
+				createdAt: (note.updatedAt || this.idService.parse(note.id).date).toISOString(),
+				cw: note.cw,
+				text: note.text,
+			}],
+			cw: ps.cw,
+			text: ps.text,
+		});
+
+		// Currently not implemented
+		// if (updater && (note.userId !== updater.id)) {
+		// 	const user = await this.usersRepository.findOneByOrFail({ id: note.userId });
+		// 	this.moderationLogService.log(updater, 'updateNote', {
+		// 		noteId: note.id,
+		// 		noteUserId: note.userId,
+		// 		noteUserUsername: user.username,
+		// 		noteUserHost: user.host,
+		// 		note: note,
+		// 	});
+		// }
+	}
+
+	@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) {
+		this.apDeliverManagerService.deliverToFollowers(user, content);
+		this.relayService.deliverToRelays(user, content);
+		const remoteUsers = await this.getMentionedRemoteUsers(note);
+		for (const remoteUser of remoteUsers) {
+			this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
+		}
+	}
+}
diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts
index e2164fec1..28b64cf45 100644
--- a/packages/backend/src/core/activitypub/ApInboxService.ts
+++ b/packages/backend/src/core/activitypub/ApInboxService.ts
@@ -770,6 +770,9 @@ export class ApInboxService {
 		} else if (getApType(object) === 'Question') {
 			await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
 			return 'ok: Question updated';
+		} else if (isPost(object)) {
+			await this.apNoteService.updateNote(object, resolver);
+			return 'ok: Post updated';
 		} else {
 			return `skip: Unknown type: ${getApType(object)}`;
 		}
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 98e944f34..72945c43d 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -593,6 +593,18 @@ export class ApRendererService {
 		};
 	}
 
+	@bindThis
+	public renderUpdateNote(object: string | IPost, note: { id: MiNote['id'], userId: MiNote['userId'] }): IUpdate {
+		return {
+			id: `${this.config.url}/notes/${note.id}#updates/${new Date().getTime()}`,
+			actor: this.userEntityService.genLocalUserUri(note.userId),
+			type: 'Update',
+			to: ['https://www.w3.org/ns/activitystreams#Public'],
+			object,
+			published: new Date().toISOString(),
+		};
+	}
+
 	@bindThis
 	public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
 		return {
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index 5b75da22a..2bd744f86 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -6,7 +6,7 @@
 import { forwardRef, Inject, Injectable } from '@nestjs/common';
 import { In } from 'typeorm';
 import { DI } from '@/di-symbols.js';
-import type { PollsRepository, EmojisRepository } from '@/models/_.js';
+import type { PollsRepository, EmojisRepository, NotesRepository } from '@/models/_.js';
 import type { Config } from '@/config.js';
 import type { MiRemoteUser } from '@/models/User.js';
 import type { MiNote } from '@/models/Note.js';
@@ -16,6 +16,7 @@ import { MetaService } from '@/core/MetaService.js';
 import { AppLockService } from '@/core/AppLockService.js';
 import type { MiDriveFile } from '@/models/DriveFile.js';
 import { NoteCreateService } from '@/core/NoteCreateService.js';
+import { NoteUpdateService } from '@/core/NoteUpdateService.js';
 import type Logger from '@/logger.js';
 import { IdService } from '@/core/IdService.js';
 import { PollService } from '@/core/PollService.js';
@@ -52,6 +53,9 @@ export class ApNoteService {
 		@Inject(DI.emojisRepository)
 		private emojisRepository: EmojisRepository,
 
+		@Inject(DI.notesRepository)
+		private notesRepository: NotesRepository,
+
 		private idService: IdService,
 		private apMfmService: ApMfmService,
 		private apResolverService: ApResolverService,
@@ -69,6 +73,7 @@ export class ApNoteService {
 		private appLockService: AppLockService,
 		private pollService: PollService,
 		private noteCreateService: NoteCreateService,
+		private noteUpdateService: NoteUpdateService,
 		private apDbResolverService: ApDbResolverService,
 		private apLoggerService: ApLoggerService,
 	) {
@@ -325,6 +330,45 @@ export class ApNoteService {
 		}
 	}
 
+	@bindThis
+	public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<void> {
+		const uri = typeof value === 'string' ? value : value.id;
+		if (uri == null) throw new Error('uri is null');
+
+		// Is from local
+		if (uri.startsWith(`${this.config.url}/`)) throw new Error('uri points local');
+
+		const originNote = await this.notesRepository.findOneBy({ uri });
+		if (originNote == null) throw new Error('Note is not registered');
+
+		// Process new note
+		const note = value as IPost;
+
+		// Fetch note author
+		if (note.attributedTo == null) {
+			throw new Error('invalid note.attributedTo: ' + note.attributedTo);
+		}
+
+		const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser;
+
+		const cw = note.summary || null;
+
+		// Text parsing
+		let text: string | null = null;
+		if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
+			text = note.source.content;
+		} else if (typeof note._misskey_content !== 'undefined') {
+			text = note._misskey_content;
+		} else if (typeof note.content === 'string') {
+			text = this.apMfmService.htmlToMfm(note.content, note.tag);
+		}
+
+		await this.noteUpdateService.update(actor, originNote, {
+			cw,
+			text,
+		}, silent);
+	}
+
 	/**
 	 * Noteを解決します。
 	 *
diff --git a/packages/backend/src/server/api/endpoints/notes/update.ts b/packages/backend/src/server/api/endpoints/notes/update.ts
index 0b940f101..179b69a2f 100644
--- a/packages/backend/src/server/api/endpoints/notes/update.ts
+++ b/packages/backend/src/server/api/endpoints/notes/update.ts
@@ -13,6 +13,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
 import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
 import { ApiError } from '../../error.js';
 import { IdService } from "@/core/IdService.js";
+import { NoteUpdateService } from '@/core/NoteUpdateService.js';
 
 export const meta = {
 	tags: ['notes'],
@@ -58,12 +59,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		@Inject(DI.usersRepository)
 		private usersRepository: UsersRepository,
 
-		@Inject(DI.notesRepository)
-		private notesRepository: NotesRepository,
-
 		private getterService: GetterService,
-		private globalEventService: GlobalEventService,
-		private idService: IdService,
+		private noteUpdateService: NoteUpdateService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
 			const note = await this.getterService.getNote(ps.noteId).catch(err => {
@@ -75,21 +72,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				throw new ApiError(meta.errors.noSuchNote);
 			}
 
-			await this.notesRepository.update({ id: note.id }, {
-				updatedAt: new Date(),
-				history: [...(note.history || []), {
-					createdAt: (note.updatedAt || this.idService.parse(note.id).date).toISOString(),
-					cw: note.cw,
-					text: note.text,
-				}],
-				cw: ps.cw,
+			await this.noteUpdateService.update(await this.usersRepository.findOneByOrFail({ id: note.userId }), note, {
 				text: ps.text,
-			});
-
-			this.globalEventService.publishNoteStream(note.id, 'updated', {
 				cw: ps.cw,
-				text: ps.text,
-			});
+			}, false, me);
 		});
 	}
 }