feat: 通報を受けた際にメールまたはWebhookで通知を送出出来るようにする (#13758)

* feat: 通報を受けた際にメールまたはWebhookで通知を送出出来るようにする

* モデログに対応&エンドポイントを単一オブジェクトでのサポートに変更(API経由で大量に作るシチュエーションもないと思うので)

* fix spdx

* fix migration

* fix migration

* fix models

* add e2e webhook

* tweak

* fix modlog

* fix bugs

* add tests and fix bugs

* add tests and fix bugs

* add tests

* fix path

* regenerate locale

* 混入除去

* 混入除去

* add abuseReportResolved

* fix pnpm-lock.yaml

* add abuseReportResolved test

* fix bugs

* fix ui

* add tests

* fix CHANGELOG.md

* add tests

* add RoleService.getModeratorIds tests

* WebhookServiceをUserとSystemに分割

* fix CHANGELOG.md

* fix test

* insertOneを使う用に

* fix

* regenerate locales

* revert version

* separate webhook job queue

* fix

* 🎨

* Update QueueProcessorService.ts

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
おさむのひと
2024-06-08 15:34:19 +09:00
committed by GitHub
parent e0cf5b2402
commit 61fae45390
79 changed files with 6527 additions and 369 deletions

View File

@@ -0,0 +1,307 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="490"
:withOkButton="false"
:okButtonDisabled="false"
@close="onCancelClicked"
@closed="emit('closed')"
>
<template #header>
{{ mode === 'create' ? i18n.ts._abuseReport._notificationRecipient.createRecipient : i18n.ts._abuseReport._notificationRecipient.modifyRecipient }}
</template>
<div v-if="loading === 0">
<MkSpacer :marginMin="20" :marginMax="28">
<div :class="$style.root" class="_gaps_m">
<MkInput v-model="title">
<template #label>{{ i18n.ts.title }}</template>
</MkInput>
<MkSelect v-model="method">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
<option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
<option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
<template #caption>
{{ methodCaption }}
</template>
</MkSelect>
<div>
<MkSelect v-if="method === 'email'" v-model="userId">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template>
<option v-for="user in moderators" :key="user.id" :value="user.id">
{{ user.name ? `${user.name}(${user.username})` : user.username }}
</option>
</MkSelect>
<div v-else-if="method === 'webhook'" :class="$style.systemWebhook">
<MkSelect v-model="systemWebhookId" style="flex: 1">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template>
<option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id">
{{ webhook.name }}
</option>
</MkSelect>
<MkButton rounded @click="onEditSystemWebhookClicked">
<span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/>
<span v-else class="ti ti-settings" style="line-height: normal"/>
</MkButton>
</div>
</div>
<MkDivider/>
<MkSwitch v-model="isActive">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</div>
</MkSpacer>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton primary :disabled="disableSubmitButton" @click="onSubmitClicked"><i class="ti ti-check"></i> {{ i18n.ts.ok }}</MkButton>
<MkButton @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
</div>
</div>
<div v-else>
<MkLoading/>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, toRefs } from 'vue';
import { entities } from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import MkInput from '@/components/MkInput.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkSelect from '@/components/MkSelect.vue';
import { MkSystemWebhookResult, showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkDivider from '@/components/MkDivider.vue';
import * as os from '@/os.js';
type NotificationRecipientMethod = 'email' | 'webhook';
const emit = defineEmits<{
(ev: 'submitted'): void;
(ev: 'closed'): void;
}>();
const props = defineProps<{
mode: 'create' | 'edit';
id?: string;
}>();
const { mode, id } = toRefs(props);
const loading = ref<number>(0);
const title = ref<string>('');
const method = ref<NotificationRecipientMethod>('email');
const userId = ref<string | null>(null);
const systemWebhookId = ref<string | null>(null);
const isActive = ref<boolean>(true);
const moderators = ref<entities.User[]>([]);
const systemWebhooks = ref<(entities.SystemWebhook | { id: null, name: string })[]>([]);
const methodCaption = computed(() => {
switch (method.value) {
case 'email': {
return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.mail;
}
case 'webhook': {
return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.webhook;
}
default: {
return '';
}
}
});
const disableSubmitButton = computed(() => {
if (!title.value) {
return true;
}
switch (method.value) {
case 'email': {
return userId.value === null;
}
case 'webhook': {
return systemWebhookId.value === null;
}
default: {
return true;
}
}
});
async function onSubmitClicked() {
await loadingScope(async () => {
const _userId = (method.value === 'email') ? userId.value : null;
const _systemWebhookId = (method.value === 'webhook') ? systemWebhookId.value : null;
const params = {
isActive: isActive.value,
name: title.value,
method: method.value,
userId: _userId ?? undefined,
systemWebhookId: _systemWebhookId ?? undefined,
};
try {
switch (mode.value) {
case 'create': {
await misskeyApi('admin/abuse-report/notification-recipient/create', params);
break;
}
case 'edit': {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await misskeyApi('admin/abuse-report/notification-recipient/update', { id: id.value!, ...params });
break;
}
}
emit('submitted');
// eslint-disable-next-line
} catch (ex: any) {
const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
emit('closed');
}
});
}
function onCancelClicked() {
emit('closed');
}
async function onEditSystemWebhookClicked() {
let result: MkSystemWebhookResult | null;
if (systemWebhookId.value === null) {
result = await showSystemWebhookEditorDialog({
mode: 'create',
});
} else {
result = await showSystemWebhookEditorDialog({
mode: 'edit',
id: systemWebhookId.value,
});
}
if (!result) {
return;
}
await fetchSystemWebhooks();
systemWebhookId.value = result.id ?? null;
}
async function fetchSystemWebhooks() {
await loadingScope(async () => {
systemWebhooks.value = [
{ id: null, name: i18n.ts.createNew },
...await misskeyApi('admin/system-webhook/list', { }),
];
});
}
async function fetchModerators() {
await loadingScope(async () => {
const users = Array.of<entities.User>();
for (; ;) {
const res = await misskeyApi('admin/show-users', {
limit: 100,
state: 'adminOrModerator',
origin: 'local',
offset: users.length,
});
if (res.length === 0) {
break;
}
users.push(...res);
}
moderators.value = users;
});
}
async function loadingScope<T>(fn: () => Promise<T>): Promise<T> {
loading.value++;
try {
return await fn();
} finally {
loading.value--;
}
}
onMounted(async () => {
await loadingScope(async () => {
await fetchModerators();
await fetchSystemWebhooks();
if (mode.value === 'edit') {
if (!id.value) {
throw new Error('id is required');
}
try {
const res = await misskeyApi('admin/abuse-report/notification-recipient/show', { id: id.value });
title.value = res.name;
method.value = res.method;
userId.value = res.userId ?? null;
systemWebhookId.value = res.systemWebhookId ?? null;
isActive.value = res.isActive;
// eslint-disable-next-line
} catch (ex: any) {
const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
emit('closed');
}
} else {
userId.value = moderators.value[0]?.id ?? null;
systemWebhookId.value = systemWebhooks.value[0]?.id ?? null;
}
});
});
</script>
<style lang="scss" module>
.root {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.footer {
display: flex;
justify-content: center;
align-items: flex-end;
margin-top: 20px;
}
.systemWebhook {
display: flex;
flex-direction: row;
justify-content: stretch;
align-items: flex-end;
gap: 8px;
button {
width: 2.5em;
height: 2.5em;
min-width: 2.5em;
min-height: 2.5em;
box-sizing: border-box;
padding: 6px;
}
}
</style>

View File

@@ -0,0 +1,114 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_panel _gaps_s">
<div :class="$style.rightDivider" style="width: 80px;"><span :class="`ti ${methodIcon}`"/> {{ methodName }}</div>
<div :class="$style.rightDivider" style="flex: 0.5">{{ entity.name }}</div>
<div :class="$style.rightDivider" style="flex: 1">
<div v-if="method === 'email' && user">
{{
`${i18n.ts._abuseReport._notificationRecipient.notifiedUser}: ` + ((user.name) ? `${user.name}(${user.username})` : user.username)
}}
</div>
<div v-if="method === 'webhook' && systemWebhook">
{{ `${i18n.ts._abuseReport._notificationRecipient.notifiedWebhook}: ` + systemWebhook.name }}
</div>
</div>
<div :class="$style.recipientButtons" style="margin-left: auto">
<button :class="$style.recipientButton" @click="onEditButtonClicked()">
<span class="ti ti-settings"/>
</button>
<button :class="$style.recipientButton" @click="onDeleteButtonClicked()">
<span class="ti ti-trash"/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { entities } from 'misskey-js';
import { computed, toRefs } from 'vue';
import { i18n } from '@/i18n.js';
const emit = defineEmits<{
(ev: 'edit', id: entities.AbuseReportNotificationRecipient['id']): void;
(ev: 'delete', id: entities.AbuseReportNotificationRecipient['id']): void;
}>();
const props = defineProps<{
entity: entities.AbuseReportNotificationRecipient;
}>();
const { entity } = toRefs(props);
const method = computed(() => entity.value.method);
const user = computed(() => entity.value.user);
const systemWebhook = computed(() => entity.value.systemWebhook);
const methodIcon = computed(() => {
switch (entity.value.method) {
case 'email':
return 'ti-mail';
case 'webhook':
return 'ti-webhook';
default:
return 'ti-help';
}
});
const methodName = computed(() => {
switch (entity.value.method) {
case 'email':
return i18n.ts._abuseReport._notificationRecipient._recipientType.mail;
case 'webhook':
return i18n.ts._abuseReport._notificationRecipient._recipientType.webhook;
default:
return '不明';
}
});
function onEditButtonClicked() {
emit('edit', entity.value.id);
}
function onDeleteButtonClicked() {
emit('delete', entity.value.id);
}
</script>
<style module lang="scss">
.root {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 4px 8px;
}
.rightDivider {
border-right: 0.5px solid var(--divider);
}
.recipientButtons {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
margin-right: -4;
}
.recipientButton {
background-color: transparent;
border: none;
border-radius: 9999px;
box-sizing: border-box;
margin-top: -2px;
margin-bottom: -2px;
padding: 8px;
&:hover {
background-color: var(--buttonBg);
}
}
</style>

View File

@@ -0,0 +1,176 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header>
<XHeader :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkSpacer :contentMax="900">
<div :class="$style.root" class="_gaps_m">
<div :class="$style.addButton">
<MkButton primary @click="onAddButtonClicked">
<span class="ti ti-plus"/> {{ i18n.ts._abuseReport._notificationRecipient.createRecipient }}
</MkButton>
</div>
<div :class="$style.subMenus" class="_gaps_s">
<MkSelect v-model="filterMethod" style="flex: 1">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
<option :value="null">-</option>
<option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
<option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
</MkSelect>
<MkInput v-model="filterText" type="search" style="flex: 1">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template>
</MkInput>
</div>
<MkDivider/>
<div :class="$style.recipients" class="_gaps_s">
<XRecipient
v-for="r in filteredRecipients"
:key="r.id"
:entity="r"
@edit="onEditButtonClicked"
@delete="onDeleteButtonClicked"
/>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script setup lang="ts">
import { entities } from 'misskey-js';
import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
import XRecipient from './notification-recipient.item.vue';
import XHeader from '@/pages/admin/_header_.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import MkDivider from '@/components/MkDivider.vue';
import { i18n } from '@/i18n.js';
const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]);
const filterMethod = ref<string | null>(null);
const filterText = ref<string>('');
const filteredRecipients = computed(() => {
const method = filterMethod.value;
const text = filterText.value.trim().length === 0 ? null : filterText.value;
return recipients.value.filter(it => {
if (method ?? text) {
if (text) {
const keywords = [it.name, it.systemWebhook?.name, it.user?.name, it.user?.username];
if (keywords.filter(k => k?.includes(text)).length !== 0) {
return true;
}
}
if (method) {
return it.method.includes(method);
}
return false;
}
return true;
});
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
async function onAddButtonClicked() {
await showEditor('create');
}
async function onEditButtonClicked(id: string) {
await showEditor('edit', id);
}
async function onDeleteButtonClicked(id: string) {
const res = await os.confirm({
type: 'warning',
title: i18n.ts._abuseReport._notificationRecipient.deleteConfirm,
});
if (!res.canceled) {
await misskeyApi('admin/abuse-report/notification-recipient/delete', { id: id });
await fetchRecipients();
}
}
async function showEditor(mode: 'create' | 'edit', id?: string) {
const { dispose, needLoad } = await new Promise<{ dispose: () => void, needLoad: boolean }>(async resolve => {
const res = await os.popup(
defineAsyncComponent(() => import('./notification-recipient.editor.vue')),
{
mode,
id,
},
{
submitted: async () => {
resolve({ dispose: res.dispose, needLoad: true });
},
closed: () => {
resolve({ dispose: res.dispose, needLoad: false });
},
},
);
});
dispose();
if (needLoad) {
await fetchRecipients();
}
}
async function fetchRecipients() {
const result = await misskeyApi('admin/abuse-report/notification-recipient/list', {
method: ['email', 'webhook'],
});
recipients.value = result.sort((a, b) => (a.method + a.id).localeCompare(b.method + b.id));
}
onMounted(async () => {
await fetchRecipients();
});
</script>
<style module lang="scss">
.root {
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
}
.addButton {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.subMenus {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
}
.recipients {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>