Skip to content
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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ kill <ngrok-pid> <app-pid>

Note the PIDs when launching (they are printed after the `&` command) and use them to stop only the intended processes.

**After stopping, always verify that the processes are actually terminated** before reporting success. On Cygwin/Windows environments, `pkill` may silently fail to kill native Windows processes. Use the following commands to confirm:

```bash
# Confirm no matching processes remain
ps aux | grep -E "ts-node|ngrok" | grep -v grep

# Confirm the port is no longer listening
netstat -ano | grep ":<PORT>" | grep "LISTENING"
```

Both commands should return empty output. If processes remain, find the PID with `ps aux` and kill them explicitly with `kill <PID>`.

### Hot Reload with ts-node-dev

`npm run dev` uses `ts-node-dev`, which **automatically detects file changes and restarts the server** without any manual intervention. When a `.ts` file is saved, the output looks like:
Expand Down
2 changes: 1 addition & 1 deletion atequiz/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export class AteQuiz {
this.eventClient.on('message', async (message: MessageEvent) => {
const thread_ts = await this.threadTsDeferred.promise;
if ('thread_ts' in message && message.thread_ts === thread_ts) {
if (message.subtype === 'bot_message') return;
if ('bot_id' in message && message.bot_id) return;
if (_option.mode === 'solo' && message.user !== _option.player) return;
this.mutex.runExclusive(async () => {
if (this.state === 'solving') {
Expand Down
215 changes: 118 additions & 97 deletions map-guessr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ import puppeteer from "puppeteer";
import { AteQuizProblem, AteQuizResult } from "../atequiz";
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import: AteQuizResult is imported but never referenced in this file. Please remove it to keep imports clean and avoid confusing readers about expected return types.

Suggested change
import { AteQuizProblem, AteQuizResult } from "../atequiz";
import { AteQuizProblem } from "../atequiz";

Copilot uses AI. Check for mistakes.
import {
ChatPostMessageArguments,
GenericMessageEvent,
WebClient,
} from "@slack/web-api";
import { increment } from "../achievements";
import { ChannelLimitedBot } from "../lib/channelLimitedBot";
import { Deferred } from "../lib/utils";
import { EventEmitter } from 'events';
const { Mutex } = require("async-mutex");
const { AteQuiz } = require("../atequiz/index.ts");
const cloudinary = require("cloudinary");

const API_KEY = process.env.GOOGLE_MAPS_API_KEY;
const CHANNEL = process.env.CHANNEL_SANDBOX;
const mutex = new Mutex();

const img_size = 1000;
Expand Down Expand Up @@ -174,7 +176,6 @@ const mesHelp = {
},
},
],
channel: CHANNEL,
};

function countriesListMessageGen(aliases: Record<string, string[]>): any {
Expand Down Expand Up @@ -212,7 +213,6 @@ function countriesListMessageGen(aliases: Record<string, string[]>): any {
return { type: "section", text: { type: "mrkdwn", text: text } };
}),
],
channel: CHANNEL,
};
return mesCountries;
}
Expand Down Expand Up @@ -582,13 +582,14 @@ function problemFormat(
img_url: string,
latitude: number,
longitude: number,
thread_ts: string
thread_ts: string,
channel: string
) {
const answer = latLngFormat(latitude, longitude);

const problem: CoordAteQuizProblem = {
problemMessage: {
channel: CHANNEL,
channel,
thread_ts,
text: `緯度と経度を当ててね。サイズは${distFormat(size)}四方だよ。`,
blocks: [
Expand All @@ -608,36 +609,36 @@ function problemFormat(
},
hintMessages: [
{
channel: CHANNEL,
channel,
text: `画像の中心点は${country.properties.NAME_JA}にあるよ:triangular_flag_on_post:`,
},
],
immediateMessage: { channel: CHANNEL, text: "制限時間: 300秒" },
immediateMessage: { channel, text: "制限時間: 300秒" },
solvedMessage: {
channel: CHANNEL,
channel,
text: ``,
reply_broadcast: true,
thread_ts,
unfurl_links: false,
unfurl_media: false,
},
incorrectMessage: {
channel: CHANNEL,
channel,
text: ``,
unfurl_links: false,
unfurl_media: false,
},
unsolvedMessage: {
channel: CHANNEL,
channel,
text: `もう、しっかりして!\n中心点の座標は <https://maps.google.co.jp/maps?ll=${latitude},${longitude}&q=${latitude},${longitude}&&t=k|${answer}> だよ:anger:`,
reply_broadcast: true,
thread_ts,
unfurl_links: false,
unfurl_media: false,
},
answer: [latitude, longitude],
zoom: zoom,
size: size,
zoom,
size,
correctAnswers: [] as string[],
};
return problem;
Expand All @@ -647,11 +648,12 @@ async function prepareProblem(
slack: any,
message: any,
aliases: Record<string, string[]>,
world: any,
thread_ts: string
world: Turf.FeatureCollection<Turf.MultiPolygon>,
thread_ts: string,
channel: string
) {
await slack.chat.postEphemeral({
Comment thread
hakatashi marked this conversation as resolved.
channel: CHANNEL,
channel,
text: "問題を生成中...",
user: message.user,
...postOptions,
Expand All @@ -664,7 +666,7 @@ async function prepareProblem(
if (errorText.length > 0) {
await slack.chat.postMessage({
text: errorText,
channel: CHANNEL,
channel,
...postOptions,
});
return;
Expand All @@ -686,106 +688,125 @@ async function prepareProblem(
img_url,
latitude,
longitude,
thread_ts
thread_ts,
channel
);
return problem;
}

export default async ({ eventClient, webClient: slack }: SlackInterface) => {
const aliases = (await fs.readJson(
__dirname + "/country_names.json"
)) as Record<string, string[]>;
class MapGuessr extends ChannelLimitedBot {
protected override readonly wakeWordRegex = /^座標[当あ]て/;

const world = await fs.readJson(__dirname + "/countries.geojson");
eventClient.on("message", async (message) => {
if (
message.channel !== CHANNEL ||
message.thread_ts ||
!(
message.text?.startsWith("座標当て") ||
message.text?.startsWith("座標あて")
)
) {
return;
}
const messageTs = { thread_ts: message.ts };
protected override readonly username = 'coord-quiz';

if (message.text.includes("help")) {
await slack.chat.postMessage({
protected override readonly iconEmoji = ':globe_with_meridians:';

private readonly aliases: Record<string, string[]>;

private readonly world: Turf.FeatureCollection<Turf.MultiPolygon>;

constructor(slackClients: SlackInterface, aliases: Record<string, string[]>, world: Turf.FeatureCollection<Turf.MultiPolygon>) {
super(slackClients);
this.aliases = aliases;
this.world = world;
}

protected override onWakeWord(message: GenericMessageEvent, channel: string): Promise<string | null> {
if (message.text.includes('help')) {
this.postMessage({
...mesHelp,
...postOptions,
...messageTs,
channel,
thread_ts: message.ts,
});
Comment on lines +715 to 720
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onWakeWord posts help output as a threaded reply using thread_ts: message.ts. When ChannelLimitedBot redirects execution to another channel, message.ts belongs to the original channel, so posting with that thread_ts will fail (invalid thread in the target channel). Consider posting help as a normal message in channel (no thread_ts), and return that posted message ts so callers from disallowed channels can be redirected to the right place.

Copilot uses AI. Check for mistakes.
return;
return Promise.resolve(null);
}

if (message.text.includes("countries")) {
await slack.chat.postMessage({
...countriesListMessageGen(aliases),
...postOptions,
...messageTs,
if (message.text.includes('countries')) {
this.postMessage({
...countriesListMessageGen(this.aliases),
channel,
thread_ts: message.ts,
});
return;
return Promise.resolve(null);
}

if (mutex.isLocked()) {
slack.chat.postMessage({
channel: CHANNEL,
text: "今クイズ中だよ:angry:",
...messageTs,
...postOptions,
this.postMessage({
channel,
text: '今クイズ中だよ:angry:',
thread_ts: message.ts,
});
return;
return Promise.resolve(null);
}

const [result, startTime, size] = await mutex.runExclusive(async () => {
const arr = await Promise.race([
(async () => {
const problem: CoordAteQuizProblem = await prepareProblem(
slack,
message,
aliases,
world,
message.ts
);

const ateQuiz = new CoordAteQuiz(eventClient, slack, problem);
const st = Date.now();
const res = await ateQuiz.start();

return [res, st, problem.size];
})(),
(async () => {
await new Promise((resolve) => {
return setTimeout(resolve, 600 * 1000);
});
return [null, null, null] as any[];
})(),
]);
return arr;
const quizMessageDeferred = new Deferred<string | null>();

mutex.runExclusive(async () => {
try {
const problem: CoordAteQuizProblem = await prepareProblem(
this.slack,
message,
this.aliases,
this.world,
message.ts,
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same cross-channel threading issue exists when starting a quiz: prepareProblem is given thread_ts: message.ts, but if the bot is invoked from a non-allowed channel, that ts does not exist in the redirected channel and Slack API calls that rely on it will fail. A more robust pattern is to first post a root message in the target channel (or let AteQuiz post the root without thread_ts) and then use the returned ts as the thread root for the rest of the quiz; return that ts from onWakeWord for ChannelLimitedBot progress-linking.

Suggested change
message.ts,
channel === message.channel ? message.ts : undefined,

Copilot uses AI. Check for mistakes.
channel,
);

if (!problem) {
quizMessageDeferred.resolve(null);
return;
}

const ateQuiz = new CoordAteQuiz(this.eventClient, this.slack, problem);
const startTime = Date.now();
const result = await ateQuiz.start({
mode: 'normal',
onStarted(startMessage: any) {
quizMessageDeferred.resolve(startMessage.ts!);
},
});

await this.deleteProgressMessage(await quizMessageDeferred.promise);

const endTime = Date.now();

if (result.state === 'solved') {
await increment(result.correctAnswerer, 'coord-quiz-easy-answer');
if (problem.size < 20.00001) {
await increment(result.correctAnswerer, 'coord-quiz-professional-answer');
}
if (problem.size <= 100.00001) {
await increment(result.correctAnswerer, 'coord-quiz-hard-answer');
}
if (problem.size <= 500.00001) {
await increment(result.correctAnswerer, 'coord-quiz-medium-answer');
}
if (endTime - startTime <= 30000) {
await increment(result.correctAnswerer, 'coord-quiz-30sec-answer');
}
}
} catch (error: unknown) {
this.log.error('Failed to start map-guessr quiz', error);
const errorText = error instanceof Error && error.stack !== undefined
? error.stack : String(error);
this.postMessage({
channel,
text: `エラー😢\n\`${errorText}\``,
});
quizMessageDeferred.resolve(null);
}
});

const endTime = Date.now();
return quizMessageDeferred.promise;
}
}

if (!result) return;
export default async (slackClients: SlackInterface) => {
const aliases = (await fs.readJson(
__dirname + "/country_names.json"
)) as Record<string, string[]>;

if (result.state === "solved") {
await increment(result.correctAnswerer, "coord-quiz-easy-answer");
if (size < 20.00001) {
await increment(
result.correctAnswerer,
"coord-quiz-professional-answer"
);
}
if (size <= 100.00001) {
await increment(result.correctAnswerer, "coord-quiz-hard-answer");
}
if (size <= 500.00001) {
await increment(result.correctAnswerer, "coord-quiz-medium-answer");
}
if (endTime - startTime <= 30000) {
await increment(result.correctAnswerer, "coord-quiz-30sec-answer");
}
}
});
const world = await fs.readJson(__dirname + "/countries.geojson");

return new MapGuessr(slackClients, aliases, world);
};
Loading