Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

atequiz: Handle errors in onTick function and allow abortion of AteQuiz #934

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions achievement-quiz/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
jest.mock('../lib/slackUtils');

import achievementQuiz from './index';
import Slack from '../lib/slackMock';

Expand Down
102 changes: 64 additions & 38 deletions atequiz/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { WebAPICallOptions, WebClient } from '@slack/web-api';
import type { EventEmitter } from 'events';
import { SlackInterface } from '../lib/slack';
import { ChatPostMessageArguments } from '@slack/web-api/dist/methods';
import assert from 'assert';
import { Mutex } from 'async-mutex';
import { Deferred } from '../lib/utils';
import logger from '../lib/logger';
import type { GenericMessageEvent, MessageEvent } from '@slack/bolt';
import { extractMessage, isBotMessage } from '../lib/slackUtils';

const log = logger.child({ bot: 'atequiz' });

export interface AteQuizProblem {
problemMessage: ChatPostMessageArguments;
Expand Down Expand Up @@ -63,15 +67,17 @@ export const typicalMessageTextsGenerator = {
* To use other judge/watSecGen/ngReaction, please extend this class.
*/
export class AteQuiz {
eventClient: EventEmitter;
eventClient: SlackInterface['eventClient'];
slack: WebClient;
problem: AteQuizProblem;
ngReaction: string | null = 'no_good';
state: AteQuizState = 'waiting';
replaceKeys: { correctAnswerer: string } = { correctAnswerer: '[[!user]]' };
mutex: Mutex;
mutex: Mutex = new Mutex();
deferred: Deferred<AteQuizResult> = new Deferred();
postOption: WebAPICallOptions;
threadTsDeferred: Deferred<string> = new Deferred();
tickTimer: NodeJS.Timeout | null = null;

judge(answer: string, _user: string): boolean {
return this.problem.correctAnswers.some(
Expand All @@ -88,7 +94,7 @@ export class AteQuiz {
* @param {any} post the post judged as correct
* @returns a object that specifies the parameters of a solved message
*/
solvedMessageGen(post: any): ChatPostMessageArguments | Promise<ChatPostMessageArguments> {
solvedMessageGen(post: GenericMessageEvent): ChatPostMessageArguments | Promise<ChatPostMessageArguments> {
const message = Object.assign({}, this.problem.solvedMessage);
message.text = message.text.replaceAll(
this.replaceKeys.correctAnswerer,
Expand All @@ -97,14 +103,14 @@ export class AteQuiz {
return message;
}

answerMessageGen(_post?: any): ChatPostMessageArguments | null | Promise<ChatPostMessageArguments | null> {
answerMessageGen(_post?: GenericMessageEvent): ChatPostMessageArguments | null | Promise<ChatPostMessageArguments | null> {
if (!this.problem.answerMessage) {
return null;
}
return this.problem.answerMessage;
}

incorrectMessageGen(post: any): ChatPostMessageArguments | null {
incorrectMessageGen(post: GenericMessageEvent): ChatPostMessageArguments | null {
if (!this.problem.incorrectMessage) {
return null;
}
Expand All @@ -124,15 +130,13 @@ export class AteQuiz {
this.eventClient = eventClient;
this.slack = slack;
this.problem = JSON.parse(JSON.stringify(problem));
this.postOption = JSON.parse(JSON.stringify(option));
this.postOption = option ? JSON.parse(JSON.stringify(option)) : option;

assert(
this.problem.hintMessages.every(
(hint) => hint.channel === this.problem.problemMessage.channel
)
);

this.mutex = new Mutex();
}

async repostProblemMessage() {
Expand All @@ -150,6 +154,10 @@ export class AteQuiz {
* @returns A promise of AteQuizResult that becomes resolved when the quiz ends.
*/
async start(startOption?: AteQuizStartOption): Promise<AteQuizResult> {
if (this.state !== 'waiting') {
throw new Error('AteQuiz is already started');
}

const _option = Object.assign(
{ mode: 'normal' } as AteQuizStartOption,
startOption
Expand All @@ -171,49 +179,62 @@ export class AteQuiz {
let previousHintTime: number = null;
let hintIndex = 0;

const deferred = new Deferred<AteQuizResult>();

const onTick = () => {
this.mutex.runExclusive(async () => {
const now = Date.now();
const nextHintTime =
previousHintTime + 1000 * this.waitSecGen(hintIndex);
if (this.state === 'solving' && nextHintTime <= now) {
previousHintTime = now;
if (hintIndex < this.problem.hintMessages.length) {
const hint = this.problem.hintMessages[hintIndex];
await postMessage(Object.assign({}, hint, { thread_ts }));
hintIndex++;
} else {
this.state = 'unsolved';
await postMessage(
Object.assign({}, this.problem.unsolvedMessage, { thread_ts })
);

const answerMessage = await this.answerMessageGen();
if (this.problem.answerMessage) {
try {
const now = Date.now();
const nextHintTime =
previousHintTime + 1000 * this.waitSecGen(hintIndex);
if (this.state === 'solving' && nextHintTime <= now) {
previousHintTime = now;
if (hintIndex < this.problem.hintMessages.length) {
const hint = this.problem.hintMessages[hintIndex];
await postMessage(Object.assign({}, hint, { thread_ts }));
hintIndex++;
} else {
this.state = 'unsolved';
await postMessage(
Object.assign({}, this.problem.answerMessage, { thread_ts })
Object.assign({}, this.problem.unsolvedMessage, { thread_ts })
);

const answerMessage = await this.answerMessageGen();
if (answerMessage) {
await postMessage(
Object.assign({}, answerMessage, { thread_ts })
);
}
clearInterval(this.tickTimer);
this.deferred.resolve(result);
}
clearInterval(tickTimer);
deferred.resolve(result);
}
} catch (error) {
log.error(error?.stack);
this.deferred.reject(error);
this.abort();

await postMessage({
username: 'AteQuiz',
channel: this.problem.problemMessage.channel,
text: `エラーが発生しました。\n${error?.stack}`,
thread_ts,
});
}
});
};

this.eventClient.on('message', async (message) => {
if (message.thread_ts === thread_ts) {
if (message.subtype === 'bot_message') return;
this.eventClient.on('message', async (messageEvent: MessageEvent) => {
const message = extractMessage(messageEvent);

if (message !== null && message.thread_ts === thread_ts) {
if (isBotMessage(message)) return;
if (_option.mode === 'solo' && message.user !== _option.player) return;
this.mutex.runExclusive(async () => {
if (this.state === 'solving') {
const answer = message.text as string;
const isCorrect = this.judge(answer, message.user as string);
if (isCorrect) {
this.state = 'solved';
clearInterval(tickTimer);
clearInterval(this.tickTimer);

await postMessage(
Object.assign({}, await this.solvedMessageGen(message), { thread_ts })
Expand All @@ -229,7 +250,7 @@ export class AteQuiz {
result.correctAnswerer = message.user;
result.hintIndex = hintIndex;
result.state = 'solved';
deferred.resolve(result);
this.deferred.resolve(result);
} else {
const generatedMessage = this.incorrectMessageGen(message);
if (this.ngReaction) {
Expand Down Expand Up @@ -262,8 +283,13 @@ export class AteQuiz {
);
}
previousHintTime = Date.now();
const tickTimer = setInterval(onTick, 1000);
this.tickTimer = setInterval(onTick, 1000);

return this.deferred.promise;
}

return deferred.promise;
abort() {
this.state = 'unsolved';
clearInterval(this.tickTimer);
}
}
8 changes: 6 additions & 2 deletions lib/slackUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {WebClient} from '@slack/web-api';
import {eventClient, getTokens} from './slack';
import {Deferred} from './utils';
import SlackCache from './slackCache';
import type {GenericMessageEvent, MessageEvent} from '@slack/bolt';
import type {BotMessageEvent, GenericMessageEvent, MessageEvent} from '@slack/bolt';

const slackCaches = new Map<string, SlackCache>();
const initializedSlackCachesDeferred = new Deferred<void>();
Expand Down Expand Up @@ -98,10 +98,14 @@ export const mrkdwn = (text: string): MrkdwnElement => ({
text,
});

const isGenericMessage = (message: MessageEvent): message is GenericMessageEvent => (
export const isGenericMessage = (message: MessageEvent): message is GenericMessageEvent => (
message.subtype === undefined
);

export const isBotMessage = (message: MessageEvent): message is BotMessageEvent => (
message.subtype === 'bot_message'
);

export const extractMessage = (message: MessageEvent) => {
if (isGenericMessage(message)) {
return message;
Expand Down
1 change: 1 addition & 0 deletions mahjong/index.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-env node, jest */

jest.mock('../achievements');
jest.mock('../lib/slackUtils.ts');

const {default: Slack} = require('../lib/slackMock.ts');
const mahjong = require('./index.js');
Expand Down
4 changes: 2 additions & 2 deletions qrcode-quiz/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {writeFileSync} from 'fs';
import {Message} from '@slack/web-api/dist/response/ConversationsHistoryResponse';
import type {GenericMessageEvent} from '@slack/bolt';
import {Mutex} from 'async-mutex';
import {v2 as cloudinary} from 'cloudinary';
import {stripIndent} from 'common-tags';
Expand Down Expand Up @@ -344,7 +344,7 @@ class QrAteQuiz extends AteQuiz {
return super.start();
}

solvedMessageGen(message: Message) {
solvedMessageGen(message: GenericMessageEvent) {
this.endTime = Date.now();

const duration = (this.endTime - this.startTime) / 1000;
Expand Down
5 changes: 3 additions & 2 deletions ricochet-robots/SinglePlayRicochetRobot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ChatPostMessageArguments, KnownBlock } from '@slack/web-api';
import { unlock } from '../achievements';
import assert from 'assert';
import { stripIndent } from 'common-tags';
import type { GenericMessageEvent } from '@slack/bolt';

interface SingleRicochetRobotConstructor {
slackClients: SlackInterface,
Expand Down Expand Up @@ -179,7 +180,7 @@ export default class SinglePlayRicochetRobot extends AteQuiz {
return false;
}

async solvedMessageGen(message: Message) {
async solvedMessageGen(message: GenericMessageEvent) {
const answer = message.text as string;
assert(board.iscommand(answer), 'answer is not command');

Expand All @@ -200,7 +201,7 @@ export default class SinglePlayRicochetRobot extends AteQuiz {
};
}

async answerMessageGen(message: Message) {
async answerMessageGen(message: GenericMessageEvent) {
const answer = message.text as string;
assert(board.iscommand(answer), 'answer is not command');

Expand Down
1 change: 1 addition & 0 deletions wadokaichin/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Slack from '../lib/slackMock';
import path from 'path';

jest.mock('../lib/slackUtils');
jest.mock('fs');
import fs from 'fs';

Expand Down
Loading