Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(backend): Federated note update #1

Merged
merged 8 commits into from
Oct 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -32,6 +32,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 @@ -165,6 +166,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 @@ -302,6 +304,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
MfmService,
ModerationLogService,
NoteCreateService,
NoteUpdateService,
NoteDeleteService,
NotePiningService,
NoteReadService,
Expand Down Expand Up @@ -432,6 +435,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$MfmService,
$ModerationLogService,
$NoteCreateService,
$NoteUpdateService,
$NoteDeleteService,
$NotePiningService,
$NoteReadService,
Expand Down Expand Up @@ -563,6 +567,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
MfmService,
ModerationLogService,
NoteCreateService,
NoteUpdateService,
NoteDeleteService,
NotePiningService,
NoteReadService,
Expand Down Expand Up @@ -692,6 +697,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$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 @@ -115,7 +115,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 @@ -127,6 +127,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);

Check failure on line 190 in packages/backend/src/core/NoteUpdateService.ts

View workflow job for this annotation

GitHub Actions / lint (backend)

A space is required after '{'

Check failure on line 190 in packages/backend/src/core/NoteUpdateService.ts

View workflow job for this annotation

GitHub Actions / lint (backend)

A space is required before '}'

if (!values.hasPoll) {
await transactionalEntityManager.delete(MiPoll, {noteId: note.id});

Check failure on line 193 in packages/backend/src/core/NoteUpdateService.ts

View workflow job for this annotation

GitHub Actions / lint (backend)

A space is required after '{'

Check failure on line 193 in packages/backend/src/core/NoteUpdateService.ts

View workflow job for this annotation

GitHub Actions / lint (backend)

A space is required before '}'
}
});
} 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

Check failure on line 223 in packages/backend/src/core/NoteUpdateService.ts

View workflow job for this annotation

GitHub Actions / lint (backend)

Do not use "@ts-ignore" because it alters compilation errors
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
Loading