ワードミュート (#6594)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
This commit is contained in:
syuilo 2020-07-27 13:34:20 +09:00 committed by GitHub
parent b5a1fdd4c7
commit cf43dd6ec5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 485 additions and 12 deletions

View file

@ -1,6 +1,7 @@
<template>
<div
class="note _panel"
v-if="!muted"
v-show="!isDeleted"
:tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }"
@ -84,6 +85,13 @@
</article>
<x-sub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
</div>
<div v-else class="_panel muted" @click="muted = false">
<i18n path="userSaysSomething" tag="small">
<router-link class="name" :to="appearNote.user | userPage" v-user-preview="appearNote.userId" place="name">
<mk-user-name :user="appearNote.user"/>
</router-link>
</i18n>
</div>
</template>
<script lang="ts">
@ -105,6 +113,7 @@ import pleaseLogin from '../scripts/please-login';
import { focusPrev, focusNext } from '../scripts/focus';
import { url } from '../config';
import copyToClipboard from '../scripts/copy-to-clipboard';
import { checkWordMute } from '../scripts/check-word-mute';
export default Vue.extend({
components: {
@ -142,6 +151,7 @@ export default Vue.extend({
replies: [],
showContent: false,
isDeleted: false,
muted: false,
myReaction: null,
reactions: {},
emojis: [],
@ -227,15 +237,16 @@ export default Vue.extend({
}
},
created() {
this.emojis = [...this.appearNote.emojis];
this.reactions = { ...this.appearNote.reactions };
this.myReaction = this.appearNote.myReaction;
async created() {
if (this.$store.getters.isSignedIn) {
this.connection = this.$root.stream;
}
this.emojis = [...this.appearNote.emojis];
this.reactions = { ...this.appearNote.reactions };
this.myReaction = this.appearNote.myReaction;
this.muted = await checkWordMute(this.appearNote, this.$store.state.i, this.$store.state.settings.mutedWords);
if (this.detail) {
this.$root.api('notes/children', {
noteId: this.appearNote.id,
@ -976,4 +987,10 @@ export default Vue.extend({
}
}
}
.muted {
padding: 8px;
text-align: center;
opacity: 0.7;
}
</style>

View file

@ -0,0 +1,42 @@
<template>
<div class="pxhvhrfw" v-size="[{ max: 500 }]">
<button v-for="item in items" class="_button" @click="$emit('input', item.value)" :class="{ active: value === item.value }" :key="item.value">{{ item.label }}</button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
items: {
type: Array,
required: true,
},
value: {
required: true,
},
},
});
</script>
<style lang="scss" scoped>
.pxhvhrfw {
display: flex;
> button {
flex: 1;
padding: 11px 8px 8px 8px;
border-bottom: solid 3px transparent;
&.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
}
&.max-width_500px {
font-size: 80%;
}
}
</style>

View file

@ -27,6 +27,7 @@
<x-import-export/>
<x-drive/>
<x-mute-block/>
<x-word-mute/>
<x-security/>
<x-2fa/>
<x-integration/>
@ -47,6 +48,7 @@ import XImportExport from './import-export.vue';
import XDrive from './drive.vue';
import XReactionSetting from './reaction.vue';
import XMuteBlock from './mute-block.vue';
import XWordMute from './word-mute.vue';
import XSecurity from './security.vue';
import X2fa from './2fa.vue';
import XIntegration from './integration.vue';
@ -68,6 +70,7 @@ export default Vue.extend({
XDrive,
XReactionSetting,
XMuteBlock,
XWordMute,
XSecurity,
X2fa,
XIntegration,

View file

@ -0,0 +1,77 @@
<template>
<section class="_card">
<div class="_title"><fa :icon="faCommentSlash"/> {{ $t('wordMute') }}</div>
<div class="_content _noPad">
<mk-tab v-model="tab" :items="[{ label: $t('_wordMute.soft'), value: 'soft' }, { label: $t('_wordMute.hard'), value: 'hard' }]"/>
</div>
<div class="_content" v-show="tab === 'soft'">
<mk-info>{{ $t('_wordMute.softDescription') }}</mk-info>
<mk-textarea v-model="softMutedWords">
<span>{{ $t('_wordMute.muteWords') }}</span>
<template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
</mk-textarea>
</div>
<div class="_content" v-show="tab === 'hard'">
<mk-info>{{ $t('_wordMute.hardDescription') }}</mk-info>
<mk-textarea v-model="hardMutedWords">
<span>{{ $t('_wordMute.muteWords') }}</span>
<template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
</mk-textarea>
</div>
<div class="_footer">
<mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons';
import MkButton from '../../components/ui/button.vue';
import MkTextarea from '../../components/ui/textarea.vue';
import MkTab from '../../components/tab.vue';
import MkInfo from '../../components/ui/info.vue';
export default Vue.extend({
components: {
MkButton,
MkTextarea,
MkTab,
MkInfo,
},
data() {
return {
tab: 'soft',
softMutedWords: '',
hardMutedWords: '',
changed: false,
faCommentSlash, faSave,
}
},
watch: {
softMutedWords() {
this.changed = true;
},
hardMutedWords() {
this.changed = true;
},
},
created() {
this.softMutedWords = this.$store.state.settings.mutedWords.map(x => x.join(' ')).join('\n');
this.hardMutedWords = this.$store.state.i.mutedWords.map(x => x.join(' ')).join('\n');
},
methods: {
async save() {
this.$store.dispatch('settings/set', { key: 'mutedWords', value: this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')) });
await this.$root.api('i/update', {
mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')),
});
this.changed = false;
},
}
});
</script>

View file

@ -0,0 +1,26 @@
export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> {
// 自分自身
if (me && (note.userId === me.id)) return false;
const words = mutedWords
// Clean up
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
if (words.length > 0) {
if (note.text == null) return false;
const matched = words.some(and =>
and.every(keyword => {
const regexp = keyword.match(/^\/(.+)\/(.*)$/);
if (regexp) {
return new RegExp(regexp[1], regexp[2]).test(note.text!);
}
return note.text!.includes(keyword);
}));
if (matched) return true;
}
return false;
}

View file

@ -18,6 +18,7 @@ export const defaultSettings = {
pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]',
memo: null,
reactions: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
mutedWords: [],
};
export const defaultDeviceUserSettings = {

View file

@ -355,6 +355,10 @@ hr {
padding: 16px;
}
&._noPad {
padding: 0 !important;
}
& + ._content {
border-top: solid 1px var(--divider);
}

View file

@ -59,6 +59,7 @@ import { PromoNote } from '../models/entities/promo-note';
import { PromoRead } from '../models/entities/promo-read';
import { program } from '../argv';
import { Relay } from '../models/entities/relay';
import { MutedNote } from '../models/entities/muted-note';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -151,6 +152,7 @@ export const entities = [
ReversiGame,
ReversiMatching,
Relay,
MutedNote,
...charts as any
];

View file

@ -0,0 +1,39 @@
const RE2 = require('re2');
import { Note } from '../models/entities/note';
import { User } from '../models/entities/user';
type NoteLike = {
userId: Note['userId'];
text: Note['text'];
};
type UserLike = {
id: User['id'];
};
export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: string[][]): Promise<boolean> {
// 自分自身
if (me && (note.userId === me.id)) return false;
const words = mutedWords
// Clean up
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
if (words.length > 0) {
if (note.text == null) return false;
const matched = words.some(and =>
and.every(keyword => {
const regexp = keyword.match(/^\/(.+)\/(.*)$/);
if (regexp) {
return new RE2(regexp[1], regexp[2]).test(note.text!);
}
return note.text!.includes(keyword);
}));
if (matched) return true;
}
return false;
}

View file

@ -0,0 +1,48 @@
import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm';
import { Note } from './note';
import { User } from './user';
import { id } from '../id';
import { mutedNoteReasons } from '../../types';
@Entity()
@Index(['noteId', 'userId'], { unique: true })
export class MutedNote {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
comment: 'The note ID.'
})
public noteId: Note['id'];
@ManyToOne(type => Note, {
onDelete: 'CASCADE'
})
@JoinColumn()
public note: Note | null;
@Index()
@Column({
...id(),
comment: 'The user ID.'
})
public userId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE'
})
@JoinColumn()
public user: User | null;
/**
*
*/
@Index()
@Column('enum', {
enum: mutedNoteReasons,
comment: 'The reason of the MutedNote.'
})
public reason: typeof mutedNoteReasons[number];
}

View file

@ -147,6 +147,17 @@ export class UserProfile {
})
public integrations: Record<string, any>;
@Index()
@Column('boolean', {
default: false,
})
public enableWordMute: boolean;
@Column('jsonb', {
default: []
})
public mutedWords: string[][];
//#region Denormalized fields
@Index()
@Column('varchar', {

View file

@ -53,6 +53,7 @@ import { PromoNote } from './entities/promo-note';
import { PromoRead } from './entities/promo-read';
import { EmojiRepository } from './repositories/emoji';
import { RelayRepository } from './repositories/relay';
import { MutedNote } from './entities/muted-note';
export const Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead);
@ -108,3 +109,4 @@ export const AntennaNotes = getRepository(AntennaNote);
export const PromoNotes = getRepository(PromoNote);
export const PromoReads = getRepository(PromoRead);
export const Relays = getCustomRepository(RelayRepository);
export const MutedNotes = getRepository(MutedNote);

View file

@ -239,6 +239,7 @@ export class UserRepository extends Repository<User> {
hasUnreadNotification: this.getHasUnreadNotification(user.id),
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
integrations: profile!.integrations,
mutedWords: profile!.mutedWords,
} : {}),
...(opts.includeSecrets ? {

View file

@ -0,0 +1,13 @@
import { User } from '../../../models/entities/user';
import { MutedNotes } from '../../../models';
import { SelectQueryBuilder } from 'typeorm';
export function generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: User) {
const mutedQuery = MutedNotes.createQueryBuilder('muted')
.select('muted.noteId')
.where('muted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.setParameters(mutedQuery.getParameters());
}

View file

@ -142,7 +142,11 @@ export const meta = {
desc: {
'ja-JP': 'ピン留めするページID'
}
}
},
mutedWords: {
validator: $.optional.arr($.arr($.str))
},
},
errors: {
@ -193,6 +197,10 @@ export default define(meta, async (ps, user, token) => {
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId;
if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId;
if (ps.mutedWords !== undefined) {
profileUpdates.mutedWords = ps.mutedWords;
profileUpdates.enableWordMute = ps.mutedWords.length > 0;
}
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;

View file

@ -10,6 +10,7 @@ import { activeUsersChart } from '../../../../services/chart';
import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
export const meta = {
desc: {
@ -83,6 +84,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user);
if (user) generateMuteQuery(query, user);
if (user) generateMutedNoteQuery(query, user);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');

View file

@ -12,6 +12,7 @@ import { activeUsersChart } from '../../../../services/chart';
import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
export const meta = {
desc: {
@ -133,6 +134,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
generateMuteQuery(query, user);
generateMutedNoteQuery(query, user);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {

View file

@ -12,6 +12,7 @@ import { Brackets } from 'typeorm';
import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
export const meta = {
desc: {
@ -101,6 +102,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
if (user) generateMuteQuery(query, user);
if (user) generateMutedNoteQuery(query, user);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');

View file

@ -10,6 +10,7 @@ import { Brackets } from 'typeorm';
import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
export const meta = {
desc: {
@ -126,6 +127,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
generateMuteQuery(query, user);
generateMutedNoteQuery(query, user);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {

View file

@ -15,6 +15,10 @@ export default abstract class Channel {
return this.connection.user;
}
protected get userProfile() {
return this.connection.userProfile;
}
protected get following() {
return this.connection.following;
}

View file

@ -4,6 +4,7 @@ import Channel from '../channel';
import { fetchMeta } from '../../../../misc/fetch-meta';
import { Notes } from '../../../../models';
import { PackedNote } from '../../../../models/repositories/note';
import { checkWordMute } from '../../../../misc/check-word-mute';
export default class extends Channel {
public readonly chName = 'globalTimeline';
@ -47,6 +48,13 @@ export default class extends Channel {
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (shouldMuteThisNote(note, this.muting)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.send('note', note);
}

View file

@ -3,6 +3,7 @@ import shouldMuteThisNote from '../../../../misc/should-mute-this-note';
import Channel from '../channel';
import { Notes } from '../../../../models';
import { PackedNote } from '../../../../models/repositories/note';
import { checkWordMute } from '../../../../misc/check-word-mute';
export default class extends Channel {
public readonly chName = 'homeTimeline';
@ -52,6 +53,13 @@ export default class extends Channel {
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (shouldMuteThisNote(note, this.muting)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.send('note', note);
}

View file

@ -5,6 +5,7 @@ import { fetchMeta } from '../../../../misc/fetch-meta';
import { Notes } from '../../../../models';
import { PackedNote } from '../../../../models/repositories/note';
import { PackedUser } from '../../../../models/repositories/user';
import { checkWordMute } from '../../../../misc/check-word-mute';
export default class extends Channel {
public readonly chName = 'hybridTimeline';
@ -61,6 +62,13 @@ export default class extends Channel {
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (shouldMuteThisNote(note, this.muting)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.send('note', note);
}

View file

@ -5,6 +5,7 @@ import { fetchMeta } from '../../../../misc/fetch-meta';
import { Notes } from '../../../../models';
import { PackedNote } from '../../../../models/repositories/note';
import { PackedUser } from '../../../../models/repositories/user';
import { checkWordMute } from '../../../../misc/check-word-mute';
export default class extends Channel {
public readonly chName = 'localTimeline';
@ -49,6 +50,13 @@ export default class extends Channel {
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (shouldMuteThisNote(note, this.muting)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.send('note', note);
}

View file

@ -7,15 +7,17 @@ import Channel from './channel';
import channels from './channels';
import { EventEmitter } from 'events';
import { User } from '../../../models/entities/user';
import { Users, Followings, Mutings } from '../../../models';
import { Users, Followings, Mutings, UserProfiles } from '../../../models';
import { ApiError } from '../error';
import { AccessToken } from '../../../models/entities/access-token';
import { UserProfile } from '../../../models/entities/user-profile';
/**
* Main stream connection
*/
export default class Connection {
public user?: User;
public userProfile?: UserProfile;
public following: User['id'][] = [];
public muting: User['id'][] = [];
public token?: AccessToken;
@ -25,6 +27,7 @@ export default class Connection {
private subscribingNotes: any = {};
private followingClock: NodeJS.Timer;
private mutingClock: NodeJS.Timer;
private userProfileClock: NodeJS.Timer;
constructor(
wsConnection: websocket.connection,
@ -49,6 +52,9 @@ export default class Connection {
this.updateMuting();
this.mutingClock = setInterval(this.updateMuting, 5000);
this.updateUserProfile();
this.userProfileClock = setInterval(this.updateUserProfile, 5000);
}
}
@ -262,6 +268,13 @@ export default class Connection {
this.muting = mutings.map(x => x.muteeId);
}
@autobind
private async updateUserProfile() {
this.userProfile = await UserProfiles.findOne({
userId: this.user!.id
});
}
/**
*
*/
@ -273,5 +286,6 @@ export default class Connection {
if (this.followingClock) clearInterval(this.followingClock);
if (this.mutingClock) clearInterval(this.mutingClock);
if (this.userProfileClock) clearInterval(this.userProfileClock);
}
}

View file

@ -17,7 +17,7 @@ import extractMentions from '../../misc/extract-mentions';
import extractEmojis from '../../misc/extract-emojis';
import extractHashtags from '../../misc/extract-hashtags';
import { Note, IMentionedRemoteUsers } from '../../models/entities/note';
import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings } from '../../models';
import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes } from '../../models';
import { DriveFile } from '../../models/entities/drive-file';
import { App } from '../../models/entities/app';
import { Not, getConnection, In } from 'typeorm';
@ -29,6 +29,7 @@ import { createNotification } from '../create-notification';
import { isDuplicateKeyValueError } from '../../misc/is-duplicate-key-value-error';
import { ensure } from '../../prelude/ensure';
import { checkHitAntenna } from '../../misc/check-hit-antenna';
import { checkWordMute } from '../../misc/check-word-mute';
import { addNoteToAntenna } from '../add-note-to-antenna';
import { countSameRenotes } from '../../misc/count-same-renotes';
import { deliverToRelays } from '../relay';
@ -219,6 +220,24 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
// Increment notes count (user)
incNotesCountOfUser(user);
// Word mute
UserProfiles.find({
enableWordMute: true
}).then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
if (shouldMute) {
MutedNotes.save({
id: genId(),
userId: u.userId,
noteId: note.id,
reason: 'word',
});
}
});
}
});
// Antenna
Antennas.find().then(async antennas => {
const followings = await Followings.createQueryBuilder('following')

View file

@ -1,3 +1,5 @@
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;