0
0
Fork 0

コメント追加したり

This commit is contained in:
xeltica 2021-09-04 18:24:11 +09:00
parent 1c45e759c3
commit b9575d2c5b
22 changed files with 945 additions and 809 deletions

View file

@ -19,6 +19,9 @@ module.exports = {
'indent': [ 'indent': [
'error', 'error',
'tab', 'tab',
{
'SwitchCase': 1,
}
], ],
'quotes': [ 'quotes': [
'error', 'error',

View file

@ -15,6 +15,12 @@ export class RankingController {
return this.getResponse(getState().nowCalculating, limit ? Number(limit) : undefined); return this.getResponse(getState().nowCalculating, limit ? Number(limit) : undefined);
} }
/**
* DBに問い合わせてランキングを取得する
* @param isCalculating
* @param limit
* @returns
*/
private async getResponse(isCalculating: boolean, limit?: number) { private async getResponse(isCalculating: boolean, limit?: number) {
const ranking = isCalculating ? [] : (await getRanking(limit)).map((u) => ({ const ranking = isCalculating ? [] : (await getRanking(limit)).map((u) => ({
id: u.id, id: u.id,

View file

@ -2,6 +2,9 @@ import { config } from '../../config';
import { User } from '../models/entities/user'; import { User } from '../models/entities/user';
import { Score } from '../../common/types/score'; import { Score } from '../../common/types/score';
/**
* 稿
*/
export const defaultTemplate = `昨日のMisskeyの活動は export const defaultTemplate = `昨日のMisskeyの活動は
: {notesCount}({notesDelta}) : {notesCount}({notesDelta})
@ -11,11 +14,17 @@ export const defaultTemplate = `昨日のMisskeyの活動は
{url}`; {url}`;
/**
*
*/
export type Variable = { export type Variable = {
description?: string; description?: string;
replace?: string | ((score: Score, user: User) => string); replace?: string | ((score: Score, user: User) => string);
}; };
/**
*
*/
export const variables: Record<string, Variable> = { export const variables: Record<string, Variable> = {
notesCount: { notesCount: {
description: 'ノート数', description: 'ノート数',
@ -61,6 +70,12 @@ export const variables: Record<string, Variable> = {
const variableRegex = /\{([a-zA-Z0-9_]+?)\}/g; const variableRegex = /\{([a-zA-Z0-9_]+?)\}/g;
/**
*
* @param score
* @param user
* @returns
*/
export const format = (score: Score, user: User): string => { export const format = (score: Score, user: User): string => {
const template = user.template || defaultTemplate; const template = user.template || defaultTemplate;
return template.replace(variableRegex, (m, name) => { return template.replace(variableRegex, (m, name) => {

View file

@ -2,6 +2,9 @@ import rndstr from 'rndstr';
import { UsedToken } from '../models/entities/used-token'; import { UsedToken } from '../models/entities/used-token';
import { UsedTokens } from '../models'; import { UsedTokens } from '../models';
/**
*
*/
export const genToken = async (): Promise<string> => { export const genToken = async (): Promise<string> => {
let used: UsedToken | undefined = undefined; let used: UsedToken | undefined = undefined;
let token: string; let token: string;
@ -10,4 +13,4 @@ export const genToken = async (): Promise<string> => {
used = await UsedTokens.findOne({ token }); used = await UsedTokens.findOne({ token });
} while (used !== undefined); } while (used !== undefined);
return token; return token;
}; };

View file

@ -3,6 +3,11 @@ import { Score } from '../../common/types/score';
import { api } from '../services/misskey'; import { api } from '../services/misskey';
import { toSignedString } from './to-signed-string'; import { toSignedString } from './to-signed-string';
/**
*
* @param user
* @returns
*/
export const getScores = async (user: User): Promise<Score> => { export const getScores = async (user: User): Promise<Score> => {
const miUser = await api<Record<string, number>>(user.host, 'users/show', { username: user.username }, user.token); const miUser = await api<Record<string, number>>(user.host, 'users/show', { username: user.username }, user.token);
if (miUser.error) { if (miUser.error) {

View file

@ -1,14 +1,19 @@
import { Users } from '../models'; import { Users } from '../models';
import { User } from '../models/entities/user'; import { User } from '../models/entities/user';
/**
*
* @param limit
* @returns
*/
export const getRanking = async (limit: number | null = 10): Promise<User[]> => { export const getRanking = async (limit: number | null = 10): Promise<User[]> => {
const query = Users.createQueryBuilder('user') const query = Users.createQueryBuilder('user')
.where('"user"."bannedFromRanking" IS NOT TRUE') .where('"user"."bannedFromRanking" IS NOT TRUE')
.orderBy('"user".rating', 'DESC'); .orderBy('"user".rating', 'DESC');
if (limit !== null) { if (limit !== null) {
query.limit(limit); query.limit(limit);
} }
return await query.getMany(); return await query.getMany();
}; };

View file

@ -1 +1,6 @@
/**
*
* @param num
* @returns
*/
export const toSignedString = (num: number): string => num < 0 ? num.toString() : '+' + num; export const toSignedString = (num: number): string => num < 0 ? num.toString() : '+' + num;

View file

@ -4,6 +4,11 @@ import { User } from '../models/entities/user';
import { updateUser } from './users'; import { updateUser } from './users';
import { MiUser } from './update-score'; import { MiUser } from './update-score';
/**
*
* @param user
* @param miUser Misskeyのユーザー
*/
export const updateRating = async (user: User, miUser: MiUser): Promise<void> => { export const updateRating = async (user: User, miUser: MiUser): Promise<void> => {
const elapsedDays = dayjs().diff(dayjs(miUser.createdAt), 'd') + 1; const elapsedDays = dayjs().diff(dayjs(miUser.createdAt), 'd') + 1;
await updateUser(user.username, user.host, { await updateUser(user.username, user.host, {

View file

@ -1,6 +1,9 @@
import { User } from '../models/entities/user'; import { User } from '../models/entities/user';
import { updateUser } from './users'; import { updateUser } from './users';
/**
* Misskeyのユーザーモデル
*/
export type MiUser = { export type MiUser = {
notesCount: number, notesCount: number,
followingCount: number, followingCount: number,
@ -8,10 +11,15 @@ export type MiUser = {
createdAt: string, createdAt: string,
}; };
export const updateScore = async (user: User, miUser: MiUser): Promise<void> => { /**
*
* @param user
* @param miUser Misskeyのユーザー
*/
export const updateScore = async (user: User, miUser: MiUser): Promise<void> => {
await updateUser(user.username, user.host, { await updateUser(user.username, user.host, {
prevNotesCount: miUser.notesCount ?? 0, prevNotesCount: miUser.notesCount ?? 0,
prevFollowingCount: miUser.followingCount ?? 0, prevFollowingCount: miUser.followingCount ?? 0,
prevFollowersCount: miUser.followersCount ?? 0, prevFollowersCount: miUser.followersCount ?? 0,
}); });
}; };

View file

@ -2,26 +2,47 @@ import { User } from '../models/entities/user';
import { Users } from '../models'; import { Users } from '../models';
import { DeepPartial } from 'typeorm'; import { DeepPartial } from 'typeorm';
import { genToken } from './gen-token'; import { genToken } from './gen-token';
import pick from 'object.pick';
/**
*
* @param username
* @param host
* @returns
*/
export const getUser = (username: string, host: string): Promise<User | undefined> => { export const getUser = (username: string, host: string): Promise<User | undefined> => {
return Users.findOne({ username, host }); return Users.findOne({ username, host });
}; };
/**
*
* @param user
* @returns
*/
export const updateUsersMisshaiToken = async (user: User | User['id']): Promise<string> => { export const updateUsersMisshaiToken = async (user: User | User['id']): Promise<string> => {
const u = typeof user === 'number' const u = typeof user === 'number'
? user ? user
: user.id; : user.id;
const misshaiToken = await genToken(); const misshaiToken = await genToken();
Users.update(u, { misshaiToken }); Users.update(u, { misshaiToken });
return misshaiToken; return misshaiToken;
}; };
/**
*
* @param token
* @returns
*/
export const getUserByMisshaiToken = (token: string): Promise<User | undefined> => { export const getUserByMisshaiToken = (token: string): Promise<User | undefined> => {
return Users.findOne({ misshaiToken: token }); return Users.findOne({ misshaiToken: token });
}; };
/**
*
* @param username
* @param host
* @param token
*/
export const upsertUser = async (username: string, host: string, token: string): Promise<void> => { export const upsertUser = async (username: string, host: string, token: string): Promise<void> => {
const u = await getUser(username, host); const u = await getUser(username, host);
if (u) { if (u) {
@ -31,14 +52,29 @@ export const upsertUser = async (username: string, host: string, token: string):
} }
}; };
/**
*
* @param username
* @param host
* @param record
*/
export const updateUser = async (username: string, host: string, record: DeepPartial<User>): Promise<void> => { export const updateUser = async (username: string, host: string, record: DeepPartial<User>): Promise<void> => {
await Users.update({ username, host }, record); await Users.update({ username, host }, record);
}; };
/**
*
* @param username
* @param host
*/
export const deleteUser = async (username: string, host: string): Promise<void> => { export const deleteUser = async (username: string, host: string): Promise<void> => {
await Users.delete({ username, host }); await Users.delete({ username, host });
}; };
/**
*
* @returns
*/
export const getUserCount = (): Promise<number> => { export const getUserCount = (): Promise<number> => {
return Users.count(); return Users.count();
}; };

View file

@ -11,7 +11,7 @@ import { upsertUser, getUser, updateUser, updateUsersMisshaiToken, getUserByMiss
import { api } from './services/misskey'; import { api } from './services/misskey';
import { AlertMode, alertModes } from '../common/types/alert-mode'; import { AlertMode, alertModes } from '../common/types/alert-mode';
import { Users } from './models'; import { Users } from './models';
import { send } from './services/send'; import { sendAlert } from './services/send-alert';
import { visibilities, Visibility } from '../common/types/visibility'; import { visibilities, Visibility } from '../common/types/visibility';
import { defaultTemplate } from './functions/format'; import { defaultTemplate } from './functions/format';
import { die } from './die'; import { die } from './die';
@ -212,7 +212,7 @@ router.post('/send', async ctx => {
await die(ctx); await die(ctx);
return; return;
} }
await send(u).catch(() => die(ctx)); await sendAlert(u).catch(() => die(ctx));
ctx.redirect('/?from=send'); ctx.redirect('/?from=send');
}); });

View file

@ -8,7 +8,13 @@ export const entities = [
UsedToken, UsedToken,
]; ];
/**
*
* @param force
* @returns DBコネクション
*/
export const initDb = async (force = false): Promise<Connection> => { export const initDb = async (force = false): Promise<Connection> => {
// forceがtrueでない限り、既に接続が存在する場合はそれを返す
if (!force) { if (!force) {
try { try {
const conn = getConnection(); const conn = getConnection();
@ -19,6 +25,7 @@ export const initDb = async (force = false): Promise<Connection> => {
} }
} }
// 接続がないか、forceがtrueの場合は新規作成する
return createConnection({ return createConnection({
type: 'postgres', type: 'postgres',
host: config.db.host, host: config.db.host,

View file

@ -4,9 +4,11 @@ import _const from '../const';
export const ua = `Mozilla/5.0 misshaialertBot/${_const.version} +https://github.com/Xeltica/misshaialert Node/${process.version}`; export const ua = `Mozilla/5.0 misshaialertBot/${_const.version} +https://github.com/Xeltica/misshaialert Node/${process.version}`;
axios.defaults.headers['User-Agent'] = ua; axios.defaults.headers['User-Agent'] = ua;
axios.defaults.validateStatus = (stat) => stat < 500; axios.defaults.validateStatus = (stat) => stat < 500;
/**
* Misskey APIを呼び出す
*/
export const api = <T = Record<string, unknown>>(host: string, endpoint: string, arg: Record<string, unknown>, i?: string): Promise<T> => { export const api = <T = Record<string, unknown>>(host: string, endpoint: string, arg: Record<string, unknown>, i?: string): Promise<T> => {
const a = { ...arg }; const a = { ...arg };
if (i) { if (i) {
@ -15,6 +17,12 @@ export const api = <T = Record<string, unknown>>(host: string, endpoint: string,
return axios.post<T>(`https://${host}/api/${endpoint}`, a).then(res => res.data); return axios.post<T>(`https://${host}/api/${endpoint}`, a).then(res => res.data);
}; };
/**
*
* @param host
* @param i
* @returns truefalse
*/
export const apiAvailable = async (host: string, i: string): Promise<boolean> => { export const apiAvailable = async (host: string, i: string): Promise<boolean> => {
try { try {
const res = await api(host, 'i', {}, i); const res = await api(host, 'i', {}, i);

View file

@ -0,0 +1,55 @@
import { User } from '../models/entities/user';
import { format } from '../functions/format';
import { getScores } from '../functions/get-scores';
import { api } from './misskey';
/**
*
* @param user
*/
export const sendAlert = async (user: User) => {
const text = format(await getScores(user), user);
switch (user.alertMode) {
case 'note':
await sendNoteAlert(text, user);
break;
case 'notification':
await sendNotificationAlert(text, user);
break;
}
};
/**
*
* @param text
* @param user
*/
const sendNoteAlert = async (text: string, user: User) => {
const res = await api<Record<string, unknown>>(user.host, 'notes/create', {
text,
visibility: user.visibility,
localOnly: user.localOnly,
remoteFollowersOnly: user.remoteFollowersOnly,
}, user.token);
if (res.error) {
throw res.error || res;
}
};
/**
*
* @param text
* @param user
*/
const sendNotificationAlert = async (text: string, user: User) => {
const res = await api(user.host, 'notifications/create', {
header: 'みす廃あらーと',
icon: 'https://i.imgur.com/B991yTl.png',
body: text,
}, user.token);
if (res.error) {
throw res.error || res;
}
};

View file

@ -1,34 +0,0 @@
import { User } from '../models/entities/user';
import { format } from '../functions/format';
import { getScores } from '../functions/get-scores';
import { api } from './misskey';
export const send = async (user: User): Promise<void> => {
const text = format(await getScores(user), user);
if (user.alertMode === 'note') {
console.info(`send ${user.username}@${user.host}'s misshaialert as a note`);
const opts = {
text,
visibility: user.visibility,
} as Record<string, unknown>;
if (user.localOnly) opts.localOnly = user.localOnly;
if (user.remoteFollowersOnly) opts.remoteFollowersOnly = user.remoteFollowersOnly;
const res = await api<Record<string, unknown>>(user.host, 'notes/create', opts, user.token);
if (res.error) {
throw res.error || res;
}
} else if (user.alertMode === 'notification') {
console.info(`send ${user.username}@${user.host}'s misshaialert as a notification`);
const res = await api(user.host, 'notifications/create', {
header: 'みす廃あらーと',
icon: 'https://i.imgur.com/B991yTl.png',
body: text,
}, user.token);
if (res.error) {
throw res.error || res;
}
} else {
console.info(`will not send ${user.username}@${user.host}'s misshaialert`);
}
};

View file

@ -7,46 +7,58 @@ import { MiUser, updateScore } from '../functions/update-score';
import { updateRating } from '../functions/update-rating'; import { updateRating } from '../functions/update-rating';
import { AlertMode } from '../../common/types/alert-mode'; import { AlertMode } from '../../common/types/alert-mode';
import { Users } from '../models'; import { Users } from '../models';
import { send } from './send'; import { sendAlert } from './send-alert';
import { api } from './misskey'; import { api } from './misskey';
import * as Store from '../store'; import * as Store from '../store';
import { User } from '../models/entities/user';
export default (): void => { export default (): void => {
cron.schedule('0 0 0 * * *', async () => { cron.schedule('0 0 0 * * *', async () => {
Store.dispatch({ Store.dispatch({ nowCalculating: true });
nowCalculating: true,
}); const users = await Users.find({ alertMode: Not<AlertMode>('nothing') });
const users = await Users.find({
alertMode: Not<AlertMode>('nothing'),
});
for (const user of users) { for (const user of users) {
try { await update(user).catch(e => handleError(user, e));
const miUser = await api<MiUser & { error: unknown }>(user.host, 'users/show', { username: user.username }, user.token);
if (miUser.error) throw miUser.error;
await updateRating(user, miUser);
await send(user);
await updateScore(user, miUser); if (user.alertMode === 'note') {
return delay(3000);
if (user.alertMode === 'note')
await delay(3000);
} catch (e: any) {
if (e.code) {
if (e.code === 'NO_SUCH_USER' || e.code === 'AUTHENTICATION_FAILED') {
// ユーザーが削除されている場合、レコードからも消してとりやめ
console.info(`${user.username}@${user.host} is deleted, so delete this user from the system`);
await deleteUser(user.username, user.host);
} else {
console.error(`Misskey Error: ${JSON.stringify(e)}`);
}
} else {
// おそらく通信エラー
console.error(`Unknown error: ${e.name} ${e.message}`);
}
} }
} }
Store.dispatch({
nowCalculating: false, Store.dispatch({ nowCalculating: false });
});
}); });
}; };
/**
*
* @param user
*/
const update = async (user: User) => {
const miUser = await api<MiUser & { error: unknown }>(user.host, 'users/show', { username: user.username }, user.token);
if (miUser.error) throw miUser.error;
await updateRating(user, miUser);
await sendAlert(user);
await updateScore(user, miUser);
};
/**
*
* @param user
* @param e ErrorだったりObjectだったりするのでanyだけど
*/
const handleError = async (user: User, e: any) => {
if (e.code) {
if (e.code === 'NO_SUCH_USER' || e.code === 'AUTHENTICATION_FAILED') {
// ユーザーが削除されている場合、レコードからも消してとりやめ
console.info(`${user.username}@${user.host} is deleted, so delete this user from the system`);
await deleteUser(user.username, user.host);
} else {
console.error(`Misskey Error: ${JSON.stringify(e)}`);
}
} else {
// おそらく通信エラー
console.error(`Unknown error: ${e.name} ${e.message}`);
}
};

View file

@ -2,21 +2,35 @@
// getStateを介してステートを取得し、dispatchによって更新する // getStateを介してステートを取得し、dispatchによって更新する
// stateを直接編集できないようになっている // stateを直接編集できないようになっている
/**
*
*/
const defaultState: State = { const defaultState: State = {
nowCalculating: false, nowCalculating: false,
}; };
let _state: Readonly<State> = defaultState; let _state: Readonly<State> = defaultState;
/**
*
*/
export type State = { export type State = {
nowCalculating: boolean, nowCalculating: boolean,
}; };
export const getState = () => Object.freeze({..._state}); /**
*
* @returns
*/
export const getState = () => Object.freeze({ ..._state });
/**
*
* @param mutation
*/
export const dispatch = (mutation: Partial<State>) => { export const dispatch = (mutation: Partial<State>) => {
_state = { _state = {
..._state, ..._state,
...mutation, ...mutation,
}; };
}; };

View file

@ -4,4 +4,4 @@ export const alertModes = [
'nothing' 'nothing'
] as const; ] as const;
export type AlertMode = typeof alertModes[number]; export type AlertMode = typeof alertModes[number];

View file

@ -4,10 +4,10 @@ import { BrowserRouter, Link, Route, Switch, useLocation } from 'react-router-do
import { IndexPage } from './pages'; import { IndexPage } from './pages';
import { RankingPage } from './pages/ranking'; import { RankingPage } from './pages/ranking';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { TermPage } from './pages/term';
import 'xeltica-ui/dist/css/xeltica-ui.min.css'; import 'xeltica-ui/dist/css/xeltica-ui.min.css';
import './style.scss'; import './style.scss';
import { TermPage } from './pages/term';
const AppInner : React.VFC = () => { const AppInner : React.VFC = () => {
const $location = useLocation(); const $location = useLocation();

View file

@ -0,0 +1,30 @@
import React, { useMemo } from 'react';
export type TabItem = {
label: string;
};
export type TabProps = {
items: TabItem[];
selected: number;
onSelect: (index: number) => void;
};
// タブコンポーネント
export const Tab: React.FC<TabProps> = (props) => {
return (
<div className="tab">
{props.items.map((item, index) => {
return (
<button
key={index}
className={'item ' + (index === props.selected ? 'selected' : '')}
onClick={() => props.onSelect(index)}
>
{item.label}
</button>
);
})}
</div>
);
};

View file

@ -7,3 +7,27 @@ body {
margin: auto; margin: auto;
max-width: 720px; max-width: 720px;
} }
.tab {
.item {
position: relative;
padding: var(--margin);
&.active {
color: var(--primary);
transition: width 0.2s ease;
&::after {
content: "";
width: 100%;
}
}
&::after {
content: "";
bottom: 0;
left: 0;
right: 0;
height: 2px;
width: 0;
background-color: var(--primary);
}
}
}

1379
yarn.lock

File diff suppressed because it is too large Load diff