wip: feat: ミス廃アラート

This commit is contained in:
Ebise Lutica 2023-06-09 16:36:31 +09:00
parent 720895df07
commit 063781478b
12 changed files with 291 additions and 43 deletions

View File

@ -6,9 +6,9 @@ import { getMisskey } from '@/libs/misskey.js';
import { prisma } from '@/libs/prisma.js'; import { prisma } from '@/libs/prisma.js';
import { connection } from '@/libs/redis.js'; import { connection } from '@/libs/redis.js';
import { queues } from '@/queue/index.js'; import { queues } from '@/queue/index.js';
import { format } from '@/services/holic/format'; import { format } from '@/services/holic/format.js';
import { avg } from '@/utils/avg.js'; import { avg } from '@/utils/avg.js';
import { toAcct } from '@/utils/to-acct'; import { toAcct } from '@/utils/to-acct.js';
const NAME = 'holicAggregate'; const NAME = 'holicAggregate';
@ -24,6 +24,9 @@ export const holicAggregateWorker = new Worker<HolicAggregateQueueType>(NAME, as
// ステータスをお問い合わせ // ステータスをお問い合わせ
const { account } = job.data; const { account } = job.data;
const { misskeySession: session } = account; const { misskeySession: session } = account;
const acct = toAcct(session);
console.log(`[holic] Aggregating for ${acct}`);
const data = await getMisskey(session.host).request('i', {}, session.token); const data = await getMisskey(session.host).request('i', {}, session.token);
if (!('notesCount' in data)) { if (!('notesCount' in data)) {
job.discard(); job.discard();
@ -43,7 +46,9 @@ export const holicAggregateWorker = new Worker<HolicAggregateQueueType>(NAME, as
const rating = records1week.length > 0 ? avg([ const rating = records1week.length > 0 ? avg([
...records1week.map(r => r.notesCount), ...records1week.map(r => r.notesCount),
data.notesCount, data.notesCount,
]) : data.notesCount; ]) : 0;
console.log(`[holic] RATING of ${acct}: ${rating}`);
// 今日分のデータ作成 // 今日分のデータ作成
const today = await prisma.holicRecord.create({ const today = await prisma.holicRecord.create({
@ -57,15 +62,8 @@ export const holicAggregateWorker = new Worker<HolicAggregateQueueType>(NAME, as
}, },
}); });
const yesterday: HolicRecord = records1week[0] ?? {
id: 0, const yesterday: HolicRecord = records1week[0] ?? today;
rating: 0,
date: new Date(),
accountId: account.misskeySessionId,
notesCount: 0,
followingCount: 0,
followersCount: 0,
};
const text = format({ const text = format({
today, today,
@ -74,7 +72,6 @@ export const holicAggregateWorker = new Worker<HolicAggregateQueueType>(NAME, as
session, session,
}); });
const acct = toAcct(session);
if (account.alertAsNote) { if (account.alertAsNote) {
queues.holicNoteQueue.add(acct, { queues.holicNoteQueue.add(acct, {
account, account,

View File

@ -5,7 +5,8 @@ import type { MisskeySession, HolicAccount } from '@prisma/client';
import { getMisskey } from '@/libs/misskey.js'; import { getMisskey } from '@/libs/misskey.js';
import { connection } from '@/libs/redis.js'; import { connection } from '@/libs/redis.js';
import { isVisibility } from '@/utils/is-visibility'; import { isVisibility } from '@/utils/is-visibility.js';
import { toAcct } from '@/utils/to-acct.js';
const NAME = 'holicNote'; const NAME = 'holicNote';
@ -20,6 +21,8 @@ export const holicNoteQueue = new Queue<HolicNoteQueueType>(NAME, { connection }
export const holicNoteWorker = new Worker<HolicNoteQueueType>(NAME, async (job) => { export const holicNoteWorker = new Worker<HolicNoteQueueType>(NAME, async (job) => {
const { session, account, text } = job.data; const { session, account, text } = job.data;
console.log(`[holic] Processing note job for ${toAcct(session)}`);
const api = getMisskey(session.host); const api = getMisskey(session.host);
if (!isVisibility(account.noteVisibility)) { if (!isVisibility(account.noteVisibility)) {
@ -36,6 +39,7 @@ export const holicNoteWorker = new Worker<HolicNoteQueueType>(NAME, async (job)
localOnly: account.noteLocalOnly, localOnly: account.noteLocalOnly,
}, session.token); }, session.token);
} catch (e) { } catch (e) {
console.log(`[holic] Failed to create a note: ${e}`);
if (!misskey.api.isAPIError(e)) throw e; if (!misskey.api.isAPIError(e)) throw e;
if (e.code === 'RATE_LIMIT_EXCEEDED') { if (e.code === 'RATE_LIMIT_EXCEEDED') {
// delay 1h // delay 1h

View File

@ -4,6 +4,7 @@ import type { MisskeySession, HolicAccount } from '@prisma/client';
import { getMisskey } from '@/libs/misskey.js'; import { getMisskey } from '@/libs/misskey.js';
import { connection } from '@/libs/redis.js'; import { connection } from '@/libs/redis.js';
import { toAcct } from '@/utils/to-acct';
const NAME = 'holicNotification'; const NAME = 'holicNotification';
@ -17,6 +18,7 @@ export const holicNotificationQueue = new Queue<HolicNotificationQueueType>(NAME
export const holicNotificationWorker = new Worker<HolicNotificationQueueType>(NAME, async (job) => { export const holicNotificationWorker = new Worker<HolicNotificationQueueType>(NAME, async (job) => {
const { session, text } = job.data; const { session, text } = job.data;
console.log(`[holic] Processing note job for ${toAcct(session)}`);
const api = getMisskey(session.host); const api = getMisskey(session.host);
await api.request('notifications/create', { await api.request('notifications/create', {

View File

@ -48,5 +48,5 @@ export const format = (p: VariableParameter): string => {
return template.replace(variableRegex, (m, name) => { return template.replace(variableRegex, (m, name) => {
const v = variables[name]; const v = variables[name];
return !v ? m : typeof v === 'function' ? v(p) : v; return !v ? m : typeof v === 'function' ? v(p) : v;
}) + '\n\n#missholic'; }) + '\n\n#misskeholic';
}; };

View File

@ -13,11 +13,13 @@ const IndexWelcome = lazy(() => import('@/pages/index.welcome'));
const Settings = lazy(() => import('@/pages/settings')); const Settings = lazy(() => import('@/pages/settings'));
const Appearance = lazy(() => import('@/pages/settings/appearance')); const Appearance = lazy(() => import('@/pages/settings/appearance'));
const Account = lazy(() => import('@/pages/settings/account')); const Account = lazy(() => import('@/pages/settings/account'));
const AnnouncementsPage = lazy(() => import('@/pages/announcements')); const Announcements = lazy(() => import('@/pages/announcements'));
const AboutPage = lazy(() => import('@/pages/about')); const About = lazy(() => import('@/pages/about'));
const AppsNoteScheduler = lazy(() => import('@/pages/apps/note-scheduler')); const AppsNoteScheduler = lazy(() => import('@/pages/apps/note-scheduler'));
const AppsNoteSchedulerNew = lazy(() => import('@/pages/apps/note-scheduler.new')); const AppsNoteSchedulerNew = lazy(() => import('@/pages/apps/note-scheduler.new'));
const AppsNoteSchedulerEdit = lazy(() => import('@/pages/apps/note-scheduler.edit')); const AppsNoteSchedulerEdit = lazy(() => import('@/pages/apps/note-scheduler.edit'));
const Misskeholic = lazy(() => import('@/pages/apps/misskeholic'));
const MisskeholicUser = lazy(() => import('@/pages/apps/misskeholic.user'));
const NotFound = lazy(() => import('@/pages/not-found')); const NotFound = lazy(() => import('@/pages/not-found'));
export const App : React.FC = () => { export const App : React.FC = () => {
@ -34,11 +36,13 @@ export const App : React.FC = () => {
<Route path="account" element={<Account />}/> <Route path="account" element={<Account />}/>
<Route path="*" element={<p>Not Found</p>}/> <Route path="*" element={<p>Not Found</p>}/>
</Route> </Route>
<Route path="/announcements/:id" element={<AnnouncementsPage />}/> <Route path="/announcements/:id" element={<Announcements />}/>
<Route path="/about" element={<AboutPage />}/> <Route path="/about" element={<About />}/>
<Route path="/apps/note-scheduler" element={<AppsNoteScheduler />}/> <Route path="/apps/note-scheduler" element={<AppsNoteScheduler />}/>
<Route path="/apps/note-scheduler/new" element={<AppsNoteSchedulerNew />}/> <Route path="/apps/note-scheduler/new" element={<AppsNoteSchedulerNew />}/>
<Route path="/apps/note-scheduler/edit/:id" element={<AppsNoteSchedulerEdit />}/> <Route path="/apps/note-scheduler/edit/:id" element={<AppsNoteSchedulerEdit />}/>
<Route path="/apps/misskeholic" element={<Misskeholic />}/>
<Route path="/apps/misskeholic/:id" element={<MisskeholicUser />}/>
<Route path="*" element={<NotFound />}/> <Route path="*" element={<NotFound />}/>
</Routes> </Routes>
<VStack as="footer" alignItems="center" css={{ padding: '$2xl $m' }}> <VStack as="footer" alignItems="center" css={{ padding: '$2xl $m' }}>

View File

@ -0,0 +1,112 @@
import { useEffect, useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { RouterOutput } from '@/libs/trpc';
import { HStack } from '@/components/layouts/HStack';
import { VStack } from '@/components/layouts/VStack';
import { Alert } from '@/components/primitives/Alert';
import { Button } from '@/components/primitives/Button';
import { Card } from '@/components/primitives/Card';
import { Radio } from '@/components/primitives/Radio';
import { RadioGroup } from '@/components/primitives/RadioGroup';
import { Switch } from '@/components/primitives/Switch';
import { Text } from '@/components/primitives/Text';
import { Textarea } from '@/components/primitives/Textarea';
type Account = NonNullable<RouterOutput['holic']['getAccount']>;
export type HolicSettingsDraft = Omit<Account, 'misskeySessionId'>;
export type HolicSettingsProp = {
draft?: HolicSettingsDraft;
onUpdateDraft?: (newDraft: HolicSettingsDraft) => void;
};
export const DRAFT_DEFAULT: HolicSettingsDraft = {
alertAsNote: false,
alertAsNotification: true,
noteVisibility: 'home',
noteLocalOnly: false,
rankingVisible: false,
template: null,
};
export const HolicSettings: React.FC<HolicSettingsProp> = (p) => {
const { t } = useTranslation();
const [draft, setDraft] = useState<HolicSettingsDraft>(DRAFT_DEFAULT);
const [isHelpDialogOpened, setHelpDialogOpened] = useState(false);
useEffect(() => {
if (!p.draft) return;
setDraft(p.draft);
}, [p.draft]);
const updateDraft = <K extends keyof HolicSettingsDraft, V extends HolicSettingsDraft[K]>(key: K, value: V) => {
const newDraft = { ...draft };
newDraft[key] = value;
setDraft(newDraft);
p.onUpdateDraft?.(newDraft);
};
return (
<>
<Card pale>
<h1>{t('alertMode')}</h1>
<VStack>
<Switch checked={draft.alertAsNote} onChange={v => updateDraft('alertAsNote', v)}>
{t('_misshaiAlert.note')}
</Switch>
<Switch checked={draft.alertAsNotification} onChange={v => updateDraft('alertAsNotification', v)}>
{t('_misshaiAlert.notification')}
</Switch>
</VStack>
</Card>
<Card pale>
<h1>{t('visibility')}</h1>
<VStack>
<RadioGroup value={draft.noteVisibility} onValueChange={v => updateDraft('noteVisibility', v)}>
<Radio value="home">{t('_visibility.home')}</Radio>
<Radio value="followers">{t('_visibility.followers')}</Radio>
</RadioGroup>
<Switch checked={draft.noteLocalOnly} onChange={v => updateDraft('noteLocalOnly', v)}>
{t('noFederation')}
</Switch>
</VStack>
</Card>
<Card pale>
<h1>{t('_misshaiAlert.holicRanking')}</h1>
<VStack>
<Switch checked={draft.rankingVisible} onChange={v => updateDraft('rankingVisible', v)}>
{t('_misshaiAlert.useRanking')}
</Switch>
</VStack>
</Card>
<Card pale>
<h1>{t('template')}</h1>
<p>
{t('_template.description')}<br/>
<Text as="span" fontSize="s" color="muted">{t('_template.hashtagAutomaticInsertion')}</Text>
</p>
<VStack alignItems="left">
<HStack>
<Button size="small" primary>{t('_template.insertVariables')}</Button>
<Button size="small" flat primary css={{ fontSize: 20, padding: 0 }} onClick={() => setHelpDialogOpened(true)}>
<i className="ti ti-help-circle"/>
</Button>
</HStack>
<Textarea rows={8} placeholder={t('_template.default')} value={draft.template ?? ''} onChange={e => updateDraft('template', e.target.value)} />
</VStack>
</Card>
<Alert
description={t('_template.insertVariablesHelp')}
open={isHelpDialogOpened}
onOpenChange={setHelpDialogOpened}
/>
</>
);
};

View File

@ -14,6 +14,7 @@ export type AlertProp = {
title?: string; title?: string;
description: string; description: string;
cancelText?: string; cancelText?: string;
hasCancel?: boolean;
okText?: string; okText?: string;
danger?: boolean; danger?: boolean;
onOkClick?: () => void; onOkClick?: () => void;
@ -32,11 +33,13 @@ export const Alert: React.FC<AlertProp> = (p) => {
{p.description} {p.description}
</AlertDialogDescription> </AlertDialogDescription>
<HStack justifyContent="right"> <HStack justifyContent="right">
{p.hasCancel && (
<$.Cancel asChild> <$.Cancel asChild>
<Button flat> <Button flat>
{p.cancelText ?? t('cancel')} {p.cancelText ?? t('cancel')}
</Button> </Button>
</$.Cancel> </$.Cancel>
)}
<$.Action asChild> <$.Action asChild>
<Button danger={p.danger} primaryGradient={!p.danger} onClick={p.onOkClick}> <Button danger={p.danger} primaryGradient={!p.danger} onClick={p.onOkClick}>
{p.okText ?? t('ok')} {p.okText ?? t('ok')}

View File

@ -50,7 +50,7 @@
"upload": "アップロード", "upload": "アップロード",
"preview": "プレビュー", "preview": "プレビュー",
"dashboard": "ダッシュボード", "dashboard": "ダッシュボード",
"missHaiAlert": "ミス廃アラート", "misskeholicAlert": "ミス廃アラート",
"avatarCropper": "アバタークロッパー", "avatarCropper": "アバタークロッパー",
"questionBox": "質問ボックス", "questionBox": "質問ボックス",
"followingManager": "フォローマネージャー", "followingManager": "フォローマネージャー",
@ -77,9 +77,9 @@
"datetime": "日時", "datetime": "日時",
"_welcome": { "_welcome": {
"misshaiAlertTitle": "ミス廃アラート", "misskeholicAlertTitle": "ミス廃アラート",
"misshaiAlertDescription": "Misskeyにのめり込んでいませんかミス廃アラートを使えば、毎日のMisskeyでの活動量を定期投稿できます。", "misskeholicAlertDescription": "Misskeyにのめり込んでいませんかミス廃アラートを使えば、毎日のMisskeyでの活動量を定期投稿できます。",
"misshaiRankingDescription": "ミス廃ランキングでは、Misskeyでの活動を数値化し、ランキング表示します。", "misskeholicRankingDescription": "ミス廃ランキングでは、Misskeyでの活動を数値化し、ランキング表示します。",
"avatarCropperDescription": "丸形、角丸形、そして猫耳のついた状態をプレビューしながら、アイコンを自在に切り抜けます。魅力的なアバターを、もっと魅力的に。", "avatarCropperDescription": "丸形、角丸形、そして猫耳のついた状態をプレビューしながら、アイコンを自在に切り抜けます。魅力的なアバターを、もっと魅力的に。",
"questionBoxDescription": "匿名で質問できるページを開設しよう。届いた質問には、お使いのMisskeyアカウントで回答できます。", "questionBoxDescription": "匿名で質問できるページを開設しよう。届いた質問には、お使いのMisskeyアカウントで回答できます。",
"followingManagerDescription": "増えすぎたフォロー・フォロワーを管理できる高機能なマネージャー。所属サーバーや、フォローバックされていないアカウントで絞り込むことができ、一括処理にも対応しています。", "followingManagerDescription": "増えすぎたフォロー・フォロワーを管理できる高機能なマネージャー。所属サーバーや、フォローバックされていないアカウントで絞り込むことができ、一括処理にも対応しています。",
@ -95,16 +95,17 @@
}, },
"_widgets": { "_widgets": {
"misshaiData": "今日のミス廃データ", "holicData": "今日のミス廃データ",
"announcements": "お知らせ", "announcements": "お知らせ",
"questionBox": "質問ボックス", "questionBox": "質問ボックス",
"apps": "アプリ", "apps": "アプリ",
"unreadHints": "未読のヒント" "unreadHints": "未読のヒント"
}, },
"_missHai": { "_misshaiAlert": {
"ranking": "ミス廃ランキング", "gettingStarted": "利用を開始する",
"rankingDescription": "ユーザーの「ミス廃レート」を算出し、高い順にランキング表示しています。", "holicRanking": "ミス廃ランキング",
"holicRankingDescription": "ユーザーの「ミス廃レート」を算出し、高い順にランキング表示しています。",
"showAll": "全員分見る", "showAll": "全員分見る",
"data": "ミス廃データ", "data": "ミス廃データ",
"dataBody": "内容", "dataBody": "内容",
@ -118,6 +119,15 @@
"notification": "Misskeyに通知する", "notification": "Misskeyに通知する",
"notificationWarning": "Misskeyのバージョンによっては動作しません。" "notificationWarning": "Misskeyのバージョンによっては動作しません。"
}, },
"_noteScheduler": {
"createNew": "新規作成",
"edit": "編集",
"delete": "削除",
"noNotes": "予約投稿はまだ作成されていません。",
"deleteConfirm": "本当に予約投稿「{{note}}」を削除しますか?",
"accountToNote": "投稿するアカウント",
"schedule": "投稿を予約"
},
"_accounts": { "_accounts": {
"switchAccount": "アカウント切り替え", "switchAccount": "アカウント切り替え",
"useAnother": "他のアカウントで登録する" "useAnother": "他のアカウントで登録する"
@ -135,7 +145,7 @@
}, },
"_template": { "_template": {
"description": "アラートの自動投稿をカスタマイズできます。", "description": "アラートの自動投稿をカスタマイズできます。",
"description2": "ハッシュタグ #misshaialert は、テンプレートに関わらず自動付与されます。", "hashtagAutomaticInsertion": "ハッシュタグ #missholicalert は、テンプレートに関わらず自動付与されます。",
"default": "昨日のMisskeyの活動は\n\nート: {notesCount}({notesDelta})\nフォロー : {followingCount}({followingDelta})\nフォロワー :{followersCount}({followersDelta})\n\nでした。\n{url}", "default": "昨日のMisskeyの活動は\n\nート: {notesCount}({notesDelta})\nフォロー : {followingCount}({followingDelta})\nフォロワー :{followersCount}({followersDelta})\n\nでした。\n{url}",
"insertVariables": "変数を挿入する", "insertVariables": "変数を挿入する",
"insertVariablesHelp": "{ } で囲われた文字列は変数と呼ばれ、特別な意味を持ちます。これを含めると、投稿時に自動的に値が埋め込まれます。", "insertVariablesHelp": "{ } で囲われた文字列は変数と呼ばれ、特別な意味を持ちます。これを含めると、投稿時に自動的に値が埋め込まれます。",
@ -185,14 +195,5 @@
"no": "キャンセル", "no": "キャンセル",
"success": "アカウントを解除しました。トップ画面に戻ります。", "success": "アカウントを解除しました。トップ画面に戻ります。",
"failure": "アカウントを解除できませんでした。" "failure": "アカウントを解除できませんでした。"
},
"_noteScheduler": {
"createNew": "新規作成",
"edit": "編集",
"delete": "削除",
"noNotes": "予約投稿はまだ作成されていません。",
"deleteConfirm": "本当に予約投稿「{{note}}」を削除しますか?",
"accountToNote": "投稿するアカウント",
"schedule": "投稿を予約"
} }
} }

View File

@ -1,4 +1,11 @@
import { blackA, olive, oliveDark, tomato, tomatoDark, whiteA } from '@radix-ui/colors'; import {
blackA,
olive,
oliveDark,
tomato,
tomatoDark,
whiteA,
} from '@radix-ui/colors';
import { createStitches } from '@stitches/react'; import { createStitches } from '@stitches/react';
export const { export const {

View File

@ -0,0 +1,61 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type { RouterOutput } from '@/libs/trpc';
import { VStack } from '@/components/layouts/VStack';
import { PageRoot } from '@/components/PageRoot';
import { Button } from '@/components/primitives/Button';
import { Heading } from '@/components/primitives/Heading';
import { styled } from '@/libs/stitches';
import { trpc } from '@/libs/trpc';
const StyledButton = styled('button', {
borderRadius: '$2',
padding: '$m',
background: '$card',
boxShadow: '$s',
textDecoration: 'none',
color: '$fg',
});
type SessionSelectorProp = {
onSelect?: (session: RouterOutput['account']['getMisskeySessions'][number]) => void;
};
const SessionSelector: React.FC<SessionSelectorProp> = (p) => {
const [sessions] = trpc.account.getMisskeySessions.useSuspenseQuery();
const { mutateAsync: run } = trpc.holic.adminForceRunAll.useMutation();
const onClickForceRunButton = () => run();
return (
<>
<p>Misskey </p>
<VStack>
{sessions.map(s => (
<StyledButton key={s.id} as={Link} to={`/apps/misskeholic/${s.id}`} onClick={() => {
p.onSelect?.(s);
}}>
@{s.username}@{s.host}
</StyledButton>
))}
</VStack>
<Button danger onClick={onClickForceRunButton}>Force RUN</Button>
</>
);
};
const MisskeholicPage: React.FC = () => {
const { t } = useTranslation();
return (
<PageRoot title={t('misskeholicAlert')}>
<Heading>{t('misskeholicAlert')}</Heading>
<SessionSelector/>
</PageRoot>
);
};
export default MisskeholicPage;

View File

@ -0,0 +1,56 @@
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { DRAFT_DEFAULT, HolicSettings } from '@/components/domains/holic/HolicSettings';
import { VStack } from '@/components/layouts/VStack';
import { PageRoot } from '@/components/PageRoot';
import { Button } from '@/components/primitives/Button';
import { Heading } from '@/components/primitives/Heading';
import { trpc } from '@/libs/trpc';
const MisskeholicUserPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams();
const [ sessions ] = trpc.account.getMisskeySessions.useSuspenseQuery();
const [ holicAccount, { refetch } ] = trpc.holic.getAccount.useSuspenseQuery({ sessionId: id ?? '' });
const { mutateAsync: createAccount } = trpc.holic.createAccount.useMutation();
const session = useMemo(() => sessions.find(s => s.id === id), [id, sessions]);
const [draft, setDraft] = useState(DRAFT_DEFAULT);
if (session === null) return null;
const startHolic = async () => {
if (!session) return;
await createAccount({
sessionId: session.id,
...draft,
});
await refetch();
};
return (
<PageRoot title={t('misskeholicAlert')}>
<Heading>{t('misskeholicAlert')}</Heading>
{!holicAccount ? (
<>
<VStack>
<p></p>
<HolicSettings draft={draft} onUpdateDraft={setDraft} />
<Button primaryGradient size="large" css={{ margin: '$xl auto 0 auto' }} onClick={startHolic}>
</Button>
</VStack>
</>
) : (
<pre>
{JSON.stringify(holicAccount, null, ' ')}
</pre>
)}
</PageRoot>
);
};
export default MisskeholicUserPage;

View File

@ -111,6 +111,7 @@ const NoteCard: React.FC<{ note: RouterOutput['noteScheduler']['list'][number] }
</VStack> </VStack>
<Alert <Alert
danger danger
hasCancel
description={t('_noteScheduler.deleteConfirm', { note: note.text })} description={t('_noteScheduler.deleteConfirm', { note: note.text })}
open={deleteConfirmDialogOpened} open={deleteConfirmDialogOpened}
onOpenChange={setDeleteConfirmDialogOpened} onOpenChange={setDeleteConfirmDialogOpened}