sharkey/packages/backend/src/core/ReactionService.ts

405 lines
13 KiB
TypeScript
Raw Normal View History

/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
2022-09-18 03:27:08 +09:00
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
2022-09-18 03:27:08 +09:00
import { DI } from '@/di-symbols.js';
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js';
2022-09-18 03:27:08 +09:00
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
2022-09-18 03:27:08 +09:00
import { IdService } from '@/core/IdService.js';
import type { MiNoteReaction } from '@/models/NoteReaction.js';
2022-09-18 03:27:08 +09:00
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { NotificationService } from '@/core/NotificationService.js';
2022-09-18 03:27:08 +09:00
import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js';
import { emojiRegex } from '@/misc/emoji-regex.js';
2022-12-04 10:16:03 +09:00
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { MetaService } from '@/core/MetaService.js';
2022-12-04 17:05:32 +09:00
import { bindThis } from '@/decorators.js';
2023-02-04 12:40:40 +09:00
import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { RoleService } from '@/core/RoleService.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
2022-09-18 03:27:08 +09:00
const FALLBACK = '❤';
2023-10-19 11:19:42 +09:00
const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
2022-09-18 03:27:08 +09:00
const legacies: Record<string, string> = {
'like': '👍',
'love': '❤', // ここに記述する場合は異体字セレクタを入れない
'laugh': '😆',
'hmm': '🤔',
'surprise': '😮',
'congrats': '🎉',
'angry': '💢',
'confused': '😥',
'rip': '😇',
'pudding': '🍮',
'star': '⭐',
};
type DecodedReaction = {
/**
* (Unicode Emoji or ':name@hostname' or ':name@.')
*/
reaction: string;
/**
* name (name, Emojiクエリに使う)
*/
name?: string;
/**
* host (host, Emojiクエリに使う)
*/
host?: string | null;
};
2023-05-18 18:18:25 +09:00
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
2022-09-18 03:27:08 +09:00
@Injectable()
export class ReactionService {
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
2022-09-18 03:27:08 +09:00
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private utilityService: UtilityService,
private metaService: MetaService,
private customEmojiService: CustomEmojiService,
private roleService: RoleService,
2022-09-18 03:27:08 +09:00
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
2023-02-04 12:40:40 +09:00
private userBlockingService: UserBlockingService,
2022-09-18 03:27:08 +09:00
private idService: IdService,
private featuredService: FeaturedService,
2023-02-04 10:02:03 +09:00
private globalEventService: GlobalEventService,
2022-09-18 03:27:08 +09:00
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private notificationService: NotificationService,
2022-09-18 03:27:08 +09:00
private perUserReactionsChart: PerUserReactionsChart,
) {
}
@bindThis
public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) {
2022-09-18 03:27:08 +09:00
// Check blocking
if (note.userId !== user.id) {
2023-02-04 12:40:40 +09:00
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
if (blocked) {
2022-09-18 03:27:08 +09:00
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7');
}
}
2022-09-18 03:27:08 +09:00
// check visibility
if (!await this.noteEntityService.isVisibleForMe(note, user.id)) {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
}
let reaction = _reaction ?? FALLBACK;
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) {
reaction = '❤️';
} else if (_reaction) {
const custom = reaction.match(isCustomEmojiRegexp);
if (custom) {
const reacterHost = this.utilityService.toPunyNullable(user.host);
const name = custom[1];
const emoji = reacterHost == null
? (await this.customEmojiService.localEmojisCache.fetch()).get(name)
: await this.emojisRepository.findOneBy({
host: reacterHost,
name,
});
if (emoji) {
if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || (await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) {
reaction = reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
// センシティブ
if ((note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && emoji.isSensitive) {
reaction = FALLBACK;
}
} else {
// リアクションとして使う権限がない
reaction = FALLBACK;
}
} else {
reaction = FALLBACK;
}
} else {
reaction = this.normalize(reaction);
}
}
const record: MiNoteReaction = {
id: this.idService.gen(),
2022-09-18 03:27:08 +09:00
noteId: note.id,
userId: user.id,
reaction,
};
2022-09-18 03:27:08 +09:00
// Create reaction
try {
await this.noteReactionsRepository.insert(record);
} catch (e) {
if (isDuplicateKeyValueError(e)) {
const exists = await this.noteReactionsRepository.findOneByOrFail({
noteId: note.id,
userId: user.id,
});
2022-09-18 03:27:08 +09:00
if (exists.reaction !== reaction) {
// 別のリアクションがすでにされていたら置き換える
await this.delete(user, note);
await this.noteReactionsRepository.insert(record);
} else {
// 同じリアクションがすでにされていたらエラー
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
}
} else {
throw e;
}
}
2022-09-18 03:27:08 +09:00
// Increment reactions count
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
await this.notesRepository.createQueryBuilder().update()
.set({
reactions: () => sql,
2023-10-19 11:19:42 +09:00
...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? {
2023-10-19 11:17:59 +09:00
reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`,
} : {}),
2022-09-18 03:27:08 +09:00
})
.where('id = :id', { id: note.id })
.execute();
// 30%の確率、セルフではない、3日以内に投稿されたートの場合ハイライト用ランキング更新
if (
Math.random() < 0.3 &&
note.userId !== user.id &&
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
) {
if (note.channelId != null) {
if (note.replyId == null) {
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1);
}
} else {
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
this.featuredService.updateGlobalNotesRanking(note.id, 1);
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
}
}
}
2023-03-24 18:48:42 +09:00
const meta = await this.metaService.fetch();
if (meta.enableChartsForRemoteUser || (user.host == null)) {
this.perUserReactionsChart.update(user, note);
}
2022-09-18 03:27:08 +09:00
// カスタム絵文字リアクションだったら絵文字情報も送る
const decodedReaction = this.decodeReaction(reaction);
const customEmoji = decodedReaction.name == null ? null : decodedReaction.host == null
? (await this.customEmojiService.localEmojisCache.fetch()).get(decodedReaction.name)
: await this.emojisRepository.findOne(
{
where: {
name: decodedReaction.name,
host: decodedReaction.host,
},
});
2023-02-04 10:02:03 +09:00
this.globalEventService.publishNoteStream(note.id, 'reacted', {
2022-09-18 03:27:08 +09:00
reaction: decodedReaction.reaction,
emoji: customEmoji != null ? {
name: customEmoji.host ? `${customEmoji.name}@${customEmoji.host}` : `${customEmoji.name}@.`,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: customEmoji.publicUrl || customEmoji.originalUrl,
2022-09-18 03:27:08 +09:00
} : null,
userId: user.id,
});
2022-09-18 03:27:08 +09:00
// リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) {
this.notificationService.createNotification(note.userId, 'reaction', {
2022-09-18 03:27:08 +09:00
noteId: note.id,
reaction: reaction,
2023-09-29 11:29:54 +09:00
}, user.id);
2022-09-18 03:27:08 +09:00
}
2022-09-18 03:27:08 +09:00
//#region 配信
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
2023-02-12 18:47:30 +09:00
const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note));
2022-09-18 03:27:08 +09:00
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
dm.addDirectRecipe(reactee as MiRemoteUser);
2022-09-18 03:27:08 +09:00
}
2022-09-18 03:27:08 +09:00
if (['public', 'home', 'followers'].includes(note.visibility)) {
dm.addFollowersRecipe();
} else if (note.visibility === 'specified') {
const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id })));
for (const u of visibleUsers.filter(u => u && this.userEntityService.isRemoteUser(u))) {
dm.addDirectRecipe(u as MiRemoteUser);
2022-09-18 03:27:08 +09:00
}
}
trackPromise(dm.execute());
2022-09-18 03:27:08 +09:00
}
//#endregion
}
@bindThis
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) {
2022-09-18 03:27:08 +09:00
// if already unreacted
const exist = await this.noteReactionsRepository.findOneBy({
noteId: note.id,
userId: user.id,
});
2022-09-18 03:27:08 +09:00
if (exist == null) {
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
}
2022-09-18 03:27:08 +09:00
// Delete reaction
const result = await this.noteReactionsRepository.delete(exist.id);
2022-09-18 03:27:08 +09:00
if (result.affected !== 1) {
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
}
2022-09-18 03:27:08 +09:00
// Decrement reactions count
const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
await this.notesRepository.createQueryBuilder().update()
.set({
reactions: () => sql,
2023-10-19 11:17:59 +09:00
reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`,
2022-09-18 03:27:08 +09:00
})
.where('id = :id', { id: note.id })
.execute();
2023-02-04 10:02:03 +09:00
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
2022-09-18 03:27:08 +09:00
reaction: this.decodeReaction(exist.reaction).reaction,
userId: user.id,
});
2022-09-18 03:27:08 +09:00
//#region 配信
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
2023-02-12 18:47:30 +09:00
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user));
2022-09-18 03:27:08 +09:00
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
dm.addDirectRecipe(reactee as MiRemoteUser);
2022-09-18 03:27:08 +09:00
}
dm.addFollowersRecipe();
trackPromise(dm.execute());
2022-09-18 03:27:08 +09:00
}
//#endregion
}
@bindThis
2022-09-18 03:27:08 +09:00
public convertLegacyReactions(reactions: Record<string, number>) {
const _reactions = {} as Record<string, number>;
for (const reaction of Object.keys(reactions)) {
if (reactions[reaction] <= 0) continue;
if (Object.keys(legacies).includes(reaction)) {
if (_reactions[legacies[reaction]]) {
_reactions[legacies[reaction]] += reactions[reaction];
} else {
_reactions[legacies[reaction]] = reactions[reaction];
}
} else {
if (_reactions[reaction]) {
_reactions[reaction] += reactions[reaction];
} else {
_reactions[reaction] = reactions[reaction];
}
}
}
const _reactions2 = {} as Record<string, number>;
for (const reaction of Object.keys(_reactions)) {
_reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction];
}
return _reactions2;
}
@bindThis
public normalize(reaction: string | null): string {
if (reaction == null) return FALLBACK;
2022-09-18 03:27:08 +09:00
// 文字列タイプのリアクションを絵文字に変換
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
// Unicode絵文字
const match = emojiRegex.exec(reaction);
if (match) {
// 合字を含む1つの絵文字
2022-09-18 03:27:08 +09:00
const unicode = match[0];
// 異体字セレクタ除去
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
}
return FALLBACK;
2022-09-18 03:27:08 +09:00
}
@bindThis
2022-09-18 03:27:08 +09:00
public decodeReaction(str: string): DecodedReaction {
2023-05-18 18:18:25 +09:00
const custom = str.match(decodeCustomEmojiRegexp);
2022-09-18 03:27:08 +09:00
if (custom) {
const name = custom[1];
const host = custom[2] ?? null;
return {
reaction: `:${name}@${host ?? '.'}:`, // ローカル分は@以降を省略するのではなく.にする
name,
host,
};
}
return {
reaction: str,
name: undefined,
host: undefined,
};
}
@bindThis
2022-09-18 03:27:08 +09:00
public convertLegacyReaction(reaction: string): string {
reaction = this.decodeReaction(reaction).reaction;
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
return reaction;
}
}