Skip to content

Commit

Permalink
feat: 新時報システムの追加 (#756)
Browse files Browse the repository at this point in the history
  • Loading branch information
MikuroXina authored Feb 27, 2023
1 parent 0e52c8f commit 46bea77
Show file tree
Hide file tree
Showing 10 changed files with 570 additions and 298 deletions.
15 changes: 15 additions & 0 deletions assets/time-signal.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
MORNING:
time:
hours: 7
minutes: 0
message: 'マルナナマルマル、朝だ。…司令官、まだ寝てるの?'
NOON:
time:
hours: 12
minutes: 0
message: 'Полдень. 今日は何を作ってるんだい?'
MIDNIGHT:
time:
hours: 2
minutes: 0
message: 'マルフタマルマル、もう遅いね。眠かったらどうぞ。私の膝を貸そうか。'
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"dependencies": {
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.14.0",
"@js-temporal/polyfill": "^0.4.3",
"date-fns": "^2.29.3",
"discord.js": "^14.7.1",
"dotenv": "^16.0.3",
Expand Down
2 changes: 1 addition & 1 deletion src/adaptor/clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export class MockClock implements Clock {
constructor(public placeholder: Date) {}

now(): Date {
return this.placeholder;
return new Date(this.placeholder);
}
}

Expand Down
6 changes: 5 additions & 1 deletion src/adaptor/proxy/middleware/message-convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ const observableMessage = (
content: raw.content || '',
async sendEphemeralToSameChannel(message: string): Promise<void> {
const FIVE_SECONDS_MS = 5000;
const sent = await raw.channel.send({
const { channel } = raw;
if (!('send' in channel)) {
return;
}
const sent = await channel.send({
content: message,
allowedMentions: {
parse: []
Expand Down
76 changes: 76 additions & 0 deletions src/adaptor/signal-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { parse } from 'yaml';

import { messageTypes, SignalSchedule } from '../service/time-signal.js';

const messageFields = ['time', 'message'] as const;
const timeFields = ['hours', 'minutes'] as const;

const validate = (object: unknown): object is SignalSchedule => {
if (!(typeof object === 'object' && object !== null)) {
return false;
}
for (const key of messageTypes) {
if (!Object.hasOwn(object, key)) {
return false;
}
const entryUnsafe = (object as Record<typeof key, unknown>)[key];
if (!(typeof entryUnsafe === 'object' && entryUnsafe !== null)) {
return false;
}
for (const field of messageFields) {
if (!Object.hasOwn(entryUnsafe, field)) {
return false;
}
}
const { time: timeUnsafe, message } = entryUnsafe as Record<
(typeof messageFields)[number],
unknown
>;
if (!(typeof message === 'string' && 0 < message.length)) {
return false;
}
if (!(typeof timeUnsafe === 'object' && timeUnsafe !== null)) {
return false;
}
const { hours, minutes } = timeUnsafe as Record<
(typeof timeFields)[number],
unknown
>;
if (
!(
typeof hours === 'number' &&
Number.isInteger(hours) &&
0 <= hours &&
hours < 24
)
) {
return false;
}
if (
!(
typeof minutes === 'number' &&
Number.isInteger(minutes) &&
0 <= minutes &&
minutes < 60
)
) {
return false;
}
}
return true;
};

export const loadSchedule = (path: string[]): SignalSchedule => {
const fileText = readFileSync(join(...path), {
encoding: 'utf-8',
flag: 'r'
});
const parsed = parse(fileText) as unknown;
if (!validate(parsed)) {
console.error(parsed);
throw new Error('invalid signal schedule');
}
return parsed;
};
6 changes: 3 additions & 3 deletions src/runner/schedule.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { addMilliseconds, isAfter } from 'date-fns';

/**
* `ScheduleRunner` に登録するイベントが実装するインターフェイス。戻り値は次に自身を再実行する時刻。`null` を返した場合は再実行されない。
* `ScheduleRunner` に登録するイベントが実装するインターフェイス。戻り値は次に自身を再実行する UTC 時刻。`null` を返した場合は再実行されない。
*/
export interface ScheduleTask {
(): Promise<Date | null>;
Expand All @@ -12,7 +12,7 @@ export interface ScheduleTask {
*/
export interface Clock {
/**
* 現在時刻を取得する
* 現在の UTC 時刻を取得する
*
* @returns 呼び出した時点での時刻。
*/
Expand Down Expand Up @@ -77,7 +77,7 @@ export class ScheduleRunner {
*
* @param key - あとで登録したタスクを停止させるときに用いるキーのオブジェクト
* @param task - 実行したいタスク
* @param time - いつ実行するのか
* @param time - いつ実行するのか、UTC 時刻で
*/
runOnNextTime(key: unknown, task: ScheduleTask, time: Date): void {
this.queue.set(key, [task, time]);
Expand Down
14 changes: 11 additions & 3 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
roleProxy
} from '../adaptor/index.js';
import { DiscordCommandProxy } from '../adaptor/proxy/command.js';
import { loadSchedule } from '../adaptor/signal-schedule.js';
import { GenVersionFetcher } from '../adaptor/version/fetch.js';
import type { Snowflake } from '../model/id.js';
import { CommandRunner } from '../runner/command.js';
Expand All @@ -49,6 +50,7 @@ import {
allRoleResponder,
registerAllCommandResponder
} from '../service/index.js';
import { startTimeSignal } from '../service/time-signal.js';
import {
type VoiceChannelParticipant,
VoiceDiff
Expand Down Expand Up @@ -86,14 +88,23 @@ const typoRepo = new InMemoryTypoRepository();
const reservationRepo = new InMemoryReservationRepository();
const clock = new ActualClock();
const sequencesYaml = loadEmojiSeqYaml(['assets', 'emoji-seq.yaml']);
const output = new DiscordOutput(client, mainChannelId);

const scheduleRunner = new ScheduleRunner(clock);
const messageCreateRunner = new MessageResponseRunner(
new MessageProxy(client, middlewareForMessage())
);
if (features.includes('MESSAGE_CREATE')) {
messageCreateRunner.addResponder(
allMessageEventResponder(typoRepo, sequencesYaml)
);

startTimeSignal({
runner: scheduleRunner,
clock,
schedule: loadSchedule(['assets', 'time-signal.yaml']),
output
});
}

const messageUpdateRunner = new MessageUpdateResponseRunner(
Expand All @@ -103,12 +114,9 @@ if (features.includes('MESSAGE_UPDATE')) {
messageUpdateRunner.addResponder(allMessageUpdateEventResponder());
}

const scheduleRunner = new ScheduleRunner(clock);

const commandProxy = new DiscordCommandProxy(client, PREFIX);
const commandRunner = new CommandRunner(commandProxy);
const stats = new DiscordMemberStats(client, GUILD_ID as Snowflake);
const output = new DiscordOutput(client, mainChannelId);

// ほとんど変わらないことが予想され環境変数で管理する必要性が薄いので、ハードコードした。
const KAWAEMON_ID = '391857452360007680' as Snowflake;
Expand Down
49 changes: 49 additions & 0 deletions src/service/time-signal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test, vi } from 'vitest';

import { MockClock } from '../adaptor/clock.js';
import { ScheduleRunner } from '../runner/schedule.js';
import type { StandardOutput } from './output.js';
import { SignalSchedule, startTimeSignal } from './time-signal.js';

describe('time signal reported', () => {
const clock = new MockClock(new Date(Date.UTC(2020, 0, 1, 0, 0)));
const runner = new ScheduleRunner(clock);
const output: StandardOutput = { sendEmbed: () => Promise.resolve() };
test('at now', () => {
const sendEmbed = vi.spyOn(output, 'sendEmbed');
const schedule: SignalSchedule = {
MORNING: {
time: {
hours: 8,
minutes: 0
},
message: 'hoge'
},
NOON: {
time: {
hours: 12,
minutes: 0
},
message: 'fuga'
},
MIDNIGHT: {
time: {
hours: 21,
minutes: 0
},
message: 'foo'
}
};

startTimeSignal({ runner, clock, schedule, output });
clock.placeholder = new Date(Date.UTC(2020, 0, 1, 3, 1));
runner.consume();

expect(sendEmbed).toHaveBeenCalledOnce();
expect(sendEmbed).toHaveBeenCalledWith({
title: 'はらちょ時報システム',
description: 'fuga',
footer: '2020/01/01 12:01:00 JST'
});
});
});
94 changes: 94 additions & 0 deletions src/service/time-signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
import { setTimeout } from 'timers/promises';

import type {
Clock,
ScheduleRunner,
ScheduleTask
} from '../runner/schedule.js';
import type { StandardOutput } from './output.js';

export const messageTypes = ['MORNING', 'NOON', 'MIDNIGHT'] as const;

export type SignalMessageType = (typeof messageTypes)[number];

/**
* 時報を送る時分を Asia/Tokyo のタイムゾーンで表す。
*/
export interface SignalTime {
hours: number;
minutes: number;
}

const intoDate = ({ hours, minutes }: SignalTime, clock: Clock): Date => {
const nowInstant = toTemporalInstant.call(clock.now());
const now = nowInstant.toZonedDateTimeISO('Asia/Tokyo');
let hasHoursMinutes = now.withPlainTime({
hour: hours,
minute: minutes
});
if (Temporal.Instant.compare(nowInstant, hasHoursMinutes.toInstant()) > 0) {
hasHoursMinutes = hasHoursMinutes.add(Temporal.Duration.from({ days: 1 }));
}
return new Date(hasHoursMinutes.epochMilliseconds);
};

export interface SignalMessage {
time: SignalTime;
message: string;
}

export type SignalSchedule = Record<SignalMessageType, SignalMessage>;

const reportTimeSignal =
({
signalMessage,
clock,
output
}: {
signalMessage: SignalMessage;
clock: Clock;
output: StandardOutput;
}): ScheduleTask =>
async (): Promise<Date> => {
const nowInstant = toTemporalInstant.call(clock.now());
const now = nowInstant.toZonedDateTimeISO('Asia/Tokyo');
await output.sendEmbed({
title: 'はらちょ時報システム',
description: signalMessage.message,
footer: now.toLocaleString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
})
});
await setTimeout(60 * 1000);
return intoDate(signalMessage.time, clock);
};

export const startTimeSignal = ({
runner,
clock,
schedule,
output
}: {
runner: ScheduleRunner;
clock: Clock;
schedule: SignalSchedule;
output: StandardOutput;
}) => {
for (const messageType of messageTypes) {
const signalMessage = schedule[messageType];
const reportTask = reportTimeSignal({ signalMessage, clock, output });
const firstSignalDate = intoDate(signalMessage.time, clock);
runner.runOnNextTime(
`TIME_SIGNAL_${messageType}`,
reportTask,
firstSignalDate
);
}
};
Loading

0 comments on commit 46bea77

Please sign in to comment.