0
0
Fork 0

ES Modulesに移行

This commit is contained in:
Xeltica 2023-02-25 17:13:07 +09:00
parent 0c3df4245d
commit 69212dd99a
105 changed files with 3154 additions and 3230 deletions

View file

@ -11,40 +11,40 @@ export const defaultTemplate = '昨日のMisskeyの活動は\n\nート: {note
export const currentTokenVersion = 2;
export const misskeyAppInfo = {
name: 'Misskey Tools',
description: 'A Professional Toolkit Designed for Misskey.',
permission: [
'read:account',
'write:account',
'read:blocks',
'write:blocks',
'read:drive',
'write:drive',
'read:favorites',
'write:favorites',
'read:following',
'write:following',
'read:messaging',
'write:messaging',
'read:mutes',
'write:mutes',
'write:notes',
'read:notifications',
'write:notifications',
'read:reactions',
'write:reactions',
'write:votes',
'read:pages',
'write:pages',
'write:page-likes',
'read:page-likes',
'read:user-groups',
'write:user-groups',
'read:channels',
'write:channels',
'read:gallery',
'write:gallery',
'read:gallery-likes',
'write:gallery-likes',
],
name: 'Misskey Tools',
description: 'A Professional Toolkit Designed for Misskey.',
permission: [
'read:account',
'write:account',
'read:blocks',
'write:blocks',
'read:drive',
'write:drive',
'read:favorites',
'write:favorites',
'read:following',
'write:following',
'read:messaging',
'write:messaging',
'read:mutes',
'write:mutes',
'write:notes',
'read:notifications',
'write:notifications',
'read:reactions',
'write:reactions',
'write:votes',
'read:pages',
'write:pages',
'write:page-likes',
'read:page-likes',
'read:user-groups',
'write:user-groups',
'read:channels',
'write:channels',
'read:gallery',
'write:gallery',
'read:gallery-likes',
'write:gallery-likes',
],
} as const;

View file

@ -4,38 +4,38 @@
*/
import { BadRequestError, CurrentUser, Get, JsonController, OnUndefined, Post } from 'routing-controllers';
import { IUser } from '../../common/types/user';
import { config } from '../../config';
import { work } from '../services/worker';
import * as Store from '../store';
import { IUser } from '../../common/types/user.js';
import { config } from '../../config.js';
import { work } from '../services/worker.js';
import * as Store from '../store.js';
@JsonController('/admin')
@JsonController('/admin')
export class AdminController {
@Get() getAdmin() {
const { username, host } = config.admin;
return {
username, host,
acct: `@${username}@${host}`,
};
}
@Get() getAdmin() {
const { username, host } = config.admin;
return {
username, host,
acct: `@${username}@${host}`,
};
}
@Get('/misshai/log') getMisshaiLog(@CurrentUser({ required: true }) user: IUser) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
@Get('/misshai/log') getMisshaiLog(@CurrentUser({ required: true }) user: IUser) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
return Store.getState().misshaiWorkerLog;
}
return Store.getState().misshaiWorkerLog;
}
@OnUndefined(204)
@Post('/misshai/start') startMisshai(@CurrentUser({ required: true }) user: IUser) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (Store.getState().nowCalculating) {
throw new BadRequestError('Already started');
}
@OnUndefined(204)
@Post('/misshai/start') startMisshai(@CurrentUser({ required: true }) user: IUser) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (Store.getState().nowCalculating) {
throw new BadRequestError('Already started');
}
work();
}
work();
}
}

View file

@ -4,101 +4,101 @@
*/
import { BadRequestError, Body, CurrentUser, Delete, Get, JsonController, NotFoundError, OnUndefined, Param, Post, Put } from 'routing-controllers';
import { IUser } from '../../common/types/user';
import { Announcements } from '../models';
import { AnnounceCreate } from './body/announce-create';
import { AnnounceUpdate } from './body/announce-update';
import { IdProp } from './body/id-prop';
import { IUser } from '../../common/types/user.js';
import { Announcements } from '../models/index.js';
import { AnnounceCreate } from './body/announce-create.js';
import { AnnounceUpdate } from './body/announce-update.js';
import { IdProp } from './body/id-prop.js';
@JsonController('/announcements')
export class AdminController {
@Get() get() {
const query = Announcements.createQueryBuilder('announcement')
.orderBy('"announcement"."createdAt"', 'DESC');
@JsonController('/announcements')
export class AnnouncementController {
@Get() get() {
const query = Announcements.createQueryBuilder('announcement')
.orderBy('"announcement"."createdAt"', 'DESC');
return query.getMany();
}
return query.getMany();
}
@OnUndefined(204)
@Post() async post(@CurrentUser({ required: true }) user: IUser, @Body({required: true}) {title, body}: AnnounceCreate) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (!title || !body) {
throw new BadRequestError();
}
await Announcements.insert({
createdAt: new Date(),
title,
body,
});
}
@OnUndefined(204)
@Post() async post(@CurrentUser({ required: true }) user: IUser, @Body({required: true}) {title, body}: AnnounceCreate) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (!title || !body) {
throw new BadRequestError();
}
await Announcements.insert({
createdAt: new Date(),
title,
body,
});
}
@OnUndefined(204)
@Put() async update(@CurrentUser({ required: true }) user: IUser, @Body() {id, title, body}: AnnounceUpdate) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (!id || !title || !body) {
throw new BadRequestError();
}
if (!(await Announcements.findOne(id))) {
throw new NotFoundError();
}
@OnUndefined(204)
@Put() async update(@CurrentUser({ required: true }) user: IUser, @Body() {id, title, body}: AnnounceUpdate) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (!id || !title || !body) {
throw new BadRequestError();
}
if (!(await Announcements.findOne(id))) {
throw new NotFoundError();
}
await Announcements.update(id, {
title,
body,
});
}
await Announcements.update(id, {
title,
body,
});
}
@OnUndefined(204)
@Post('/like/:id') async like(@CurrentUser({ required: true }) user: IUser, @Param('id') id: string) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
const idNumber = Number(id);
if (isNaN(idNumber)) {
throw new NotFoundError();
}
if (!id) {
throw new BadRequestError();
}
@OnUndefined(204)
@Post('/like/:id') async like(@CurrentUser({ required: true }) user: IUser, @Param('id') id: string) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
const idNumber = Number(id);
if (isNaN(idNumber)) {
throw new NotFoundError();
}
if (!id) {
throw new BadRequestError();
}
const announcement = await Announcements.findOne(Number(idNumber));
const announcement = await Announcements.findOne(Number(idNumber));
if (!announcement) {
throw new NotFoundError();
}
if (!announcement) {
throw new NotFoundError();
}
await Announcements.update(id, {
like: announcement.like + 1,
});
await Announcements.update(id, {
like: announcement.like + 1,
});
return announcement.like + 1;
}
return announcement.like + 1;
}
@Delete() async delete(@CurrentUser({ required: true }) user: IUser, @Body() {id}: IdProp) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
@Delete() async delete(@CurrentUser({ required: true }) user: IUser, @Body() {id}: IdProp) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (!id) {
throw new BadRequestError();
}
if (!id) {
throw new BadRequestError();
}
await Announcements.delete(id);
}
await Announcements.delete(id);
}
@Get('/:id') async getDetail(@Param('id') id: string) {
const idNumber = Number(id);
if (isNaN(idNumber)) {
throw new NotFoundError();
}
const announcement = await Announcements.findOne(idNumber);
if (!announcement) {
throw new NotFoundError();
}
return announcement;
}
@Get('/:id') async getDetail(@Param('id') id: string) {
const idNumber = Number(id);
if (isNaN(idNumber)) {
throw new NotFoundError();
}
const announcement = await Announcements.findOne(idNumber);
if (!announcement) {
throw new NotFoundError();
}
return announcement;
}
}

View file

@ -1,4 +1,4 @@
export class AnnounceCreate {
title: string;
body: string;
title: string;
body: string;
}

View file

@ -1,5 +1,5 @@
export class AnnounceUpdate {
id: number;
title: string;
body: string;
id: number;
title: string;
body: string;
}

View file

@ -1,3 +1,3 @@
export class IdProp {
id: number;
id: number;
}

View file

@ -1,25 +1,25 @@
import { IsIn, IsOptional } from 'class-validator';
import { AlertMode, alertModes } from '../../../common/types/alert-mode';
import { visibilities, Visibility } from '../../../common/types/visibility';
import { AlertMode, alertModes } from '../../../common/types/alert-mode.js';
import { visibilities, Visibility } from '../../../common/types/visibility.js';
export class UserSetting {
@IsIn(alertModes)
@IsOptional()
alertMode?: AlertMode;
@IsIn(alertModes)
@IsOptional()
alertMode?: AlertMode;
@IsIn(visibilities)
@IsOptional()
visibility?: Visibility;
@IsIn(visibilities)
@IsOptional()
visibility?: Visibility;
@IsOptional()
localOnly?: boolean;
@IsOptional()
localOnly?: boolean;
@IsOptional()
remoteFollowersOnly?: boolean;
@IsOptional()
remoteFollowersOnly?: boolean;
@IsOptional()
template?: string;
@IsOptional()
template?: string;
@IsOptional()
useRanking?: boolean;
@IsOptional()
useRanking?: boolean;
}

View file

@ -0,0 +1,13 @@
import {MetaController} from './meta.js';
import {AdminController} from './admin.js';
import {AnnouncementController} from './announcement.js';
import {RankingController} from './ranking.js';
import {SessionController} from './session.js';
export default [
MetaController,
AdminController,
AnnouncementController,
RankingController,
SessionController,
];

View file

@ -3,19 +3,17 @@
* @author Xeltica
*/
import { readFile } from 'fs';
import { Get, JsonController } from 'routing-controllers';
import { promisify } from 'util';
import { Meta } from '../../common/types/meta';
import { currentTokenVersion } from '../const';
import { Meta } from '../../common/types/meta.js';
import { currentTokenVersion } from '../const.js';
import { meta } from '../../config.js';
@JsonController('/meta')
export class MetaController {
@Get() async get(): Promise<Meta> {
const {version} = JSON.parse(await promisify(readFile)(__dirname + '/../../meta.json', { encoding: 'utf-8'}));
return {
version,
currentTokenVersion,
};
}
@Get() async get(): Promise<Meta> {
return {
version: meta.version,
currentTokenVersion,
};
}
}

View file

@ -4,34 +4,34 @@
*/
import { Get, JsonController, QueryParam } from 'routing-controllers';
import { getRanking } from '../functions/ranking';
import { getUserCount } from '../functions/users';
import { getState } from '../store';
import { getRanking } from '../functions/ranking.js';
import { getUserCount } from '../functions/users.js';
import { getState } from '../store.js';
@JsonController('/ranking')
export class RankingController {
@Get()
async get(@QueryParam('limit', { required: false }) limit?: string) {
return this.getResponse(getState().nowCalculating, limit ? Number(limit) : undefined);
}
@Get()
async get(@QueryParam('limit', { required: false }) limit?: string) {
return this.getResponse(getState().nowCalculating, limit ? Number(limit) : undefined);
}
/**
* DBに問い合わせてランキングを取得する
* @param isCalculating
* @param limit
* @returns
*/
private async getResponse(isCalculating: boolean, limit?: number) {
const ranking = isCalculating ? [] : (await getRanking(limit)).map((u) => ({
id: u.id,
username: u.username,
host: u.host,
rating: u.rating,
}));
return {
isCalculating,
userCount: await getUserCount(),
ranking,
};
}
/**
* DBに問い合わせてランキングを取得する
* @param isCalculating
* @param limit
* @returns
*/
private async getResponse(isCalculating: boolean, limit?: number) {
const ranking = isCalculating ? [] : (await getRanking(limit)).map((u) => ({
id: u.id,
username: u.username,
host: u.host,
rating: u.rating,
}));
return {
isCalculating,
userCount: await getUserCount(),
ranking,
};
}
}

View file

@ -5,45 +5,45 @@
import { Body, CurrentUser, Delete, Get, JsonController, OnUndefined, Post, Put } from 'routing-controllers';
import { DeepPartial } from 'typeorm';
import { getScores } from '../functions/get-scores';
import { deleteUser, updateUser } from '../functions/users';
import { User } from '../models/entities/user';
import { sendAlert } from '../services/send-alert';
import { UserSetting } from './body/user-setting';
import { getScores } from '../functions/get-scores.js';
import { deleteUser, updateUser } from '../functions/users.js';
import { User } from '../models/entities/user.js';
import { sendAlert } from '../services/send-alert.js';
import { UserSetting } from './body/user-setting.js';
@JsonController('/session')
export class SessionController {
@Get() get(@CurrentUser({ required: true }) user: User) {
return user;
}
@Get() get(@CurrentUser({ required: true }) user: User) {
return user;
}
@Get('/score')
async getScore(@CurrentUser({ required: true }) user: User) {
return getScores(user);
}
@Get('/score')
async getScore(@CurrentUser({ required: true }) user: User) {
return getScores(user);
}
@OnUndefined(204)
@Put() async updateSetting(@CurrentUser({ required: true }) user: User, @Body() setting: UserSetting) {
const s: DeepPartial<User> = {};
if (setting.alertMode != null) s.alertMode = setting.alertMode;
if (setting.visibility != null) s.visibility = setting.visibility;
if (setting.localOnly != null) s.localOnly = setting.localOnly;
if (setting.remoteFollowersOnly != null) s.remoteFollowersOnly = setting.remoteFollowersOnly;
if (setting.template !== undefined) s.template = setting.template;
if (setting.useRanking !== undefined) s.useRanking = setting.useRanking;
if (Object.keys(s).length === 0) return;
await updateUser(user.username, user.host, s);
}
@OnUndefined(204)
@Put() async updateSetting(@CurrentUser({ required: true }) user: User, @Body() setting: UserSetting) {
const s: DeepPartial<User> = {};
if (setting.alertMode != null) s.alertMode = setting.alertMode;
if (setting.visibility != null) s.visibility = setting.visibility;
if (setting.localOnly != null) s.localOnly = setting.localOnly;
if (setting.remoteFollowersOnly != null) s.remoteFollowersOnly = setting.remoteFollowersOnly;
if (setting.template !== undefined) s.template = setting.template;
if (setting.useRanking !== undefined) s.useRanking = setting.useRanking;
if (Object.keys(s).length === 0) return;
await updateUser(user.username, user.host, s);
}
@OnUndefined(204)
@Post('/alert') async testAlert(@CurrentUser({ required: true }) user: User) {
await sendAlert(user);
}
@OnUndefined(204)
@Post('/alert') async testAlert(@CurrentUser({ required: true }) user: User) {
await sendAlert(user);
}
@OnUndefined(204)
@Delete() async delete(@CurrentUser({ required: true }) user: User) {
await deleteUser(user.username, user.host);
}
@OnUndefined(204)
@Delete() async delete(@CurrentUser({ required: true }) user: User) {
await deleteUser(user.username, user.host);
}
}

View file

@ -1,7 +1,7 @@
import { Context } from 'koa';
import { ErrorCode } from '../common/types/error-code';
import { ErrorCode } from '../common/types/error-code.js';
export const die = (ctx: Context, error: ErrorCode = 'other', status = 400): Promise<void> => {
ctx.status = status;
return ctx.render('frontend', { error });
ctx.status = status;
return ctx.render('frontend', { error });
};

View file

@ -1,8 +1,8 @@
import {MisskeyError} from '../services/misskey';
import {MisskeyError} from '../services/misskey.js';
export const errorToString = (e: Error) => {
if (e instanceof MisskeyError) {
return JSON.stringify(e.error);
}
return `${e.name}: ${e.message}\n${e.stack}`;
if (e instanceof MisskeyError) {
return JSON.stringify(e.error, null, ' ');
}
return `${e.name}: ${e.message}\n${e.stack}`;
};

View file

@ -1,16 +1,16 @@
import rndstr from 'rndstr';
import { UsedToken } from '../models/entities/used-token';
import { UsedTokens } from '../models';
import { UsedToken } from '../models/entities/used-token.js';
import { UsedTokens } from '../models/index.js';
/**
*
*/
export const genToken = async (): Promise<string> => {
let used: UsedToken | undefined = undefined;
let token: string;
do {
token = rndstr(32);
used = await UsedTokens.findOne({ token });
} while (used !== undefined);
return token;
let used: UsedToken | undefined = undefined;
let token: string;
do {
token = rndstr(32);
used = await UsedTokens.findOne({ token });
} while (used !== undefined);
return token;
};

View file

@ -1,9 +1,9 @@
import { User } from '../models/entities/user';
import { toSignedString } from '../../common/functions/to-signed-string';
import {Count} from '../models/count';
import {api} from '../services/misskey';
import {Score} from '../../common/types/score';
import {MiUser} from './update-score';
import { User } from '../models/entities/user.js';
import { toSignedString } from '../../common/functions/to-signed-string.js';
import {Count} from '../models/count.js';
import {api} from '../services/misskey.js';
import {Score} from '../../common/types/score.js';
import {MiUser} from './update-score.js';
/**
*
@ -11,15 +11,15 @@ import {MiUser} from './update-score';
* @returns
*/
export const getScores = async (user: User): Promise<Score> => {
// TODO 毎回取ってくるのも微妙なので、ある程度キャッシュしたいかも
const miUser = await api<MiUser>(user.host, 'users/show', { username: user.username }, user.token);
// TODO 毎回取ってくるのも微妙なので、ある程度キャッシュしたいかも
const miUser = await api<MiUser>(user.host, 'users/show', { username: user.username }, user.token);
return {
notesCount: miUser.notesCount,
followingCount: miUser.followingCount,
followersCount: miUser.followersCount,
...getDelta(user, miUser),
};
return {
notesCount: miUser.notesCount,
followingCount: miUser.followingCount,
followersCount: miUser.followersCount,
...getDelta(user, miUser),
};
};
/**
@ -29,9 +29,9 @@ export const getScores = async (user: User): Promise<Score> => {
* @returns
*/
export const getDelta = (user: User, count: Count) => {
return {
notesDelta: toSignedString(count.notesCount - user.prevNotesCount),
followingDelta: toSignedString(count.followingCount - user.prevFollowingCount),
followersDelta: toSignedString(count.followersCount - user.prevFollowersCount),
};
return {
notesDelta: toSignedString(count.notesCount - user.prevNotesCount),
followingDelta: toSignedString(count.followingCount - user.prevFollowingCount),
followersDelta: toSignedString(count.followersCount - user.prevFollowersCount),
};
};

View file

@ -1,5 +1,5 @@
import { Users } from '../models';
import { User } from '../models/entities/user';
import { Users } from '../models/index.js';
import { User } from '../models/entities/user.js';
/**
*
@ -7,15 +7,15 @@ import { User } from '../models/entities/user';
* @returns
*/
export const getRanking = async (limit?: number | null): Promise<User[]> => {
const query = Users.createQueryBuilder('user')
.where('"user"."useRanking" IS TRUE')
.andWhere('"user"."bannedFromRanking" IS NOT TRUE')
.andWhere('"user"."rating" <> \'NaN\'')
.orderBy('"user".rating', 'DESC');
const query = Users.createQueryBuilder('user')
.where('"user"."useRanking" IS TRUE')
.andWhere('"user"."bannedFromRanking" IS NOT TRUE')
.andWhere('"user"."rating" <> \'NaN\'')
.orderBy('"user".rating', 'DESC');
if (limit) {
query.limit(limit);
}
if (limit) {
query.limit(limit);
}
return await query.getMany();
return await query.getMany();
};

View file

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

View file

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

View file

@ -1,22 +1,22 @@
import { User } from '../models/entities/user';
import { Users } from '../models';
import { User } from '../models/entities/user.js';
import { Users } from '../models/index.js';
import { DeepPartial } from 'typeorm';
import { genToken } from './gen-token';
import { IUser } from '../../common/types/user';
import { config } from '../../config';
import { currentTokenVersion } from '../const';
import { genToken } from './gen-token.js';
import { IUser } from '../../common/types/user.js';
import { config } from '../../config.js';
import { currentTokenVersion } from '../const.js';
/**
* IUser
*/
const packUser = (user: User | undefined): IUser | undefined => {
if (!user) return undefined;
const { username: adminName, host: adminHost } = config.admin;
if (!user) return undefined;
const { username: adminName, host: adminHost } = config.admin;
return {
...user,
isAdmin: adminName === user.username && adminHost === user.host,
};
return {
...user,
isAdmin: adminName === user.username && adminHost === user.host,
};
};
/**
@ -26,7 +26,7 @@ const packUser = (user: User | undefined): IUser | undefined => {
* @returns
*/
export const getUser = (username: string, host: string): Promise<IUser | undefined> => {
return Users.findOne({ username, host }).then(packUser);
return Users.findOne({ username, host }).then(packUser);
};
/**
@ -35,13 +35,13 @@ export const getUser = (username: string, host: string): Promise<IUser | undefin
* @returns
*/
export const updateUsersToolsToken = async (user: User | User['id']): Promise<string> => {
const id = typeof user === 'number'
? user
: user.id;
const id = typeof user === 'number'
? user
: user.id;
const misshaiToken = await genToken();
Users.update(id, { misshaiToken });
return misshaiToken;
const misshaiToken = await genToken();
Users.update(id, { misshaiToken });
return misshaiToken;
};
/**
@ -50,7 +50,7 @@ export const updateUsersToolsToken = async (user: User | User['id']): Promise<st
* @returns
*/
export const getUserByToolsToken = (token: string): Promise<IUser | undefined> => {
return Users.findOne({ misshaiToken: token }).then(packUser);
return Users.findOne({ misshaiToken: token }).then(packUser);
};
/**
@ -60,13 +60,13 @@ export const getUserByToolsToken = (token: string): Promise<IUser | undefined> =
* @param token
*/
export const upsertUser = async (username: string, host: string, token: string): Promise<void> => {
const u = await getUser(username, host);
if (u) {
await Users.update(u.id, { token, tokenVersion: currentTokenVersion });
} else {
const result = await Users.save({ username, host, token, tokenVersion: currentTokenVersion });
await updateUsersToolsToken(result.id);
}
const u = await getUser(username, host);
if (u) {
await Users.update(u.id, { token, tokenVersion: currentTokenVersion });
} else {
const result = await Users.save({ username, host, token, tokenVersion: currentTokenVersion });
await updateUsersToolsToken(result.id);
}
};
/**
@ -76,7 +76,7 @@ export const upsertUser = async (username: string, host: string, token: string):
* @param record
*/
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);
};
/**
@ -85,7 +85,7 @@ export const updateUser = async (username: string, host: string, record: DeepPar
* @param host
*/
export const deleteUser = async (username: string, host: string): Promise<void> => {
await Users.delete({ username, host });
await Users.delete({ username, host });
};
/**
@ -93,5 +93,5 @@ export const deleteUser = async (username: string, host: string): Promise<void>
* @returns
*/
export const getUserCount = (): Promise<number> => {
return Users.count();
return Users.count();
};

View file

@ -1,6 +1,6 @@
export interface Count
{
notesCount: number;
followingCount: number;
followersCount: number;
notesCount: number;
followingCount: number;
followersCount: number;
}

View file

@ -1,31 +1,31 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { IAnnouncement } from '../../../common/types/announcement';
import {Entity, PrimaryGeneratedColumn, Column} from 'typeorm';
import {IAnnouncement} from '../../../common/types/announcement.js';
@Entity()
export class Announcement implements IAnnouncement {
@PrimaryGeneratedColumn()
public id: number;
@PrimaryGeneratedColumn()
public id: number;
@Column({
type: 'timestamp without time zone',
})
public createdAt: Date;
@Column({
type: 'timestamp without time zone',
})
public createdAt: Date;
@Column({
type: 'varchar',
length: 128,
})
public title: string;
@Column({
type: 'varchar',
length: 128,
})
public title: string;
@Column({
type: 'varchar',
length: 8192,
})
public body: string;
@Column({
type: 'varchar',
length: 8192,
})
public body: string;
@Column({
type: 'integer',
default: 0,
})
public like: number;
@Column({
type: 'integer',
default: 0,
})
public like: number;
}

View file

@ -3,8 +3,8 @@ import { Entity, Index, PrimaryColumn } from 'typeorm';
@Entity()
@Index([ 'token' ], { unique: true })
export class UsedToken {
@PrimaryColumn({
type: 'varchar'
})
public token: string;
}
@PrimaryColumn({
type: 'varchar'
})
public token: string;
}

View file

@ -1,114 +1,114 @@
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
import { AlertMode, alertModes } from '../../../common/types/alert-mode';
import { visibilities, Visibility } from '../../../common/types/visibility';
import { IUser } from '../../../common/types/user';
import { AlertMode, alertModes } from '../../../common/types/alert-mode.js';
import { visibilities, Visibility } from '../../../common/types/visibility.js';
import { IUser } from '../../../common/types/user.js';
@Entity()
@Index(['username', 'host'], { unique: true })
export class User implements IUser {
@PrimaryGeneratedColumn()
public id: number;
@PrimaryGeneratedColumn()
public id: number;
@Column({
type: 'varchar'
})
public username: string;
@Column({
type: 'varchar'
})
public username: string;
@Column({
type: 'varchar'
})
public host: string;
@Column({
type: 'varchar'
})
public host: string;
@Column({
type: 'varchar'
})
public token: string;
@Column({
type: 'varchar'
})
public token: string;
@Column({
type: 'varchar',
default: ''
})
public misshaiToken: string;
@Column({
type: 'varchar',
default: ''
})
public misshaiToken: string;
@Column({
type: 'integer',
default: 0,
})
public prevNotesCount: number;
@Column({
type: 'integer',
default: 0,
})
public prevNotesCount: number;
@Column({
type: 'integer',
default: 0,
})
public prevFollowingCount: number;
@Column({
type: 'integer',
default: 0,
})
public prevFollowingCount: number;
@Column({
type: 'integer',
default: 0,
})
public prevFollowersCount: number;
@Column({
type: 'integer',
default: 0,
})
public prevFollowersCount: number;
@Column({
type: 'enum',
enum: alertModes,
default: 'notification'
})
public alertMode: AlertMode;
@Column({
type: 'enum',
enum: alertModes,
default: 'notification'
})
public alertMode: AlertMode;
@Column({
type: 'enum',
enum: visibilities,
default: 'home',
})
public visibility: Visibility;
@Column({
type: 'enum',
enum: visibilities,
default: 'home',
})
public visibility: Visibility;
@Column({
type: 'boolean',
default: false,
})
public localOnly: boolean;
@Column({
type: 'boolean',
default: false,
})
public localOnly: boolean;
@Column({
type: 'boolean',
default: false,
})
public remoteFollowersOnly: boolean;
@Column({
type: 'boolean',
default: false,
})
public remoteFollowersOnly: boolean;
@Column({
type: 'varchar',
length: 1024,
nullable: true,
})
public template: string | null;
@Column({
type: 'varchar',
length: 1024,
nullable: true,
})
public template: string | null;
@Column({
type: 'real',
default: 0,
})
public prevRating: number;
@Column({
type: 'real',
default: 0,
})
public prevRating: number;
@Column({
type: 'real',
default: 0,
})
public rating: number;
@Column({
type: 'real',
default: 0,
})
public rating: number;
@Column({
type: 'boolean',
default: false,
})
public useRanking: boolean;
@Column({
type: 'boolean',
default: false,
})
public useRanking: boolean;
@Column({
type: 'boolean',
default: false,
})
public bannedFromRanking: boolean;
@Column({
type: 'boolean',
default: false,
})
public bannedFromRanking: boolean;
@Column({
type: 'integer',
default: 1,
comment: 'Misskey API トークンのバージョン。現行と違う場合はアップデートを要求する',
})
public tokenVersion: number;
@Column({
type: 'integer',
default: 1,
comment: 'Misskey API トークンのバージョン。現行と違う場合はアップデートを要求する',
})
public tokenVersion: number;
}

View file

@ -1,7 +1,7 @@
import { User } from './entities/user';
import { UsedToken } from './entities/used-token';
import { User } from './entities/user.js';
import { UsedToken } from './entities/used-token.js';
import { getRepository } from 'typeorm';
import { Announcement } from './entities/announcement';
import { Announcement } from './entities/announcement.js';
export const Users = getRepository(User);
export const UsedTokens = getRepository(UsedToken);

View file

@ -1,7 +1,12 @@
import views from 'koa-views';
import { version } from '../meta.json';
import path from 'path';
import url from 'url';
import { meta } from '../config.js';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
export const render = views(__dirname + '/views', {
extension: 'pug',
options: { version },
extension: 'pug',
options: { version: meta.version },
});

View file

@ -8,12 +8,16 @@ import ms from 'ms';
import striptags from 'striptags';
import MarkdownIt from 'markdown-it';
import { config } from '../config';
import { upsertUser, getUser, updateUser } from './functions/users';
import { api } from './services/misskey';
import { die } from './die';
import { misskeyAppInfo } from './const';
import { Announcements } from './models';
import { config } from '../config.js';
import { upsertUser, getUser, updateUser } from './functions/users.js';
import { api } from './services/misskey.js';
import { die } from './die.js';
import { misskeyAppInfo } from './const.js';
import { Announcements } from './models/index.js';
import path from 'path';
import url from 'url';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
export const router = new Router<DefaultState, Context>();
@ -23,175 +27,175 @@ const tokenSecretCache: Record<string, string> = {};
const md = new MarkdownIt();
router.get('/login', async ctx => {
let host = ctx.query.host as string | undefined;
if (!host) {
await die(ctx, 'invalidParamater');
return;
}
let host = ctx.query.host as string | undefined;
if (!host) {
await die(ctx, 'invalidParamater');
return;
}
// http://, https://を潰す
host = host.trim().replace(/^https?:\/\//g, '').replace(/\/+/g, '');
// http://, https://を潰す
host = host.trim().replace(/^https?:\/\//g, '').replace(/\/+/g, '');
const meta = await api<{ name: string, uri: string, version: string, features: Record<string, boolean | undefined> }>(host, 'meta', {}).catch(async e => {
if (!(e instanceof Error && e.name === 'Error')) throw e;
await die(ctx, 'hostNotFound');
});
const meta = await api<{ name: string, uri: string, version: string, features: Record<string, boolean | undefined> }>(host, 'meta', {}).catch(async e => {
if (!(e instanceof Error && e.name === 'Error')) throw e;
await die(ctx, 'hostNotFound');
});
// NOTE: catchが呼ばれた場合はvoidとなるためundefinedのはず
if (typeof meta === 'undefined') return;
// NOTE: catchが呼ばれた場合はvoidとなるためundefinedのはず
if (typeof meta === 'undefined') return;
if (typeof meta !== 'object') {
await die(ctx, 'other');
return;
}
if (typeof meta !== 'object') {
await die(ctx, 'other');
return;
}
if (meta.version.includes('hitori')) {
await die(ctx, 'hitorisskeyIsDenied');
return;
}
if (meta.version.includes('hitori')) {
await die(ctx, 'hitorisskeyIsDenied');
return;
}
// NOTE:
// 環境によってはアクセスしたドメインとMisskeyにおけるhostが異なるケースがある
// そういったインスタンスにおいてアカウントの不整合が生じるため、
// APIから戻ってきたホスト名を正しいものとして、改めて正規化する
host = meta.uri.replace(/^https?:\/\//g, '').replace(/\/+/g, '').trim();
// NOTE:
// 環境によってはアクセスしたドメインとMisskeyにおけるhostが異なるケースがある
// そういったインスタンスにおいてアカウントの不整合が生じるため、
// APIから戻ってきたホスト名を正しいものとして、改めて正規化する
host = meta.uri.replace(/^https?:\/\//g, '').replace(/\/+/g, '').trim();
const { name, permission, description } = misskeyAppInfo;
const { name, permission, description } = misskeyAppInfo;
if (meta.features.miauth) {
// MiAuthを使用する
const callback = encodeURI(`${config.url}/miauth`);
if (meta.features.miauth) {
// MiAuthを使用する
const callback = encodeURI(`${config.url}/miauth`);
const session = uuid();
const url = `https://${host}/miauth/${session}?name=${encodeURI(name)}&callback=${encodeURI(callback)}&permission=${encodeURI(permission.join(','))}`;
sessionHostCache[session] = host;
const session = uuid();
const url = `https://${host}/miauth/${session}?name=${encodeURI(name)}&callback=${encodeURI(callback)}&permission=${encodeURI(permission.join(','))}`;
sessionHostCache[session] = host;
ctx.redirect(url);
} else {
// 旧型認証を使用する
const callbackUrl = encodeURI(`${config.url}/legacy-auth`);
ctx.redirect(url);
} else {
// 旧型認証を使用する
const callbackUrl = encodeURI(`${config.url}/legacy-auth`);
const { secret } = await api<{ secret: string }>(host, 'app/create', {
name, description, permission, callbackUrl,
});
const { secret } = await api<{ secret: string }>(host, 'app/create', {
name, description, permission, callbackUrl,
});
const { token, url } = await api<{ token: string, url: string }>(host, 'auth/session/generate', {
appSecret: secret
});
const { token, url } = await api<{ token: string, url: string }>(host, 'auth/session/generate', {
appSecret: secret
});
sessionHostCache[token] = host;
tokenSecretCache[token] = secret;
sessionHostCache[token] = host;
tokenSecretCache[token] = secret;
ctx.redirect(url);
}
ctx.redirect(url);
}
});
router.get('/teapot', async ctx => {
await die(ctx, 'teapot', 418);
await die(ctx, 'teapot', 418);
});
router.get('/miauth', async ctx => {
const session = ctx.query.session as string | undefined;
if (!session) {
await die(ctx, 'sessionRequired');
return;
}
const host = sessionHostCache[session];
delete sessionHostCache[session];
if (!host) {
await die(ctx);
console.error('host is null or undefined');
return;
}
const session = ctx.query.session as string | undefined;
if (!session) {
await die(ctx, 'sessionRequired');
return;
}
const host = sessionHostCache[session];
delete sessionHostCache[session];
if (!host) {
await die(ctx);
console.error('host is null or undefined');
return;
}
const url = `https://${host}/api/miauth/${session}/check`;
const res = await axios.post(url, {});
const { token, user } = res.data;
const url = `https://${host}/api/miauth/${session}/check`;
const res = await axios.post(url, {});
const { token, user } = res.data;
if (!token || !user) {
await die(ctx);
if (!token) console.error('token is null or undefined');
if (!user) console.error('user is null or undefined');
return;
}
if (!token || !user) {
await die(ctx);
if (!token) console.error('token is null or undefined');
if (!user) console.error('user is null or undefined');
return;
}
await login(ctx, user, host, token);
await login(ctx, user, host, token);
});
router.get('/legacy-auth', async ctx => {
const token = ctx.query.token as string | undefined;
if (!token) {
await die(ctx, 'tokenRequired');
return;
}
const host = sessionHostCache[token];
delete sessionHostCache[token];
if (!host) {
await die(ctx);
return;
}
const appSecret = tokenSecretCache[token];
delete tokenSecretCache[token];
if (!appSecret) {
await die(ctx);
return;
}
const token = ctx.query.token as string | undefined;
if (!token) {
await die(ctx, 'tokenRequired');
return;
}
const host = sessionHostCache[token];
delete sessionHostCache[token];
if (!host) {
await die(ctx);
return;
}
const appSecret = tokenSecretCache[token];
delete tokenSecretCache[token];
if (!appSecret) {
await die(ctx);
return;
}
const { accessToken, user } = await api<{ accessToken: string, user: Record<string, unknown> }>(host, 'auth/session/userkey', {
appSecret, token,
});
const i = crypto.createHash('sha256').update(accessToken + appSecret, 'utf8').digest('hex');
const { accessToken, user } = await api<{ accessToken: string, user: Record<string, unknown> }>(host, 'auth/session/userkey', {
appSecret, token,
});
const i = crypto.createHash('sha256').update(accessToken + appSecret, 'utf8').digest('hex');
await login(ctx, user, host, i);
await login(ctx, user, host, i);
});
router.get('/assets/(.*)', async ctx => {
await koaSend(ctx as any, ctx.path.replace('/assets/', ''), {
root: `${__dirname}/../assets/`,
maxage: process.env.NODE_ENV !== 'production' ? 0 : ms('7 days'),
});
await koaSend(ctx as any, ctx.path.replace('/assets/', ''), {
root: `${__dirname}/../assets/`,
maxage: process.env.NODE_ENV !== 'production' ? 0 : ms('7 days'),
});
});
router.get('/api(.*)', async (ctx, next) => {
next();
next();
});
router.get('/announcements/:id', async (ctx) => {
const a = await Announcements.findOne(ctx.params.id);
const stripped = striptags(md.render(a?.body ?? '').replace(/\n/g, ' '));
await ctx.render('frontend', a ? {
t: a.title,
d: stripped.length > 80 ? stripped.substring(0, 80) + '…' : stripped,
} : null);
const a = await Announcements.findOne(ctx.params.id);
const stripped = striptags(md.render(a?.body ?? '').replace(/\n/g, ' '));
await ctx.render('frontend', a ? {
t: a.title,
d: stripped.length > 80 ? stripped.substring(0, 80) + '…' : stripped,
} : null);
});
router.get('/__rescue__', async(ctx) => {
await ctx.render('rescue');
await ctx.render('rescue');
});
router.get('(.*)', async (ctx) => {
await ctx.render('frontend');
await ctx.render('frontend');
});
async function login(ctx: Context, user: Record<string, unknown>, host: string, token: string) {
const isNewcomer = !(await getUser(user.username as string, host));
await upsertUser(user.username as string, host, token);
const isNewcomer = !(await getUser(user.username as string, host));
await upsertUser(user.username as string, host, token);
const u = await getUser(user.username as string, host);
const u = await getUser(user.username as string, host);
if (!u) {
await die(ctx);
return;
}
if (!u) {
await die(ctx);
return;
}
if (isNewcomer) {
await updateUser(u.username, u.host, {
prevNotesCount: user.notesCount as number ?? 0,
prevFollowingCount: user.followingCount as number ?? 0,
prevFollowersCount: user.followersCount as number ?? 0,
});
}
if (isNewcomer) {
await updateUser(u.username, u.host, {
prevNotesCount: user.notesCount as number ?? 0,
prevFollowingCount: user.followingCount as number ?? 0,
prevFollowersCount: user.followersCount as number ?? 0,
});
}
await ctx.render('frontend', { token: u.misshaiToken });
await ctx.render('frontend', { token: u.misshaiToken });
}

View file

@ -1,45 +1,44 @@
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import { Action, useKoaServer } from 'routing-controllers';
import {Action, useKoaServer} from 'routing-controllers';
import { config } from '../config';
import { render } from './render';
import { router } from './router';
import { getUserByToolsToken } from './functions/users';
import { version } from '../meta.json';
import {config, meta} from '../config.js';
import {render} from './render.js';
import {router} from './router.js';
import {getUserByToolsToken} from './functions/users.js';
import controllers from './controllers/index.js';
import 'reflect-metadata';
export default (): void => {
const app = new Koa();
const app = new Koa();
console.log('Misskey Tools v' + version);
console.log('Misskey Tools v' + meta.version);
console.log('Initializing DB connection...');
console.log('Initializing DB connection...');
app.use(render);
app.use(bodyParser());
app.use(render);
app.use(bodyParser());
useKoaServer(app, {
controllers: [__dirname + '/controllers/**/*{.ts,.js}'],
routePrefix: '/api/v1',
classTransformer: true,
validation: true,
currentUserChecker: async ({ request }: Action) => {
const { authorization } = request.header;
if (!authorization || !authorization.startsWith('Bearer ')) return null;
useKoaServer(app, {
controllers,
routePrefix: '/api/v1',
classTransformer: true,
validation: true,
currentUserChecker: async ({ request }: Action) => {
const { authorization } = request.header;
if (!authorization || !authorization.startsWith('Bearer ')) return null;
const token = authorization.split(' ')[1].trim();
const user = await getUserByToolsToken(token);
return user;
},
});
const token = authorization.split(' ')[1].trim();
return await getUserByToolsToken(token);
},
});
app.use(router.routes());
app.use(router.allowedMethods());
app.use(router.routes());
app.use(router.allowedMethods());
console.log(`listening port ${config.port}...`);
console.log('App launched!');
console.log(`listening port ${config.port}...`);
console.log('App launched!');
app.listen(config.port || 3000);
app.listen(config.port || 3000);
};

View file

@ -1,13 +1,13 @@
import { getConnection, createConnection, Connection } from 'typeorm';
import { config } from '../../config';
import { User } from '../models/entities/user';
import { UsedToken } from '../models/entities/used-token';
import { Announcement } from '../models/entities/announcement';
import { config } from '../../config.js';
import { User } from '../models/entities/user.js';
import { UsedToken } from '../models/entities/used-token.js';
import { Announcement } from '../models/entities/announcement.js';
export const entities = [
User,
UsedToken,
Announcement,
User,
UsedToken,
Announcement,
];
/**
@ -16,26 +16,26 @@ export const entities = [
* @returns DBコネクション
*/
export const initDb = async (force = false): Promise<Connection> => {
// forceがtrueでない限り、既に接続が存在する場合はそれを返す
if (!force) {
try {
const conn = getConnection();
return Promise.resolve(conn);
} catch (e) {
// noop
console.warn('connection is not found, so create');
}
}
// forceがtrueでない限り、既に接続が存在する場合はそれを返す
if (!force) {
try {
const conn = getConnection();
return Promise.resolve(conn);
} catch (e) {
// noop
console.warn('connection is not found, so create');
}
}
// 接続がないか、forceがtrueの場合は新規作成する
return createConnection({
type: 'postgres',
host: config.db.host,
port: config.db.port,
username: config.db.user,
password: config.db.pass,
database: config.db.db,
extra: config.db.extra,
entities,
});
// 接続がないか、forceがtrueの場合は新規作成する
return createConnection({
type: 'postgres',
host: config.db.host,
port: config.db.port,
username: config.db.user,
password: config.db.pass,
database: config.db.db,
extra: config.db.extra,
entities,
});
};

View file

@ -1,6 +1,6 @@
import axios from 'axios';
import {printLog} from '../store';
import {delay} from '../utils/delay';
import {printLog} from '../store.js';
import {delay} from '../utils/delay.js';
export const ua = `Mozilla/5.0 MisskeyTools +https://github.com/shrimpia/misskey-tools Node/${process.version}`;
@ -10,26 +10,26 @@ const RETRY_COUNT = 5;
* Misskey APIを呼び出す
*/
export const api = async <T extends Record<string, unknown> = Record<string, unknown>>(host: string, endpoint: string, arg: Record<string, unknown>, token?: string): Promise<T> => {
const a = { ...arg };
if (token) {
a.i = token;
}
const a = { ...arg };
if (token) {
a.i = token;
}
for (let i = 0; i < RETRY_COUNT; i++) {
let data: T;
try {
data = await axios.post<T>(`https://${host}/api/${endpoint}`, a).then(res => res.data);
} catch (e) {
printLog(`接続エラー: ${host}/api/${endpoint} リトライ中 (${i + 1} / ${RETRY_COUNT})\n${e}`, 'error');
await delay(3000);
continue;
}
if (!('error' in data)) {
return data;
}
throw new MisskeyError((data as any).error);
}
throw new TimedOutError();
for (let i = 0; i < RETRY_COUNT; i++) {
let data: T;
try {
data = await axios.post<T>(`https://${host}/api/${endpoint}`, a).then(res => res.data);
} catch (e) {
printLog(`接続エラー: ${host}/api/${endpoint} リトライ中 (${i + 1} / ${RETRY_COUNT})\n${e}`, 'error');
await delay(3000);
continue;
}
if (!(typeof data === 'object' && 'error' in data)) {
return data;
}
throw new MisskeyError((data as any).error);
}
throw new TimedOutError();
};
/**
@ -39,25 +39,25 @@ export const api = async <T extends Record<string, unknown> = Record<string, unk
* @returns truefalse
*/
export const apiAvailable = async (host: string, i: string): Promise<boolean> => {
try {
const res = await api(host, 'i', {}, i);
return !res.error;
} catch {
return false;
}
try {
const res = await api(host, 'i', {}, i);
return !res.error;
} catch {
return false;
}
};
export class TimedOutError extends Error {}
export class MisskeyError extends Error {
constructor(public error: MisskeyErrorObject) {
super();
}
constructor(public error: MisskeyErrorObject) {
super();
}
}
export interface MisskeyErrorObject {
message: string;
code: string;
id: string;
kind: string;
message: string;
code: string;
id: string;
kind: string;
}

View file

@ -1,7 +1,7 @@
import { User } from '../models/entities/user';
import { api } from './misskey';
import {format} from '../../common/functions/format';
import {getScores} from '../functions/get-scores';
import { User } from '../models/entities/user.js';
import { api } from './misskey.js';
import {format} from '../../common/functions/format.js';
import {getScores} from '../functions/get-scores.js';
/**
@ -9,21 +9,21 @@ import {getScores} from '../functions/get-scores';
* @param user
*/
export const sendAlert = async (user: User) => {
const text = format(user, await getScores(user));
switch (user.alertMode) {
case 'note':
await sendNoteAlert(text, user);
break;
case 'notification':
await sendNotificationAlert(text, user);
break;
case 'both':
await Promise.all([
sendNotificationAlert(text, user),
sendNoteAlert(text, user),
]);
break;
}
const text = format(user, await getScores(user));
switch (user.alertMode) {
case 'note':
await sendNoteAlert(text, user);
break;
case 'notification':
await sendNotificationAlert(text, user);
break;
case 'both':
await Promise.all([
sendNotificationAlert(text, user),
sendNoteAlert(text, user),
]);
break;
}
};
/**
@ -32,16 +32,16 @@ export const sendAlert = async (user: User) => {
* @param user
*/
export 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);
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;
}
if (res.error) {
throw res.error || res;
}
};
/**
@ -50,13 +50,13 @@ export const sendNoteAlert = async (text: string, user: User) => {
* @param user
*/
export const sendNotificationAlert = async (text: string, user: User) => {
const res = await api(user.host, 'notifications/create', {
header: 'Misskey Tools',
icon: 'https://i.imgur.com/B991yTl.png',
body: text,
}, user.token);
const res = await api(user.host, 'notifications/create', {
header: 'Misskey Tools',
icon: 'https://i.imgur.com/B991yTl.png',
body: text,
}, user.token);
if (res.error) {
throw res.error || res;
}
if (res.error) {
throw res.error || res;
}
};

View file

@ -1,18 +1,19 @@
import cron from 'node-cron';
import { deleteUser } from '../functions/users';
import { MiUser, updateScore } from '../functions/update-score';
import { updateRating } from '../functions/update-rating';
import { Users } from '../models';
import {sendNoteAlert, sendNotificationAlert} from './send-alert';
import {api, MisskeyError, TimedOutError} from './misskey';
import * as Store from '../store';
import { User } from '../models/entities/user';
import {groupBy} from '../utils/group-by';
import {clearLog, printLog} from '../store';
import {errorToString} from '../functions/error-to-string';
import {Acct, toAcct} from '../models/acct';
import {Count} from '../models/count';
import {format} from '../../common/functions/format';
import { deleteUser } from '../functions/users.js';
import { MiUser, updateScore } from '../functions/update-score.js';
import { updateRating } from '../functions/update-rating.js';
import { Users } from '../models/index.js';
import {sendNoteAlert, sendNotificationAlert} from './send-alert.js';
import {api, MisskeyError, TimedOutError} from './misskey.js';
import * as Store from '../store.js';
import { User } from '../models/entities/user.js';
import {groupBy} from '../utils/group-by.js';
import {clearLog, printLog} from '../store.js';
import {errorToString} from '../functions/error-to-string.js';
import {Acct, toAcct} from '../models/acct.js';
import {Count} from '../models/count.js';
import {format} from '../../common/functions/format.js';
import {delay} from '../utils/delay.js';
const ERROR_CODES_USER_REMOVED = ['NO_SUCH_USER', 'AUTHENTICATION_FAILED', 'YOUR_ACCOUNT_SUSPENDED'];
@ -20,106 +21,109 @@ const ERROR_CODES_USER_REMOVED = ['NO_SUCH_USER', 'AUTHENTICATION_FAILED', 'YOUR
const userScoreCache = new Map<Acct, Count>();
export default (): void => {
cron.schedule('0 0 0 * * *', work);
cron.schedule('0 0 0 * * *', work);
};
export const work = async () => {
Store.dispatch({ nowCalculating: true });
Store.dispatch({ nowCalculating: true });
clearLog();
printLog('Started.');
clearLog();
printLog('Started.');
try {
const users = await Users.find();
const groupedUsers = groupBy(users, u => u.host);
try {
const users = await Users.find();
const groupedUsers = groupBy(users, u => u.host);
printLog(`${users.length} アカウントのレート計算を開始します。`);
await calculateAllRating(groupedUsers);
Store.dispatch({ nowCalculating: false });
printLog(`${users.length} アカウントのレート計算を開始します。`);
await calculateAllRating(groupedUsers);
Store.dispatch({ nowCalculating: false });
printLog(`${users.length} アカウントのアラート送信を開始します。`);
await sendAllAlerts(groupedUsers);
printLog(`${users.length} アカウントのアラート送信を開始します。`);
await sendAllAlerts(groupedUsers);
printLog('ミス廃アラートワーカーは正常に完了しました。');
} catch (e) {
printLog('ミス廃アラートワーカーが異常終了しました。', 'error');
printLog(e instanceof Error ? errorToString(e) : e, 'error');
} finally {
Store.dispatch({ nowCalculating: false });
}
printLog('ミス廃アラートワーカーは正常に完了しました。');
} catch (e) {
printLog('ミス廃アラートワーカーが異常終了しました。', 'error');
printLog(e instanceof Error ? errorToString(e) : JSON.stringify(e, null, ' '), 'error');
} finally {
Store.dispatch({ nowCalculating: false });
}
};
const calculateAllRating = async (groupedUsers: [string, User[]][]) => {
return await Promise.all(groupedUsers.map(kv => calculateRating(...kv)));
return await Promise.all(groupedUsers.map(kv => calculateRating(...kv)));
};
const calculateRating = async (host: string, users: User[]) => {
for (const user of users) {
let miUser: MiUser;
try {
miUser = await api<MiUser>(user.host, 'i', {}, user.token);
} catch (e) {
if (!(e instanceof Error)) {
printLog('バグエラーオブジェクトはErrorを継承していないといけない', 'error');
} else if (e instanceof MisskeyError) {
if (ERROR_CODES_USER_REMOVED.includes(e.error.code)) {
// ユーザーが削除されている場合、レコードからも消してとりやめ
printLog(`アカウント ${toAcct(user)} は削除されているか、凍結されているか、トークンを失効しています。そのため、本システムからアカウントを削除します。`, 'warn');
await deleteUser(user.username, user.host);
} else {
printLog(`Misskey エラー: ${JSON.stringify(e.error)}`, 'error');
}
} else if (e instanceof TimedOutError) {
printLog(`サーバー ${user.host} との接続に失敗したため、このサーバーのレート計算を中断します。`, 'error');
return;
} else {
// おそらく通信エラー
printLog(`不明なエラーが発生しました。\n${errorToString(e)}`, 'error');
}
continue;
}
userScoreCache.set(toAcct(user), miUser);
for (const user of users) {
let miUser: MiUser;
try {
miUser = await api<MiUser>(user.host, 'i', {}, user.token);
} catch (e) {
if (!(e instanceof Error)) {
printLog('バグエラーオブジェクトはErrorを継承していないといけない', 'error');
} else if (e instanceof MisskeyError) {
if (ERROR_CODES_USER_REMOVED.includes(e.error.code)) {
// ユーザーが削除されている場合、レコードからも消してとりやめ
printLog(`アカウント ${toAcct(user)} は削除されているか、凍結されているか、トークンを失効しています。そのため、本システムからアカウントを削除します。`, 'warn');
await deleteUser(user.username, user.host);
} else {
printLog(`Misskey エラー: ${JSON.stringify(e.error)}`, 'error');
}
} else if (e instanceof TimedOutError) {
printLog(`サーバー ${user.host} との接続に失敗したため、このサーバーのレート計算を中断します。`, 'error');
return;
} else {
// おそらく通信エラー
printLog(`不明なエラーが発生しました。\n${errorToString(e)}`, 'error');
}
continue;
}
userScoreCache.set(toAcct(user), miUser);
await updateRating(user, miUser);
}
printLog(`${host} ユーザー(${users.length}人) のレート計算が完了しました。`);
await updateRating(user, miUser);
}
printLog(`${host} ユーザー(${users.length}人) のレート計算が完了しました。`);
};
const sendAllAlerts = async (groupedUsers: [string, User[]][]) => {
return await Promise.all(groupedUsers.map(kv => sendAlerts(...kv)));
return await Promise.all(groupedUsers.map(kv => sendAlerts(...kv)));
};
const sendAlerts = async (host: string, users: User[]) => {
const models = users
.map(user => {
const count = userScoreCache.get(toAcct(user));
if (count == null) return null;
return {
user,
count,
message: format(user, count),
};
})
.filter(u => u != null) as {user: User, count: Count, message: string}[];
const models = users
.map(user => {
const count = userScoreCache.get(toAcct(user));
if (count == null) return null;
return {
user,
count,
message: format(user, count),
};
})
.filter(u => u != null) as {user: User, count: Count, message: string}[];
// 何もしない
for (const {user, count} of models.filter(m => m.user.alertMode === 'nothing')) {
await updateScore(user, count);
}
// 何もしない
for (const {user, count} of models.filter(m => m.user.alertMode === 'nothing')) {
await updateScore(user, count);
}
// 通知
for (const {user, count, message} of models.filter(m => m.user.alertMode === 'notification' || m.user.alertMode === 'both')) {
await sendNotificationAlert(message, user);
if (user.alertMode === 'notification') {
await updateScore(user, count);
}
}
// 通知
for (const {user, count, message} of models.filter(m => m.user.alertMode === 'notification' || m.user.alertMode === 'both')) {
await sendNotificationAlert(message, user);
if (user.alertMode === 'notification') {
await updateScore(user, count);
}
}
// 通知
for (const {user, count, message} of models.filter(m => m.user.alertMode === 'note')) {
await sendNoteAlert(message, user);
await updateScore(user, count);
}
// アラート
for (const {user, count, message} of models.filter(m => m.user.alertMode === 'note' || m.user.alertMode === 'both')) {
await sendNoteAlert(message, user);
await Promise.all([
updateScore(user, count),
delay(1000),
]);
}
printLog(`${host} ユーザー(${users.length}人) へのアラート送信が完了しました。`);
printLog(`${host} ユーザー(${users.length}人) へのアラート送信が完了しました。`);
};

View file

@ -2,14 +2,14 @@
// getStateを介してステートを取得し、dispatchによって更新する
// stateを直接編集できないようになっている
import {Log} from '../common/types/log';
import {Log} from '../common/types/log.js';
/**
*
*/
const defaultState: State = {
nowCalculating: false,
misshaiWorkerLog: [],
nowCalculating: false,
misshaiWorkerLog: [],
};
let _state: Readonly<State> = defaultState;
@ -18,8 +18,8 @@ let _state: Readonly<State> = defaultState;
*
*/
export type State = {
nowCalculating: boolean,
misshaiWorkerLog: Log[],
nowCalculating: boolean,
misshaiWorkerLog: Log[],
};
/**
@ -33,19 +33,20 @@ export const getState = () => Object.freeze({ ..._state });
* @param mutation
*/
export const dispatch = (mutation: Partial<State>) => {
_state = {
..._state,
...mutation,
};
_state = {
..._state,
...mutation,
};
};
export const clearLog = () => {
dispatch({ misshaiWorkerLog: [] });
dispatch({ misshaiWorkerLog: [] });
};
export const printLog = (log: unknown, level: Log['level'] = 'info') => {
dispatch({ misshaiWorkerLog: [
...getState().misshaiWorkerLog,
{ text: String(log), level, timestamp: new Date() },
] });
dispatch({ misshaiWorkerLog: [
...getState().misshaiWorkerLog,
{ text: String(log), level, timestamp: new Date() },
] });
console[level](log);
};

View file

@ -1,13 +1,13 @@
type GetKeyFunction<K extends PropertyKey, V> = (cur: V, idx: number, src: readonly V[]) => K;
export const groupBy = <K extends PropertyKey, V>(array: readonly V[], getKey: GetKeyFunction<K, V>) => {
return Array.from(
array.reduce((map, cur, idx, src) => {
const key = getKey(cur, idx, src);
const list = map.get(key);
if (list) list.push(cur);
else map.set(key, [cur]);
return map;
}, new Map<K, V[]>())
);
return Array.from(
array.reduce((map, cur, idx, src) => {
const key = getKey(cur, idx, src);
const list = map.get(key);
if (list) list.push(cur);
else map.set(key, [cur]);
return map;
}, new Map<K, V[]>())
);
};