diff --git a/atequiz/index.ts b/atequiz/index.ts index a6abef19..88b7e26c 100644 --- a/atequiz/index.ts +++ b/atequiz/index.ts @@ -1,4 +1,4 @@ -import { ChatPostMessageArguments, WebClient } from '@slack/web-api'; +import { ChatPostMessageArguments, ChatPostMessageResponse, MessageEvent, WebClient } from '@slack/web-api'; import type { EventEmitter } from 'events'; import { SlackInterface } from '../lib/slack'; import assert from 'assert'; @@ -25,11 +25,15 @@ export interface AteQuizResult { hintIndex: number | null; } -export interface NormalAteQuizStartOption { +export interface CommonAteQuizStartOption { + onStarted?: (startMessage: ChatPostMessageResponse) => void; +} + +export interface NormalAteQuizStartOption extends CommonAteQuizStartOption { mode: 'normal'; } -export interface SoloAteQuizStartOption { +export interface SoloAteQuizStartOption extends CommonAteQuizStartOption { mode: 'solo'; player: string; } @@ -87,30 +91,30 @@ 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 { + solvedMessageGen(post: MessageEvent): ChatPostMessageArguments | Promise { const message = Object.assign({}, this.problem.solvedMessage); message.text = message.text.replaceAll( this.replaceKeys.correctAnswerer, - post.user as string + 'user' in post ? post.user : '' ); return message; } - answerMessageGen(_post?: any): ChatPostMessageArguments | null | Promise { + answerMessageGen(_post?: MessageEvent): ChatPostMessageArguments | null | Promise { if (!this.problem.answerMessage) { return null; } return this.problem.answerMessage; } - incorrectMessageGen(post: any): ChatPostMessageArguments | null { + incorrectMessageGen(post: MessageEvent): ChatPostMessageArguments | null { if (!this.problem.incorrectMessage) { return null; } const message = Object.assign({}, this.problem.incorrectMessage); message.text = message.text.replaceAll( this.replaceKeys.correctAnswerer, - post.user as string + 'user' in post ? post.user : '' ); return message; } @@ -205,9 +209,9 @@ export class AteQuiz { }); }; - this.eventClient.on('message', async (message) => { + this.eventClient.on('message', async (message: MessageEvent) => { const thread_ts = await this.threadTsDeferred.promise; - if (message.thread_ts === thread_ts) { + if ('thread_ts' in message && message.thread_ts === thread_ts) { if (message.subtype === 'bot_message') return; if (_option.mode === 'solo' && message.user !== _option.player) return; this.mutex.runExclusive(async () => { @@ -259,6 +263,11 @@ export class AteQuiz { // Listeners should be added before postMessage is called. const response = await postMessage(this.problem.problemMessage); + + if (startOption?.onStarted) { + startOption.onStarted(response); + } + const thread_ts = response?.message?.thread_ts ?? response.ts; this.threadTsDeferred.resolve(thread_ts); assert(typeof thread_ts === 'string'); diff --git a/character-quiz/index.ts b/character-quiz/index.ts index 4c169dde..f347e226 100644 --- a/character-quiz/index.ts +++ b/character-quiz/index.ts @@ -2,12 +2,13 @@ import type { ImageBlock, MrkdwnElement, ChatPostMessageArguments, + GenericMessageEvent, } from '@slack/web-api'; import {Mutex} from 'async-mutex'; import axios from 'axios'; import cloudinary from 'cloudinary'; import type {CommonTransformationOptions} from 'cloudinary'; -// @ts-expect-error +// @ts-expect-error: Missing types import {hiraganize} from 'japanese'; import {random, sample, range} from 'lodash'; import {increment} from '../achievements'; @@ -17,32 +18,32 @@ import { typicalMessageTextsGenerator, typicalAteQuizHintTexts, } from '../atequiz'; +import {ChannelLimitedBot} from '../lib/channelLimitedBot'; import {SlackInterface} from '../lib/slack'; import State from '../lib/state'; -import {Loader} from '../lib/utils'; -import {isPlayground} from '../lib/slackUtils'; +import {Deferred, Loader} from '../lib/utils'; const mutex = new Mutex(); interface CharacterData { - tweetId: string; - mediaId: string; - imageUrl: string; - characterName: string; - workName: string; - validAnswers: string[]; - author: string; - rating: string; - characterId: string; + tweetId: string; + mediaId: string; + imageUrl: string; + characterName: string; + workName: string; + validAnswers: string[]; + author: string; + rating: string; + characterId: string; } interface PersistentState { - recentMediaIds: string[]; - recentCharacterIds: string[]; + recentMediaIds: string[]; + recentCharacterIds: string[]; } interface CharacterQuizProblem extends AteQuizProblem { - correctCharacter: CharacterData; + correctCharacter: CharacterData; } class CharacterQuiz extends AteQuiz { @@ -82,9 +83,9 @@ const loadCharacters = async (author: string) => { const characterNames = characterName.split('、').filter((name) => name !== ''); const characterRubys = characterRuby.split('、').filter((name) => name !== ''); - if (characterNames.length === 0 || characterRubys.length === 0) { - return [] as CharacterData[]; - } + if (characterNames.length === 0 || characterRubys.length === 0) { + return [] as CharacterData[]; + } const names = [...characterNames, ...characterRubys]; const namePartsList = names.map((name) => name.split('&').map((nameOne) => nameOne.split(' '))); @@ -108,7 +109,7 @@ const loadCharacters = async (author: string) => { author, rating: rating ?? '0', characterId: `${namePartsList[0].flat().join('')}\0${normalizedWorkName}`, - } as CharacterData + } as CharacterData, ]; }) .filter(({rating}) => rating === '0'); @@ -208,11 +209,6 @@ const getHintOptions = ( }; }; -const postOption = { - username: 'namori', - icon_emoji: ':namori:', -}; - const generateProblem = async ( character: CharacterData, channel: string, @@ -339,99 +335,131 @@ const generateProblem = async ( return problem; }; -export default async (slackClients: SlackInterface) => { - const {eventClient} = slackClients; +class CharacterQuizBot extends ChannelLimitedBot { + protected override readonly wakeWordRegex = /^(?:キャラ|なもり|Ixy)当てクイズ$/; - const persistentState = await State.init('anime-namori', { - recentMediaIds: [], - recentCharacterIds: [], - }); + protected override readonly username = 'namori'; - eventClient.on('message', (message) => { - if (!isPlayground(message.channel)) { - return; - } + protected override readonly iconEmoji = ':namori:'; - mutex.runExclusive(async () => { - if ( - message.text && - message.text.match(/^(?:キャラ|なもり|Ixy)当てクイズ$/) - ) { - const characters = await (async () => { - const namori = - message.text === 'キャラ当てクイズ' || - message.text === 'なもり当てクイズ' ? await loaderNamori.load() : []; - const ixy = - message.text === 'キャラ当てクイズ' || - message.text === 'Ixy当てクイズ' ? await loaderIxy.load() : []; - - return [...namori, ...ixy]; - })(); - const candidateCharacterIds = characters - .filter( - (character) => !persistentState.recentCharacterIds.includes( - character.characterId, - ), - ) - .map(({characterId}) => characterId); - const answerCharacterId = sample( - Array.from(new Set(candidateCharacterIds)), - ); + constructor( + protected readonly slackClients: SlackInterface, + protected readonly persistentState: PersistentState, + ) { + super(slackClients); + } - const answer = sample( - characters.filter( - (character) => character.characterId === answerCharacterId, + protected override onWakeWord(message: GenericMessageEvent, channel: string): Promise { + const quizMessageDeferred = new Deferred(); + + mutex.runExclusive(async () => { + const characters = await (async () => { + const namori = + message.text === 'キャラ当てクイズ' || + message.text === 'なもり当てクイズ' ? await loaderNamori.load() : []; + const ixy = + message.text === 'キャラ当てクイズ' || + message.text === 'Ixy当てクイズ' ? await loaderIxy.load() : []; + + return [...namori, ...ixy]; + })(); + + const candidateCharacterIds = characters + .filter( + (character) => !this.persistentState.recentCharacterIds.includes( + character.characterId, ), - ); + ) + .map(({characterId}) => characterId); + const answerCharacterId = sample( + Array.from(new Set(candidateCharacterIds)), + ); + + const answer = sample( + characters.filter( + (character) => character.characterId === answerCharacterId, + ), + ); + + const problem = await generateProblem(answer, channel); + const quiz = new CharacterQuiz(this.slackClients, problem, { + username: this.username, + icon_emoji: this.iconEmoji, + }); + + this.persistentState.recentCharacterIds.push(answer.characterId); + while (this.persistentState.recentCharacterIds.length > 200) { + this.persistentState.recentCharacterIds.shift(); + } + + const result = await quiz.start({ + mode: 'normal', + onStarted(startMessage) { + quizMessageDeferred.resolve(startMessage.ts!); + }, + }); - const problem = await generateProblem(answer, message.channel); - const quiz = new CharacterQuiz(slackClients, problem, postOption); + await this.deleteProgressMessage(await quizMessageDeferred.promise); - persistentState.recentCharacterIds.push(answer.characterId); - while (persistentState.recentCharacterIds.length > 200) { - persistentState.recentCharacterIds.shift(); + if (result.state === 'solved') { + // Achievements for all quizzes + await increment(result.correctAnswerer, 'chara-ate-answer'); + if (result.hintIndex === 0) { + await increment(result.correctAnswerer, 'chara-ate-answer-first-hint'); + } + if (result.hintIndex <= 1) { + await increment(result.correctAnswerer, 'chara-ate-answer-second-hint'); + } + if (result.hintIndex <= 2) { + await increment(result.correctAnswerer, 'chara-ate-answer-third-hint'); } - const result = await quiz.start(); - - if (result.state === 'solved') { - // Achievements for all quizzes - await increment(result.correctAnswerer, 'chara-ate-answer'); - if (result.hintIndex === 0) { - await increment(result.correctAnswerer, 'chara-ate-answer-first-hint'); - } - if (result.hintIndex <= 1) { - await increment(result.correctAnswerer, 'chara-ate-answer-second-hint'); - } - if (result.hintIndex <= 2) { - await increment(result.correctAnswerer, 'chara-ate-answer-third-hint'); - } - - // for author-specific quizzes + // for author-specific quizzes + await increment( + result.correctAnswerer, + `${problem.correctCharacter.author}-answer`, + ); + if (result.hintIndex === 0) { + await increment( + result.correctAnswerer, + `${problem.correctCharacter.author}-answer-first-hint`, + ); + } + if (result.hintIndex <= 1) { + await increment( + result.correctAnswerer, + `${problem.correctCharacter.author}-answer-second-hint`, + ); + } + if (result.hintIndex <= 2) { await increment( result.correctAnswerer, - `${problem.correctCharacter.author}-answer`, + `${problem.correctCharacter.author}-answer-third-hint`, ); - if (result.hintIndex === 0) { - await increment( - result.correctAnswerer, - `${problem.correctCharacter.author}-answer-first-hint`, - ); - } - if (result.hintIndex <= 1) { - await increment( - result.correctAnswerer, - `${problem.correctCharacter.author}-answer-second-hint`, - ); - } - if (result.hintIndex <= 2) { - await increment( - result.correctAnswerer, - `${problem.correctCharacter.author}-answer-third-hint`, - ); - } } } + }).catch((error: unknown) => { + this.log.error('Failed to start character quiz', error); + const errorText = + error instanceof Error && error.stack !== undefined + ? error.stack : String(error); + this.postMessage({ + channel, + text: `エラー😢\n\`${errorText}\``, + }); + quizMessageDeferred.reject(error); }); + + return quizMessageDeferred.promise; + } +} + +// eslint-disable-next-line require-jsdoc +export default async function characterQuiz(slackClients: SlackInterface) { + const persistentState = await State.init('anime-namori', { + recentMediaIds: [], + recentCharacterIds: [], }); -}; + + return new CharacterQuizBot(slackClients, persistentState); +} diff --git a/lib/channelLimitedBot.ts b/lib/channelLimitedBot.ts index 73d62f1a..8cb7f7ad 100644 --- a/lib/channelLimitedBot.ts +++ b/lib/channelLimitedBot.ts @@ -1,6 +1,6 @@ import type {SlackInterface} from './slack'; import {extractMessage, isGenericMessage} from './slackUtils'; -import type {GenericMessageEvent, WebClient} from '@slack/web-api'; +import type {ChatPostMessageArguments, GenericMessageEvent, WebClient} from '@slack/web-api'; import type {MessageEvent} from '@slack/bolt'; import logger from './logger'; import {Deferred} from './utils'; @@ -127,11 +127,9 @@ export class ChannelLimitedBot { return undefined; } - const progressMessage = await this.slack.chat.postMessage({ + const progressMessage = await this.postMessage({ channel: this.progressMessageChannel, text: `<${gameMessageLink}|進行中のゲーム>があります!`, - username: this.username, - icon_emoji: this.iconEmoji, unfurl_links: false, unfurl_media: false, }); @@ -139,6 +137,14 @@ export class ChannelLimitedBot { return progressMessage.ts; } + protected postMessage(message: ChatPostMessageArguments) { + return this.slack.chat.postMessage({ + username: this.username, + icon_emoji: this.iconEmoji, + ...message, + } as ChatPostMessageArguments); + } + protected async deleteProgressMessage(gameMessageTs: string) { if (this.progressMessageChannel === undefined) { return;