Skip to content

Commit

Permalink
feat(backend): Federated note update (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
caipira113 authored and noridev committed Oct 25, 2023
1 parent 86d8abd commit 6af23d4
Show file tree
Hide file tree
Showing 10 changed files with 550 additions and 27 deletions.
12 changes: 12 additions & 0 deletions packages/backend/migration/1696604572677-poll_vote_poll.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export class PollVotePoll1696604572677 {
name = 'PollVotePoll1696604572677';

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "poll_vote" ADD CONSTRAINT "FK_poll_vote_poll" FOREIGN KEY ("noteId") REFERENCES "poll"("noteId") ON DELETE CASCADE`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "poll_vote" DROP CONSTRAINT "FK_poll_vote_poll"`);
}

}
6 changes: 6 additions & 0 deletions packages/backend/src/core/CoreModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { MetaService } from './MetaService.js';
import { MfmService } from './MfmService.js';
import { ModerationLogService } from './ModerationLogService.js';
import { NoteCreateService } from './NoteCreateService.js';
import { NoteUpdateService } from './NoteUpdateService.js';
import { NoteDeleteService } from './NoteDeleteService.js';
import { NotePiningService } from './NotePiningService.js';
import { NoteReadService } from './NoteReadService.js';
Expand Down Expand Up @@ -168,6 +169,7 @@ const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaServic
const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService };
const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService };
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
const $NoteUpdateService: Provider = { provide: 'NoteUpdateService', useExisting: NoteUpdateService };
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
Expand Down Expand Up @@ -307,6 +309,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv
MfmService,
ModerationLogService,
NoteCreateService,
NoteUpdateService,
NoteDeleteService,
NotePiningService,
NoteReadService,
Expand Down Expand Up @@ -439,6 +442,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv
$MfmService,
$ModerationLogService,
$NoteCreateService,
$NoteUpdateService,
$NoteDeleteService,
$NotePiningService,
$NoteReadService,
Expand Down Expand Up @@ -572,6 +576,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv
MfmService,
ModerationLogService,
NoteCreateService,
NoteUpdateService,
NoteDeleteService,
NotePiningService,
NoteReadService,
Expand Down Expand Up @@ -703,6 +708,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv
$MfmService,
$ModerationLogService,
$NoteCreateService,
$NoteUpdateService,
$NoteDeleteService,
$NotePiningService,
$NoteReadService,
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/core/GlobalEventService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export interface NoteEventTypes {
};
updated: {
cw: string | null;
text: string;
text: string | null;
};
reacted: {
reaction: string;
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/core/NoteCreateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ type MinimumUser = {

type Option = {
createdAt?: Date | null;
updatedAt?: Date | null;
name?: string | null;
text?: string | null;
reply?: MiNote | null;
Expand Down
297 changes: 297 additions & 0 deletions packages/backend/src/core/NoteUpdateService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
/*
* 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();
}
}
Loading

0 comments on commit 6af23d4

Please sign in to comment.