diff --git a/CODEOWNERS b/CODEOWNERS index b3f7c72b..0021362e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -31,6 +31,7 @@ kirafan/** @settyan117 lyrics/** @hideo54 mahjong/** @hakatashi mail-hook/** @hakatashi +nmpz/** @sh-mug nojoin/** @hakatashi oauth/** @hakatashi octas/** @Yosshi999 diff --git a/index.ts b/index.ts index 6284044e..31704543 100644 --- a/index.ts +++ b/index.ts @@ -114,6 +114,7 @@ const productionBots = [ 'oneiromancy', 'auto-archiver', 'city-symbol', + 'nmpz', ]; const developmentBots = [ diff --git a/nmpz/.eslintrc.json b/nmpz/.eslintrc.json new file mode 100644 index 00000000..f44731bd --- /dev/null +++ b/nmpz/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "plugins": [ + "@typescript-eslint" + ], + "env": { + "node": true, + "es6": true + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "sourceType": "module", + "project": "./tsconfig.json" + }, + "rules": {} +} diff --git a/nmpz/.gitignore b/nmpz/.gitignore new file mode 100644 index 00000000..779197cd --- /dev/null +++ b/nmpz/.gitignore @@ -0,0 +1,2 @@ +coordinates.db +template.html diff --git a/nmpz/.prettierrc.json b/nmpz/.prettierrc.json new file mode 100644 index 00000000..9653d0d5 --- /dev/null +++ b/nmpz/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "bracketSpacing": true +} \ No newline at end of file diff --git a/nmpz/index.ts b/nmpz/index.ts new file mode 100644 index 00000000..4764350e --- /dev/null +++ b/nmpz/index.ts @@ -0,0 +1,383 @@ +import { + ChatPostMessageArguments, + WebClient, +} from "@slack/web-api"; +import { EventEmitter } from 'events'; +import fs from "fs"; +import path from "path"; +import puppeteer from "puppeteer"; +import sqlite3 from "sqlite3"; +import { AteQuizProblem } from "../atequiz"; +import logger from "../lib/logger"; +import type { SlackInterface } from "../lib/slack"; +const { Mutex } = require("async-mutex"); +const { AteQuiz } = require("../atequiz/index.ts"); +const cloudinary = require("cloudinary"); + +const mutex = new Mutex(); + +const API_KEY = process.env.GOOGLE_MAPS_API_KEY; +const CHANNEL = process.env.CHANNEL_SANDBOX; +if (!API_KEY) { + throw new Error("Google Maps API key is missing from .env file."); +} +if (!process.env.CLOUDINARY_URL) { + throw new Error("Cloudinary URL is missing from .env file."); +} + +const postOptions = { + username: "NMPZ-quiz", + icon_emoji: ":rainbolt:", +}; + +interface Coordinate { + pano_id: string; + lat: number; + lng: number; + heading: number; + pitch: number; + country_code: string; +} + +interface Country { + country_code: string; + region: string; + subregion: string; + name_official: string; + name_common: string; +} + +const coordToURL = ({ lat, lng, heading, pitch }: Coordinate) => + `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}&heading=${heading}&pitch=${pitch}`; + +const coordToStr = (lat: number, lng: number) => + `${Math.abs(lat).toFixed(4)}${lat >= 0 ? "N" : "S"}, ${Math.abs(lng).toFixed(4)}${lng >= 0 ? "E" : "W"}`; + +let coordinates: { db: sqlite3.Database }; +const initDatabase = async (dbPath: string) => { + coordinates = { + db: new sqlite3.Database(dbPath) + }; +}; +const getRandomCoordinate = async () => new Promise((resolve, reject) => { + coordinates.db.get("SELECT * FROM coordinates ORDER BY RANDOM() LIMIT 1", (err, row) => { + if (err) { + reject(err); + } else { + resolve(row); + } + }); +}); + +const getCountryName = async (country_code: string): Promise => new Promise((resolve, reject) => { + const url = `https://restcountries.com/v3.1/alpha/${country_code}`; + fetch(url) + .then((response) => response.json()) + .then((data) => { + const country = data[0]; + const region = country.region; + const subregion = country.subregion; + const name_official = country.translations.jpn.common; + const name_common = country.translations.jpn.official; + resolve({ country_code, region, subregion, name_official, name_common }); + }) + .catch((error) => { + reject(error); + }); +}); + +const generateHTML = ({ lat, lng, heading, pitch }: Coordinate): string => ` + + + + + Street View Screenshot + + + + + +
+
+
+
+ + +`; +const saveHTML = async (coord: Coordinate, outputPath = "template.html"): Promise => { + const htmlContent = generateHTML(coord); + const filePath = path.resolve("nmpz", outputPath); + await fs.promises.writeFile(filePath, htmlContent, "utf8"); + logger.info(`HTML file saved at: ${filePath}`); +}; + +async function captureStreetViewScreenshot(coord: Coordinate): Promise { + saveHTML(coord); + + const browser = await puppeteer.launch({ + args: [ + "--no-sandbox", + "--disable-setuid-sandbox" + ], + headless: true, + defaultViewport: { + width: 1920, + height: 1080 + } + }); + const page = await browser.newPage(); + + const url = `file://${process.cwd()}/nmpz/template.html`; + try { + try { + await page.goto(url, { waitUntil: "networkidle0" }); + } + catch (error) { + logger.error("Error loading page:", error); + } + + // upload screenshot to Cloudinary + const result: any = await new Promise((resolve, reject) => { + page.screenshot({ fullPage: true, type: "jpeg", quality: 90 }).then((data) => { + cloudinary.v2.uploader.upload_stream({ resource_type: "image" }, (error: any, result: any) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }).end(data); + }); + }); + await browser.close(); + + return result.secure_url; + } catch (error) { + logger.error("Error capturing screenshot:", error); + throw error; + } +} + +interface NmpzAteQuizProblem extends AteQuizProblem { + answer: string; +} + +class NmpzAteQuiz extends AteQuiz { + static option?: Partial = postOptions; + constructor( + eventClient: EventEmitter, + slack: WebClient, + problem: NmpzAteQuizProblem + ) { + super( + { eventClient: eventClient, webClient: slack }, + problem, + NmpzAteQuiz.option + ); + this.answeredUsers = new Set(); + } + + judge(answer: string, _user: string) { + return answer.toLowerCase() === this.problem.answer.toLowerCase(); + } + + waitSecGen(_hintIndex: number): number { + return 30; + } +} + +async function problemGen(): Promise<[Country, number, number, string, string]> { + const row = await getRandomCoordinate() as Coordinate; + const country_code = row.country_code; + logger.info(row); + const country = await getCountryName(country_code); + logger.info(country); + const img_url = await captureStreetViewScreenshot(row); + logger.info(img_url); + const answer_url = coordToURL(row); + + return [country, row.lat, row.lng, img_url, answer_url]; +} + +function problemFormat( + country: Country, + lat: number, + lng: number, + img_url: string, + answer_url: string, + thread_ts: string +): NmpzAteQuizProblem { + const emoji = `:flag-${country.country_code}:`; + const problem: NmpzAteQuizProblem = { + problemMessage: { + channel: CHANNEL, + thread_ts, + text: `どこの国でしょう?`, + blocks: [ + { + type: "section", + text: { + type: "plain_text", + text: `どこの国でしょう?`, + }, + }, + { + type: "image", + image_url: img_url, + alt_text: "Map cannot be displayed.", + }, + ], + }, + hintMessages: [ + { + channel: CHANNEL, + text: `ヒント: 画像の地域は${country.region}だよ。`, + }, + { + channel: CHANNEL, + text: `ヒント: 画像の地域は${country.subregion}だよ。`, + }, + ], + immediateMessage: { channel: CHANNEL, text: "制限時間: 90秒" }, + solvedMessage: { + channel: CHANNEL, + text: `<@[[!user]]> 正解!:tada: 正解地点は <${answer_url}|${coordToStr(lat, lng)}> だよ ${emoji}`, + reply_broadcast: true, + thread_ts, + unfurl_links: false, + unfurl_media: false, + }, + unsolvedMessage: { + channel: CHANNEL, + text: `残念!:cry: 正解は${country.name_official}、正解地点は <${answer_url}|${coordToStr(lat, lng)}> だよ ${emoji}`, + reply_broadcast: true, + thread_ts, + unfurl_links: false, + unfurl_media: false, + }, + answer: country.name_official, + correctAnswers: [country.name_official, country.name_common], + }; + return problem; +} + +async function prepareProblem( + slack: any, + message: any, + thread_ts: string +) { + await slack.chat.postEphemeral({ + channel: CHANNEL, + text: "問題を生成中...", + user: message.user, + ...postOptions, + }); + + const [country, lat, lng, img_url, answer_url] = await problemGen(); + const problem: NmpzAteQuizProblem = problemFormat(country, lat, lng, img_url, answer_url, thread_ts); + return problem; +} + +export default async ({ eventClient, webClient: slack }: SlackInterface) => { + const dbPath = "nmpz/coordinates.db"; + await initDatabase(dbPath); + eventClient.on("message", async (message) => { + if (message.text === "NMPZ") { + const messageTs = { thread_ts: message.ts }; + + if (mutex.isLocked()) { + slack.chat.postMessage({ + channel: CHANNEL, + text: "今クイズ中だよ:angry:", + ...messageTs, + ...postOptions, + }); + return; + } + const [result, _startTime] = await mutex.runExclusive(async () => { + try { + const arr = await Promise.race([ + (async () => { + const problem: NmpzAteQuizProblem = await prepareProblem(slack, message, message.ts); + const ateQuiz = new NmpzAteQuiz(eventClient, slack, problem); + const st = Date.now(); + const res = await ateQuiz.start(); + + return [res, st]; + })(), + (async () => { + await new Promise((resolve) => { + return setTimeout(resolve, 600 * 1000); + }); + return [null, null, null] as any[]; + })(), + ]); + return arr; + } catch (error) { + logger.error("Error generating NmpzAteQuiz:", error); + throw error; + } + }).catch((error: any): [null, null] => { + mutex.release(); + slack.chat.postMessage({ + channel: CHANNEL, + text: `問題生成中にエラーが発生しました: ${error.message}`, + ...messageTs, + ...postOptions, + }); + return [null, null]; + }); + if (!result) return; + } + }); +};