Skip to content
Draft
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
37 changes: 37 additions & 0 deletions achievements/achievements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4376,6 +4376,43 @@ const achievements: Achievement[] = [
category: 'manual',
manual: true,
},
// prefecture-quiz
{
id: 'prefecture-quiz-answer-1',
difficulty: 'easy',
title: '郷土愛',
condition: '都道府県当てクイズで正解する',
category: 'prefecture-quiz',
counter: 'prefecture-quiz-answer',
value: 1,
},
{
id: 'prefecture-quiz-answer-10',
difficulty: 'medium',
title: '旅人',
condition: '都道府県当てクイズで10回正解する',
category: 'prefecture-quiz',
counter: 'prefecture-quiz-answer',
value: 10,
},
{
id: 'prefecture-quiz-answer-47',
difficulty: 'hard',
title: '日本一周',
condition: '都道府県当てクイズで47回正解する',
category: 'prefecture-quiz',
counter: 'prefecture-quiz-answer',
value: 47,
},
{
id: 'prefecture-quiz-answer-first-hint',
difficulty: 'hard',
title: '地理マニア',
condition: '都道府県当てクイズで最初のヒントのみで正解する',
category: 'prefecture-quiz',
counter: 'prefecture-quiz-answer-first-hint',
value: 1,
},
];

const achievementMap = new Map(achievements.map((achievement) => [achievement.id, achievement]));
Expand Down
9 changes: 9 additions & 0 deletions atequiz/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export class AteQuiz {
);
}

// Returns false to silently skip (no reaction). Override to filter or rate-limit answers.
isValidAnswer(_answer: string, _user: string, _message: MessageEvent): boolean | Promise<boolean> {
return true;
}

async onHintPosted(_hintIndex: number, _thread_ts: string): Promise<void> {}

waitSecGen(hintIndex: number): number {
return hintIndex === this.problem.hintMessages.length ? 30 : 15;
}
Expand Down Expand Up @@ -185,6 +192,7 @@ export class AteQuiz {
if (hintIndex < this.problem.hintMessages.length) {
const hint = this.problem.hintMessages[hintIndex];
await postMessage(Object.assign({}, hint, { thread_ts }));
await this.onHintPosted(hintIndex, thread_ts);
hintIndex++;
} else {
this.state = 'unsolved';
Expand Down Expand Up @@ -217,6 +225,7 @@ export class AteQuiz {
this.mutex.runExclusive(async () => {
if (this.state === 'solving') {
const answer = message.text as string;
if (!await this.isValidAnswer(answer, message.user as string, message)) return;
const isCorrect = await this.judge(answer, message.user as string);
if (isCorrect) {
this.state = 'solved';
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const productionBots = [
'autogen-quiz',
'twenty-questions',
'google-calendar',
'prefecture-quiz',
];

const developmentBots = [
Expand Down
7 changes: 4 additions & 3 deletions lib/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
cost: number | null; // Cost in USD
}

const OpenAIUsageLog = db.collection('openai_usage_logs') as CollectionReference<OpenAIUsageLog>;
const OpenAIUsageLog = db?.collection('openai_usage_logs') as CollectionReference<OpenAIUsageLog> | null ?? null;

Check warning on line 31 in lib/openai.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=tsg-ut_slackbot&issues=AZ7GCH6pgfB4D_M8ibTX&open=AZ7GCH6pgfB4D_M8ibTX&pullRequest=1208

const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
Expand All @@ -45,6 +45,7 @@
};

const checkUsageLimit = async (errorDestination: 'slack' | 'discord'): Promise<void> => {
if (!OpenAIUsageLog) return;
const now = new Date();
const oneDayAgo = dayjs(now).subtract(1, 'day').toDate();
const query = OpenAIUsageLog.where('createdAt', '>=', oneDayAgo).orderBy('createdAt', 'desc');
Expand Down Expand Up @@ -133,7 +134,7 @@

log.info(`OpenAI audio.speech.create API cost: $${cost?.toFixed(4) ?? 'unknown'}`);

await OpenAIUsageLog.add({
await OpenAIUsageLog?.add({
method: 'audio.speech.create',
createdAt: firestore.FieldValue.serverTimestamp(),
model: params.model,
Expand Down Expand Up @@ -204,7 +205,7 @@

log.info(`OpenAI chat.completions.create API cost: $${cost?.toFixed(4) ?? 'unknown'}`);

await OpenAIUsageLog.add({
await OpenAIUsageLog?.add({
method: 'chat.completions.create',
createdAt: firestore.FieldValue.serverTimestamp(),
model: params.model,
Expand Down
111 changes: 111 additions & 0 deletions prefecture-quiz/PrefectureAteQuiz.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type {Block, ChatPostMessageArguments, KnownBlock, MessageEvent} from '@slack/web-api';
import {AteQuiz, type AteQuizProblem, type AteQuizStartOption} from '../atequiz';
import type {SlackInterface} from '../lib/slack';
import {isValidPrefectureAnswer} from './answers';

function buildHintBlocks(revealedHints: string[]): (Block | KnownBlock)[] {
return [
{
type: 'header',
text: {
type: 'plain_text',
text: ':japan: 都道府県当てクイズ!',
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: 'スレッドで回答してください。1人3回まで有効です。',
},
},
{
type: 'rich_text',
elements: [
{
type: 'rich_text_section',
elements: [{type: 'text', text: '現在公開中のヒント:'}],
},
{
type: 'rich_text_list',
style: 'ordered',
indent: 0,
border: 0,
elements: revealedHints.map((hint) => ({
type: 'rich_text_section',
elements: [{type: 'text', text: hint}],
})),
},
],
},
];
}

export class PrefectureAteQuiz extends AteQuiz {
#answersCount: Map<string, number> = new Map();

Check warning on line 45 in prefecture-quiz/PrefectureAteQuiz.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '#answersCount' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=tsg-ut_slackbot&issues=AZ7GCH9AgfB4D_M8ibTl&open=AZ7GCH9AgfB4D_M8ibTl&pullRequest=1208
#problemMessageTs: string | null = null;
#allHints: string[];

Check warning on line 47 in prefecture-quiz/PrefectureAteQuiz.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '#allHints' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=tsg-ut_slackbot&issues=AZ7GCH9AgfB4D_M8ibTm&open=AZ7GCH9AgfB4D_M8ibTm&pullRequest=1208
#revealedCount: number;
#channel: string;

Check warning on line 49 in prefecture-quiz/PrefectureAteQuiz.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '#channel' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=tsg-ut_slackbot&issues=AZ7GCH9AgfB4D_M8ibTn&open=AZ7GCH9AgfB4D_M8ibTn&pullRequest=1208
#lastMessage: MessageEvent | null = null;

Check warning on line 50 in prefecture-quiz/PrefectureAteQuiz.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'#lastMessage' is defined but never used.

See more on https://sonarcloud.io/project/issues?id=tsg-ut_slackbot&issues=AZ7GCH9AgfB4D_M8ibTo&open=AZ7GCH9AgfB4D_M8ibTo&pullRequest=1208

constructor(
slack: SlackInterface,
problem: AteQuizProblem,
allHints: string[],
postOption?: Partial<ChatPostMessageArguments>,
) {
super(slack, problem, postOption);
this.#allHints = allHints;
// Hint 1 is already in the problem message, so revealed count starts at 1
this.#revealedCount = 1;
this.#channel = problem.problemMessage.channel as string;
}

override async start(option?: AteQuizStartOption) {
return super.start({
...option,
onStarted: (msg) => {
this.#problemMessageTs = msg.ts ?? null;
option?.onStarted?.(msg);
},
});
}

// Hint 2–5 arrive every 45 seconds; after all hints, wait 30 seconds
override waitSecGen(hintIndex: number): number {
return hintIndex < this.problem.hintMessages.length ? 45 : 30;
}

override async isValidAnswer(answer: string, user: string, message: MessageEvent): Promise<boolean> {
if (!isValidPrefectureAnswer(answer)) return false;

const count = this.#answersCount.get(user) ?? 0;
if (count >= 3) {
await this.slack.reactions.add({
name: 'no_entry_sign',
channel: message.channel,
timestamp: message.ts,
});
return false;
}

this.#answersCount.set(user, count + 1);
this.#lastMessage = message;
return true;
}

override async onHintPosted(hintIndex: number, _thread_ts: string): Promise<void> {
// hintIndex is 0-based index into hintMessages (which are hints 2–5)
this.#revealedCount = hintIndex + 2;
const revealed = this.#allHints.slice(0, this.#revealedCount);
if (!this.#problemMessageTs) return;

await this.slack.chat.update({
channel: this.#channel,
ts: this.#problemMessageTs,
text: `都道府県当てクイズ: ${revealed.length}件のヒントが公開中`,
blocks: buildHintBlocks(revealed),
});
}
}
102 changes: 102 additions & 0 deletions prefecture-quiz/answers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {getCorrectAnswers, isValidPrefectureAnswer, maskAnswers} from './answers';

describe('getCorrectAnswers', () => {
test('新潟県 returns expected variants', () => {
const answers = getCorrectAnswers('新潟県');
expect(answers).toContain('新潟県');
expect(answers).toContain('新潟');
expect(answers).toContain('にいがた');
expect(answers).toContain('にいがたけん');
expect(answers).toContain('ニイガタ');
});

test('東京都 returns expected variants', () => {
const answers = getCorrectAnswers('東京都');
expect(answers).toContain('東京都');
expect(answers).toContain('東京');
expect(answers).toContain('とうきょう');
expect(answers).toContain('とうきょうと');
});

test('大阪府 returns expected variants', () => {
const answers = getCorrectAnswers('大阪府');
expect(answers).toContain('大阪府');
expect(answers).toContain('大阪');
expect(answers).toContain('おおさか');
});

test('北海道 returns expected variants', () => {
const answers = getCorrectAnswers('北海道');
expect(answers).toContain('北海道');
expect(answers).toContain('ほっかいどう');
});
});

describe('isValidPrefectureAnswer', () => {
test('accepts full prefecture names', () => {
expect(isValidPrefectureAnswer('新潟県')).toBe(true);
expect(isValidPrefectureAnswer('東京都')).toBe(true);
expect(isValidPrefectureAnswer('大阪府')).toBe(true);
expect(isValidPrefectureAnswer('北海道')).toBe(true);
});

test('accepts short names without suffix', () => {
expect(isValidPrefectureAnswer('新潟')).toBe(true);
expect(isValidPrefectureAnswer('東京')).toBe(true);
expect(isValidPrefectureAnswer('大阪')).toBe(true);
});

test('accepts hiragana readings', () => {
expect(isValidPrefectureAnswer('にいがた')).toBe(true);
expect(isValidPrefectureAnswer('にいがたけん')).toBe(true);
expect(isValidPrefectureAnswer('とうきょう')).toBe(true);
});

test('accepts katakana readings', () => {
expect(isValidPrefectureAnswer('ニイガタ')).toBe(true);
expect(isValidPrefectureAnswer('トウキョウ')).toBe(true);
});

test('rejects non-prefecture strings', () => {
expect(isValidPrefectureAnswer('こんにちは')).toBe(false);
expect(isValidPrefectureAnswer('日本')).toBe(false);
expect(isValidPrefectureAnswer('新潟市')).toBe(false);
expect(isValidPrefectureAnswer('')).toBe(false);
});

test('is case insensitive', () => {
expect(isValidPrefectureAnswer('ニイガタ')).toBe(true);
expect(isValidPrefectureAnswer('にいがた')).toBe(true);
});
});

describe('maskAnswers', () => {
test('masks prefecture name', () => {
const result = maskAnswers('新潟県は米どころです', '新潟県');
expect(result).not.toContain('新潟');
expect(result).toContain('〇〇');
});

test('masks short name', () => {
const result = maskAnswers('新潟の米はおいしい', '新潟県');
expect(result).not.toContain('新潟');
expect(result).toContain('〇〇');
});

test('masks old province names', () => {
const result = maskAnswers('越後平野は広大です', '新潟県');
expect(result).not.toContain('越後');
expect(result).toContain('〇〇');
});

test('masks佐渡 for 新潟県', () => {
const result = maskAnswers('佐渡島は美しい', '新潟県');
expect(result).not.toContain('佐渡');
expect(result).toContain('〇〇');
});

test('does not mask unrelated content', () => {
const result = maskAnswers('米どころとして有名です', '新潟県');
expect(result).toBe('米どころとして有名です');
});
});
Loading
Loading