commit 2e88a7d85ae7f613d26fab33dbf7f5a36b36e08b Author: JianMiau Date: Tue Aug 18 11:10:27 2026 +0800 建立建喵打羽球 Bot:移植 Node-RED 報名流程並新增群組記分板 根本原因: 原本的「今日羽球報名」bot 寫在 Node-RED 的 function 節點裡,不易測試與擴充; 羽球團又需要在 Telegram 群組內直接記分、看發球者,並把戰績寫進 web 版共用的 DB。 影響: 同一個 bot(@JianMiauBadmintonBot)改由本專案服務:報名指令、按鈕、暱稱、 週一 10:00 排程、白名單行為與訊息格式皆與 Node-RED 版相同,舊訊息按鈕仍可用; 另新增 /記分(/score、/new)記分板,並在結束/再一局時寫入共用的 history 表。 修法: - signup.js:逐句移植 Node-RED bd_sm 狀態機(attendance / tg_poll / tg_members) - scoreboard.js + match.js + render.js:羽球規則(發球區、換位、Deuce、30 分封頂)、 上排 1 2/下排 4 3 版面、從今日出席選人開局、⏱ 結算、兩段式結束確認、 開局者/管理員權限、scoreList(web 版格式) - history.js:寫入 history 表,欄位與 web 版 ensureHistoryTable 相同 - scripts/nodered-switch.mjs:透過 admin API 暫停/恢復 Node-RED 舊流程 - 單元測試 23 項(規則、報名、選人、結算、權限、歷史寫入),Dockerfile / compose Co-Authored-By: Claude Fable 5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..66d580d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.env +data +*.log +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dfe6e20 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# 從 @BotFather 取得的 Bot Token +BOT_TOKEN=123456789:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# 白名單聊天(逗號分隔 chat_id);不在名單的聊天指令與按鈕都會被擋 +ALLOWED_CHAT_IDS=-1002488036686,-4679836160 + +# 每週自動發報名(cron 語法,時區 Asia/Taipei);留空則不排程 +SIGNUP_CRON=0 10 * * 1 +SIGNUP_CRON_CHAT_ID=-4679836160 + +# 記分板預設目標分數 +TARGET_SCORE=21 +# 記分板狀態持久化檔案 +DATA_FILE=./data/matches.json + +# MariaDB / MySQL(與 badminton-scoreboard 共用) +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=your_password +DB_DATABASE=badminton +DB_HISTORY_TABLE=history + +# Node-RED(舊版報名流程切換用) +NODERED_URL=http://192.168.5.45:1880 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b82b02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +data/*.json +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f71ac6a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY src ./src +COPY scripts ./scripts +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa41fb5 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# tg-shuttle-score — 建喵打羽球 Bot + +Telegram bot「建喵打羽球 @JianMiauBadmintonBot」的 Node.js 版本,包含: + +1. **今日羽球報名**(從 Node-RED「羽球報名」分頁移植,行為、訊息格式、DB 表完全相同) +2. **群組記分板**(新功能):`/記分` 開局,訊息本身就是記分板,用 inline 按鈕記分 + +只在白名單群組運作:`建喵測試 (-1002488036686)`、`羽球團~ (-4679836160)`。 + +## 指令 + +| 指令 | 英文別名 | 說明 | +| --- | --- | --- | +| `/羽球 [YYYYMMDD]` | `/badminton` | 發今日(或指定日期)報名訊息,附「✅ 參加 / ❌ 不參加」按鈕 | +| `/羽球名單` | `/bdlist` | 回今天的參加名單 | +| `/羽球暱稱 名字` | `/bdname` | 設定報名顯示的名字(存 `tg_members`,綁 user_id) | +| `/記分` | `/score`、`/new` | 列出今天出席的人(`attendance`),依 1→4 點選上場人員:1、2 一隊,3、4 一隊;點滿 4 人自動開局,選 2 人可按「單打開始」 | +| `/記分 小明 小華 vs 阿強 阿美` | `/score`、`/new` | 直接開一場雙打記分板(單打各 1 人;最後加數字可改目標分) | +| `/help` | | 指令說明 | + +英文指令已用 `setMyCommands` 註冊(啟動時自動),群組打 `/` 會有選單。 + +### 報名按鈕 + +- 參加 → `INSERT attendance`;不參加 → `DELETE`;重按只跳提示、不動 DB +- 名單直接改在原訊息上;`callback_data` 為 `bd|yes||YYYYMMDD` / `bd|no||YYYYMMDD`,**Node-RED 時期發的舊訊息按鈕仍可用** +- 每週一 10:00(`SIGNUP_CRON`)自動發到正式群 + +### 記分板按鈕 + +- **權限**:只有開局者(下 `/記分` 的人)或群組管理員(creator / administrator,查詢結果快取 5 分鐘)能按;其他人按了只跳提示「只有開局者或群組管理員可以操作記分板」。報名按鈕不受限。 + +- 選人(無參數開局):點名字依序標 1️⃣~4️⃣,`↩️ 取消上一位`、`▶️ 單打開始`(剛好 2 人時)、`✖️ 取消` +- 開局:`🅰️ / 🅱️ 先發球`;雙打可 `🔀 換位` 決定誰站右區(誰先發/先接) +- 進行中(三排):`🅰️ +1` `🅱️ +1` / `↩️ 上一步` `🔄 重新開始` / `⏱ 結算` `🏁 結束` +- `⏱ 結算`:時間到以目前比分定勝負(領先方 🏆、同分平手),可 `上一步` 復原 +- 分出勝負顯示 🏆;`🏁 結束` 需兩段確認(第一次按 → 出現 `✅ 確定結束 / ↩️ 取消`,按其他按鈕也會取消),確定後移除按鈕、鎖定 +- 規則:偶數分右區發、發球方得分同隊換位續發、失分換發不換位、21 分制、Deuce 領先 2 分、30 分封頂(與 badminton-scoreboard 一致) + +## 安裝與啟動 + +```bash +npm install +cp .env.example .env # 填 BOT_TOKEN、DB_*、ALLOWED_CHAT_IDS +npm test # 15 個單元測試(規則、報名流程、選人、指令解析) +npm start # 或 npm run dev +``` + +### ⚠️ 同一個 bot 只能有一個 polling 端 + +Node-RED(`http://192.168.5.45:1880`)的「羽球報名」分頁已於 2026-08-18 停用,由本專案接手。 +若要暫時切回 Node-RED,或反過來,用下列指令(兩邊同時 poll 會互相 409): + +```bash +npm run nodered:status # 看目前狀態 +npm run nodered:pause # 停用 bd_tab + bot 設定改 send-only → 之後 npm start +npm run nodered:resume # 還原 Node-RED(本專案要先停) +``` + +(透過 Node-RED admin API,deploy type = nodes,只重啟有變動的節點。) + +## Docker / NAS + +```bash +sudo docker compose up -d --build +``` + +`docker-compose.yml` 讀 `.env`,狀態檔掛在 `./data`。上線步驟:NAS 起容器 → 確認 log 出現「已啟動」→ `npm run nodered:pause`。 + +## 資料庫 + +與 badminton-scoreboard / Node-RED 共用 `jianmiau.tk:3307/badminton`: + +| 表 | 用途 | +| --- | --- | +| `attendance` | 今日出席(只存要參加的人;PK poll_date+user_id) | +| `history` | 完成的比賽戰績(與 web 版共用,記分板寫入) | +| `tg_poll` | 每天報名訊息的 chat_id / message_id(改暱稱後回頭更新用) | +| `tg_members` | `/羽球暱稱` 設的名字,綁 user_id | + +記分板狀態存 `DATA_FILE`(JSON),重啟後舊訊息按鈕仍可用。 + +**歷史戰績**:分出勝負(打到目標分或 `⏱ 結算`)且有得分的比賽,在按 `✅ 確定結束` 或 `🔄 再一局` 時寫入 `history` 表(`DB_HISTORY_TABLE`,預設 `history`,欄位與 web 版相同),web 版「歷史戰績」頁直接看得到。未結算就結束的比賽不會存(確認提示會寫明)。寫入失敗時不會結束,可再按一次重試。 +- `players = [1號,2號,3號,4號]`、`team = [[1,2],[3,4]]`、`score = [🅰️,🅱️]`、`type` 0 雙打/1 單打、`winScore` 目標分、`dayOfWeek` 台北時區 +- 單打 `players` 存 `[p1,p1,p3,p3]`(web 歷史頁用 `players[starter]` 顯示發球者) + +每一分都會累積 `scoreList`(不顯示在 TG,供之後寫 `history` 用),格式沿用 web 版:`[round, starter, winCount, winner]` +- `round` 第幾分(0 起算) +- `starter` 發球位置編號 0~3 = 畫面 1~4 號位減 1(🅰️ 上排:偶數分 0、奇數分 1;🅱️ 下排:偶數分 2、奇數分 3;只看位置不看人) +- `winCount` 同隊連續得分數,第一分為 0 +- `winner` 得分隊 0 = 🅰️、1 = 🅱️ +- 上一步會一併回退;重新開始清空;結算不影響 + +## 專案結構 + +``` +src/ + index.js 進入點:白名單、指令註冊、callback 分流、排程 + config.js .env 讀取 + db.js mysql2 連線池 + util.js 共用小工具(HTML 跳脫、日期、指令 regex) + signup.js 報名功能(移植自 Node-RED bd_sm 狀態機) + scoreboard.js 記分板功能 + match.js 羽球規則(純邏輯) + render.js 記分板文字 + 鍵盤 + store.js 記分板狀態持久化 +scripts/ + nodered-switch.mjs 暫停 / 恢復 Node-RED 舊流程 +test/ + match.test.js signup.test.js +``` diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..acb2649 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + tg-shuttle-score: + build: . + container_name: tg-shuttle-score + restart: unless-stopped + env_file: .env + environment: + - TZ=Asia/Taipei + - DATA_FILE=/app/data/matches.json + volumes: + - ./data:/app/data diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..21081c3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2578 @@ +{ + "name": "tg-shuttle-score", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tg-shuttle-score", + "version": "0.1.0", + "dependencies": { + "dotenv": "^17.4.2", + "mysql2": "^3.23.3", + "node-cron": "^4.6.0", + "node-telegram-bot-api": "^0.66.0" + } + }, + "node_modules/@cypress/request": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", + "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~4.0.4", + "http-signature": "~1.4.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "performance-now": "^2.1.0", + "qs": "~6.14.1", + "safe-buffer": "^5.1.2", + "tough-cookie": "^5.0.0", + "tunnel-agent": "^0.6.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@cypress/request-promise": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@cypress/request-promise/-/request-promise-5.0.0.tgz", + "integrity": "sha512-eKdYVpa9cBEw2kTBlHeu1PP16Blwtum6QHg/u9s/MoHkZfuo1pRGka1VlUHXF5kdew82BvOJVVGk0x8X0nbp+w==", + "license": "ISC", + "dependencies": { + "bluebird": "^3.5.0", + "request-promise-core": "1.1.3", + "stealthy-require": "^1.1.1", + "tough-cookie": "^4.1.3" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "@cypress/request": "^3.0.0" + } + }, + "node_modules/@cypress/request-promise/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findindex": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/array.prototype.findindex/-/array.prototype.findindex-2.2.4.tgz", + "integrity": "sha512-LLm4mhxa9v8j0A/RPnpQAP4svXToJFh+Hp1pNYl5ZD5qpB4zdx/D4YjpVcETkhFbUKWO3iGMVLvrOnnmkAJT6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT", + "peer": true + }, + "node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-signature": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", + "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^2.0.2", + "sshpk": "^1.18.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "peer": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/jsprim": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", + "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.23.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.3.tgz", + "integrity": "sha512-ehp9HEKr4wVJaBOUVxNFa+CNrsCCCZ6363/jbGhb7WpEmSRNIXjHBjFs5K2s2cXn7j/RhDieejsQJ6nfUwD6vQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/node-cron": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz", + "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==", + "license": "ISC", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-telegram-bot-api": { + "version": "0.66.0", + "resolved": "https://registry.npmjs.org/node-telegram-bot-api/-/node-telegram-bot-api-0.66.0.tgz", + "integrity": "sha512-s4Hrg5q+VPl4/tJVG++pImxF6eb8tNJNj4KnDqAOKL6zGU34lo9RXmyAN158njwGN+v8hdNf8s9fWIYW9hPb5A==", + "license": "MIT", + "dependencies": { + "@cypress/request": "^3.0.1", + "@cypress/request-promise": "^5.0.0", + "array.prototype.findindex": "^2.0.2", + "bl": "^1.2.3", + "debug": "^3.2.7", + "eventemitter3": "^3.0.0", + "file-type": "^3.9.0", + "mime": "^1.6.0", + "pump": "^2.0.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request-promise-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", + "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", + "license": "ISC", + "dependencies": { + "lodash": "^4.17.15" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "request": "^2.34" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/request/node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "peer": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/request/node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "peer": true, + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==", + "license": "ISC", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d4bed1 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "tg-shuttle-score", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "建喵打羽球 Telegram Bot:今日羽球報名 + 群組記分板", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "test": "node --test", + "nodered:pause": "node scripts/nodered-switch.mjs pause", + "nodered:resume": "node scripts/nodered-switch.mjs resume", + "nodered:status": "node scripts/nodered-switch.mjs status" + }, + "dependencies": { + "dotenv": "^17.4.2", + "mysql2": "^3.23.3", + "node-cron": "^4.6.0", + "node-telegram-bot-api": "^0.66.0" + } +} diff --git a/scripts/nodered-switch.mjs b/scripts/nodered-switch.mjs new file mode 100644 index 0000000..6234b2d --- /dev/null +++ b/scripts/nodered-switch.mjs @@ -0,0 +1,52 @@ +// 切換 Node-RED 舊版「羽球報名」流程的開關(同一個 bot 只能有一個 polling 端) +// +// node scripts/nodered-switch.mjs pause # 停用 bd_tab、bot 設定改 send-only → 讓本專案接手 +// node scripts/nodered-switch.mjs resume # 還原 Node-RED polling(本專案要先停) +// node scripts/nodered-switch.mjs status +// +// 用 Node-RED admin API(http://:1880/flows),deploy type = nodes(只重啟有變動的節點)。 +import 'dotenv/config' + +const BASE = (process.env.NODERED_URL || 'http://192.168.5.45:1880').replace(/\/$/, '') +const TAB_ID = process.env.NODERED_BD_TAB || 'bd_tab' +const BOT_CONFIG_ID = process.env.NODERED_BD_BOT || 'tgbot_bd_config' +const mode = process.argv[2] + +if (!['pause', 'resume', 'status'].includes(mode)) { + console.error('用法:node scripts/nodered-switch.mjs pause|resume|status') + process.exit(1) +} + +const headers = { 'Node-RED-API-Version': 'v2', 'Content-Type': 'application/json' } +const res = await fetch(`${BASE}/flows`, { headers }) +if (!res.ok) throw new Error(`GET /flows 失敗:${res.status}`) +const { rev, flows } = await res.json() + +const tab = flows.find((n) => n.id === TAB_ID) +const bot = flows.find((n) => n.id === BOT_CONFIG_ID) +if (!tab || !bot) throw new Error(`找不到 ${TAB_ID} 或 ${BOT_CONFIG_ID}`) + +const show = () => + console.log(`Node-RED ${BASE}\n ${TAB_ID}: ${tab.disabled ? '停用' : '啟用'}\n ${BOT_CONFIG_ID}.updatemode: ${bot.updatemode}`) + +if (mode === 'status') { + show() + process.exit(0) +} + +if (mode === 'pause') { + tab.disabled = true + bot.updatemode = 'none' +} else { + tab.disabled = false + bot.updatemode = 'polling' +} + +const post = await fetch(`${BASE}/flows`, { + method: 'POST', + headers: { ...headers, 'Node-RED-Deployment-Type': 'nodes' }, + body: JSON.stringify({ rev, flows }), +}) +if (!post.ok) throw new Error(`POST /flows 失敗:${post.status} ${await post.text()}`) +console.log(`✅ 已${mode === 'pause' ? '暫停' : '恢復'} Node-RED 羽球報名流程(rev ${(await post.json()).rev.slice(0, 8)})`) +show() diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..45e1646 --- /dev/null +++ b/src/config.js @@ -0,0 +1,30 @@ +import 'dotenv/config' + +const env = process.env + +export const config = { + botToken: env.BOT_TOKEN ?? '', + allowedChatIds: new Set( + (env.ALLOWED_CHAT_IDS ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ), + signupCron: (env.SIGNUP_CRON ?? '').trim(), + signupCronChatId: (env.SIGNUP_CRON_CHAT_ID ?? '').trim(), + targetScore: Number(env.TARGET_SCORE) || 21, + dataFile: env.DATA_FILE || './data/matches.json', + db: { + host: env.DB_HOST ?? '', + port: Number(env.DB_PORT) || 3306, + user: env.DB_USER ?? '', + password: env.DB_PASSWORD ?? '', + database: env.DB_DATABASE ?? '', + historyTable: env.DB_HISTORY_TABLE || 'history', + }, + timezone: 'Asia/Taipei', +} + +export function isChatAllowed(chatId) { + return config.allowedChatIds.has(String(chatId)) +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..9d4b802 --- /dev/null +++ b/src/db.js @@ -0,0 +1,27 @@ +// MariaDB 連線池(與 badminton-scoreboard / Node-RED 共用同一顆 DB) +import mysql from 'mysql2/promise' +import { config } from './config.js' + +let pool = null + +export function getPool() { + if (!pool) { + pool = mysql.createPool({ + host: config.db.host, + port: config.db.port, + user: config.db.user, + password: config.db.password, + database: config.db.database, + timezone: '+08:00', + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: 5, + }) + } + return pool +} + +export async function query(sql, params = []) { + const [rows] = await getPool().execute(sql, params) + return rows +} diff --git a/src/history.js b/src/history.js new file mode 100644 index 0000000..32cd1de --- /dev/null +++ b/src/history.js @@ -0,0 +1,71 @@ +// 把完成的比賽寫入 badminton-scoreboard 共用的 history 表 +// 欄位與 web 版 server/server.mjs ensureHistoryTable / POST /api/history 相同: +// time INT(unix 秒)、dayOfWeek、score "[A,B]"、winScore、type(0 雙打 / 1 單打)、 +// players "[1號,2號,3號,4號]"、team "[[1,2],[3,4]]"、scoreList "[[round,starter,winCount,winner],...]" +// 單打:players 存 [p1, p1, p3, p3],因為 web 歷史頁用 players[starter] 顯示發球者, +// starter 是位置編號 0~3,這樣單打的每一分都能對到正確的人。 +import { query as dbQuery } from './db.js' +import { config } from './config.js' +import { isDoubles } from './match.js' + +const DDL = (table) => ` + CREATE TABLE IF NOT EXISTS \`${table}\` ( + id INT(11) NOT NULL AUTO_INCREMENT, + time INT(11) NOT NULL COMMENT '記錄時間', + dayOfWeek INT(1) NOT NULL COMMENT '星期', + score VARCHAR(255) NOT NULL COMMENT '隊伍分數 [ [隊伍1分數], [隊伍2分數] ]', + winScore INT(2) NOT NULL COMMENT '幾分獲勝', + type INT(1) NOT NULL COMMENT '遊戲類型(0:雙打,1:單打)', + players VARCHAR(255) NOT NULL COMMENT '玩家', + team VARCHAR(255) NOT NULL COMMENT '玩家隊伍 [ [隊伍1成員], [隊伍2成員] ]', + scoreList TEXT DEFAULT NULL COMMENT '得分過程[round, starter, winCount, winner]', + PRIMARY KEY (id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci +` + +// 台北時區的星期(0 = 日),與 web 版 new Date().getDay() 一致 +export function taipeiDayOfWeek(now = new Date()) { + const local = new Date(now.toLocaleString('en-US', { timeZone: config.timezone })) + return local.getDay() +} + +export function buildHistoryPayload(match, now = new Date()) { + const [a, b] = match.teams.map((team) => team.players) + const doubles = isDoubles(match) + return { + time: Math.floor(now.getTime() / 1000), + dayOfWeek: taipeiDayOfWeek(now), + score: [match.score[0], match.score[1]], + winScore: match.target, + type: doubles ? 0 : 1, + players: doubles ? [a[0], a[1], b[0], b[1]] : [a[0], a[0], b[0], b[0]], + team: [[...a], [...b]], + scoreList: (match.scoreList ?? []).map((entry) => [...entry]), + } +} + +let ensured = false + +// 回傳新增的 history.id +export async function saveHistory(match, { query = dbQuery, table = config.db.historyTable, now = new Date() } = {}) { + if (!ensured) { + await query(DDL(table)) + ensured = true + } + const p = buildHistoryPayload(match, now) + const result = await query( + `INSERT INTO \`${table}\` (time, dayOfWeek, score, winScore, type, players, team, scoreList) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + p.time, + p.dayOfWeek, + JSON.stringify(p.score), + p.winScore, + p.type, + JSON.stringify(p.players), + JSON.stringify(p.team), + JSON.stringify(p.scoreList), + ], + ) + return result?.insertId ?? null +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..5dfc1f1 --- /dev/null +++ b/src/index.js @@ -0,0 +1,131 @@ +// 建喵打羽球 Bot 進入點:報名(signup)+ 記分板(scoreboard) +import TelegramBot from 'node-telegram-bot-api' +import cron from 'node-cron' +import { config, isChatAllowed } from './config.js' +import { commandRegex } from './util.js' +import { createSignup, CALLBACK_PREFIX as SIGNUP_CB, SIGNUP_HELP } from './signup.js' +import { createScoreboard, CALLBACK_PREFIX as SCORE_CB, SCOREBOARD_HELP } from './scoreboard.js' + +if (!config.botToken) { + console.error('缺少 BOT_TOKEN,請先設定 .env') + process.exit(1) +} +if (!config.db.host) { + console.error('缺少 DB_HOST 等資料庫設定,請先設定 .env') + process.exit(1) +} + +const log = { + info: (...args) => console.log(new Date().toISOString(), ...args), + warn: (...args) => console.warn(new Date().toISOString(), ...args), + error: (...args) => console.error(new Date().toISOString(), ...args), +} + +const bot = new TelegramBot(config.botToken, { polling: { interval: 300 } }) +const signup = createSignup(bot, { log }) +const scoreboard = createScoreboard(bot, { + dataFile: config.dataFile, + targetScore: config.targetScore, + log, + loadAttendees: () => signup.attendeeNames(), +}) + +const HELP_TEXT = [ + '🏸 建喵打羽球 指令:', + '', + '【報名】', + ...SIGNUP_HELP, + '', + '【記分板】', + ...SCOREBOARD_HELP, +].join('\n') + +const START_TEXT = + '🏸 嗨!我是建喵打羽球。在群組打 /羽球 就會發今天的報名按鈕,/記分 可開記分板,輸入 /help 看全部指令。' + +// 指令包裝:白名單檢查 + 錯誤攔截 +function guard(handler) { + return async (msg, match) => { + if (!isChatAllowed(msg.chat.id)) return + try { + await handler(msg, match) + } catch (error) { + log.error('指令處理失敗:', msg.text, error) + bot + .sendMessage(msg.chat.id, '⚠️ 處理失敗,請稍後再試', { reply_to_message_id: msg.message_id }) + .catch(() => {}) + } + } +} + +async function main() { + const me = await bot.getMe() + const username = me.username + log.info(`🏸 @${username} 啟動中… 白名單:${[...config.allowedChatIds].join(', ') || '(無)'}`) + + bot.onText(commandRegex('start', username), guard((msg) => bot.sendMessage(msg.chat.id, START_TEXT))) + bot.onText(commandRegex('help', username), guard((msg) => bot.sendMessage(msg.chat.id, HELP_TEXT))) + signup.registerCommands(username, { guard }) + scoreboard.registerCommands(username, { guard }) + + // 不在白名單的聊天:只對指令回一句提示,其餘無視 + bot.on('message', (msg) => { + if (isChatAllowed(msg.chat.id)) return + if (!/^\//.test(msg.text ?? '')) return + log.warn(`未授權聊天嘗試使用: ${msg.chat.id} (${msg.chat.title || msg.chat.type})`) + bot + .sendMessage(msg.chat.id, '🙅 這個 bot 只在指定的羽球群組使用喔', { reply_to_message_id: msg.message_id }) + .catch(() => {}) + }) + + bot.on('callback_query', async (cq) => { + if (!cq.message || !cq.data) return + if (!isChatAllowed(cq.message.chat.id)) { + bot + .answerCallbackQuery(cq.id, { text: '這個 bot 只在指定的羽球群組使用喔', show_alert: true }) + .catch(() => {}) + return + } + try { + if (cq.data.startsWith(SIGNUP_CB)) await signup.rsvp(cq) + else if (cq.data.startsWith(SCORE_CB)) await scoreboard.onCallback(cq) + else await bot.answerCallbackQuery(cq.id) + } catch (error) { + log.error('按鈕處理失敗:', cq.data, error) + bot.answerCallbackQuery(cq.id, { text: '⚠️ 處理失敗,請稍後再試', show_alert: true }).catch(() => {}) + } + }) + + bot.on('polling_error', (error) => log.error('polling_error:', error.message)) + + // 群組打 / 會出現的選單(Telegram 只接受英文小寫指令) + await bot.setMyCommands([ + { command: 'badminton', description: '發今日羽球報名(可加 YYYYMMDD)' }, + { command: 'bdlist', description: '看今天的報名名單' }, + { command: 'bdname', description: '設定報名顯示的名字:/bdname 建喵' }, + { command: 'score', description: '開記分板(無參數=從今日出席選人)' }, + { command: 'new', description: '同 /score:/new 小明 小華 vs 阿強 阿美' }, + { command: 'help', description: '指令說明' }, + ]) + + // 每週自動發報名 + if (config.signupCron && config.signupCronChatId) { + if (!cron.validate(config.signupCron)) { + log.warn(`SIGNUP_CRON 格式不正確:${config.signupCron},已略過排程`) + } else { + cron.schedule( + config.signupCron, + () => signup.post(config.signupCronChatId).catch((error) => log.error('排程發報名失敗:', error)), + { timezone: config.timezone }, + ) + log.info(`排程已啟用:${config.signupCron} (${config.timezone}) → ${config.signupCronChatId}`) + } + } + + log.info(`✅ @${username} 已啟動(記分板進行中 ${scoreboard.store.size()} 場)`) +} + +main().catch((error) => { + log.error('啟動失敗:', error) + process.exit(1) +}) diff --git a/src/match.js b/src/match.js new file mode 100644 index 0000000..0247742 --- /dev/null +++ b/src/match.js @@ -0,0 +1,219 @@ +// 羽球比賽狀態與規則(純邏輯,不依賴 Telegram) +// +// 狀態結構: +// { +// teams: [{ players: ['小明', '小華'], right: 0 }, { players: [...], right: 0 }], +// - players: 1 人 = 單打,2 人 = 雙打 +// - right: 目前站在「自己右發球區」的隊員 index(0 或 1),僅雙打有意義 +// score: [0, 0], +// server: null | 0 | 1, // 目前發球隊,null = 尚未決定先發 +// target: 21, +// finished: false, +// settled: false, // true = 時間到提前結算(以目前比分定勝負) +// scoreList: [[round, starter, winCount, winner], ...], +// - 得分歷程,格式沿用 web 版 history.scoreList(不顯示在 TG,寫 DB 用) +// - round:第幾分(0 起算) +// - starter:發球「位置」編號 0~3(= 畫面 1~4 號位減 1): +// 🅰️ 上排發球:偶數分 0(1 號位)、奇數分 1(2 號位) +// 🅱️ 下排發球:偶數分 2(3 號位)、奇數分 3(4 號位) +// - winCount:同隊連續得分數,第一分為 0 +// - winner:得分隊 0 = 🅰️、1 = 🅱️ +// history: [snapshot, ...], // 供上一步復原 +// } + +const MAX_SCORE = 30 + +export function createMatch(teamAPlayers, teamBPlayers, target = 21) { + return { + teams: [ + { players: [...teamAPlayers], right: 0 }, + { players: [...teamBPlayers], right: 0 }, + ], + score: [0, 0], + server: null, + target, + finished: false, + settled: false, + scoreList: [], + history: [], + } +} + +export function isDoubles(match) { + return match.teams[0].players.length === 2 +} + +// 偶數分右區發球、奇數分左區發球 +export function getServiceCourt(score) { + return score % 2 === 0 ? 'right' : 'left' +} + +function getPlayerOnCourt(team, court) { + if (team.players.length === 1) return team.players[0] + const index = court === 'right' ? team.right : 1 - team.right + return team.players[index] +} + +// 目前發球者 / 接發者;尚未決定先發時回傳 null +export function getServing(match) { + if (match.server === null) return null + const serverTeam = match.server + const receiverTeam = 1 - serverTeam + const court = getServiceCourt(match.score[serverTeam]) + return { + team: serverTeam, + court, + server: getPlayerOnCourt(match.teams[serverTeam], court), + receiver: getPlayerOnCourt(match.teams[receiverTeam], court), + } +} + +// 目前這一分的發球位置編號(web 版 getServerHistoryIndex) +export function getStarterIndex(match) { + if (match.server === null) return null + const even = match.score[match.server] % 2 === 0 + return match.server === 0 ? (even ? 0 : 1) : even ? 2 : 3 +} + +export function hasWonGame(match) { + const [a, b] = match.score + const leading = Math.max(a, b) + const trailing = Math.min(a, b) + if (leading < match.target) return false + if (leading >= MAX_SCORE) return true + if (trailing >= match.target - 1) return leading - trailing >= 2 + return true +} + +export function getWinner(match) { + if (match.settled) { + if (match.score[0] === match.score[1]) return null + return match.score[0] > match.score[1] ? 0 : 1 + } + if (!hasWonGame(match)) return null + return match.score[0] > match.score[1] ? 0 : 1 +} + +function snapshot(match) { + return { + teams: match.teams.map((team) => ({ players: [...team.players], right: team.right })), + score: [...match.score], + server: match.server, + finished: match.finished, + settled: match.settled, + scoreList: (match.scoreList ?? []).map((entry) => [...entry]), + } +} + +function pushHistory(match) { + match.history.push(snapshot(match)) +} + +// 決定先發球隊(只允許在 0:0 時設定) +export function setFirstServer(match, teamIndex) { + if (match.score[0] !== 0 || match.score[1] !== 0) return false + match.server = teamIndex + return true +} + +// 交換某隊左右站位(只允許在 0:0 時,用來調整誰先發 / 先接) +export function swapCourt(match, teamIndex) { + if (!isDoubles(match)) return false + if (match.score[0] !== 0 || match.score[1] !== 0) return false + match.teams[teamIndex].right = 1 - match.teams[teamIndex].right + return true +} + +// 某隊得分 +export function addPoint(match, teamIndex) { + if (match.finished || match.server === null) return false + pushHistory(match) + + // 得分歷程(在改變狀態前記下這一分的發球位置) + const starter = getStarterIndex(match) + const list = (match.scoreList ??= []) + const last = list[list.length - 1] + const winCount = last && last[3] === teamIndex ? last[2] + 1 : 0 + list.push([list.length, starter, winCount, teamIndex]) + + // 發球方得分:雙打時同隊兩人交換發球區,繼續由同隊發球 + if (match.server === teamIndex && isDoubles(match)) { + match.teams[teamIndex].right = 1 - match.teams[teamIndex].right + } + match.server = teamIndex + match.score[teamIndex] += 1 + + if (hasWonGame(match)) { + match.finished = true + } + return true +} + +// 時間到提前結算:以目前比分定勝負(可用 undo 復原) +export function settleMatch(match) { + if (match.finished || match.server === null) return false + pushHistory(match) + match.finished = true + match.settled = true + return true +} + +export function undo(match) { + const last = match.history.pop() + if (!last) return false + match.teams = last.teams + match.score = last.score + match.server = last.server + match.finished = last.finished + match.settled = last.settled ?? false + match.scoreList = last.scoreList ?? [] + return true +} + +// 重新開始這一局(保留隊員與目標分數) +export function resetMatch(match) { + match.score = [0, 0] + match.server = null + match.finished = false + match.settled = false + match.scoreList = [] + match.history = [] + match.teams.forEach((team) => { + team.right = 0 + }) +} + +// 解析 "/new 小明 小華 vs 阿強 阿美 [21]" 之類的輸入 +// 隊員分隔:空白、/、,、,、、 兩隊分隔:vs / VS / 對 +export function parseTeams(input, defaultTarget = 21) { + const raw = (input ?? '').trim() + if (!raw) return { error: '請輸入隊員名單' } + + const tokens = raw.split(/\s+/) + let target = defaultTarget + if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) { + target = Number(tokens.pop()) + if (target < 1 || target > MAX_SCORE) { + return { error: `目標分數需介於 1 ~ ${MAX_SCORE}` } + } + } + + const sides = tokens.join(' ').split(/\s*(?:\bvs\b|對)\s*/i) + if (sides.length !== 2) return { error: '請用 vs 分隔兩隊,例如:/new 小明 小華 vs 阿強 阿美' } + + const teams = sides.map((side) => + side + .split(/[\s/,,、]+/) + .map((name) => name.trim()) + .filter(Boolean), + ) + + if (teams.some((team) => team.length < 1 || team.length > 2)) { + return { error: '每隊需 1 人(單打)或 2 人(雙打)' } + } + if (teams[0].length !== teams[1].length) { + return { error: '兩隊人數需相同(都是 1 人或都是 2 人)' } + } + + return { teams, target } +} diff --git a/src/render.js b/src/render.js new file mode 100644 index 0000000..70b1d21 --- /dev/null +++ b/src/render.js @@ -0,0 +1,151 @@ +// 把比賽狀態轉成 Telegram 訊息文字(HTML)與 inline 鍵盤 +import { getServing, getWinner, isDoubles } from './match.js' +import { escapeHtml } from './util.js' + +const TEAM_ICON = ['🅰️', '🅱️'] +const COURT_LABEL = { right: '右區', left: '左區' } + +// 隊員名單,依「畫面位置」排列(同 web 版:上排 1 2、下排 4 3),發球者前面加 🏸 +// 上排 🅰️ 為鏡像:畫面左 = 自己的右發球區;下排 🅱️:畫面右 = 自己的右發球區 +function renderPlayers(match, teamIndex, serving) { + const team = match.teams[teamIndex] + let ordered = team.players + if (team.players.length === 2) { + const rightPlayer = team.players[team.right] + const leftPlayer = team.players[1 - team.right] + ordered = teamIndex === 0 ? [rightPlayer, leftPlayer] : [leftPlayer, rightPlayer] + } + return ordered + .map((name) => { + const isServer = serving && serving.team === teamIndex && serving.server === name + return `${isServer ? '🏸' : ''}${escapeHtml(name)}` + }) + .join(' / ') +} + +export function renderText(match, { ended = false } = {}) { + const serving = getServing(match) + const winner = getWinner(match) + const lines = [] + + lines.push(`🏸 羽球記分板 ${isDoubles(match) ? '雙打' : '單打'}・${match.target} 分制`) + lines.push('') + + for (const teamIndex of [0, 1]) { + const marker = winner === teamIndex ? ' 🏆' : '' + lines.push( + `${TEAM_ICON[teamIndex]} ${renderPlayers(match, teamIndex, serving)} ${match.score[teamIndex]}${marker}`, + ) + } + lines.push('') + + const hasPoints = (match.scoreList ?? []).length > 0 + if (ended) { + if (match.historyId) lines.push(`⏹ 已結束 📝 已存入歷史戰績 #${match.historyId}`) + else if (hasPoints && !match.finished) lines.push('⏹ 已結束(未結算,未存入戰績)') + else lines.push('⏹ 已結束') + } else if (match.confirmEnd) { + if (match.finished) lines.push('❓ 確定要結束嗎?會把這場寫入歷史戰績') + else if (hasPoints) lines.push('❓ 確定要結束嗎?比賽未結算,不會寫入戰績(要存請先按 ⏱ 結算)') + else lines.push('❓ 確定要結束嗎?') + } else if (match.settled) { + const result = winner !== null ? `${TEAM_ICON[winner]} 獲勝` : '平手' + lines.push(`⏱ 時間到結算 ${match.score[0]} : ${match.score[1]} ${result}`) + } else if (winner !== null) { + lines.push(`🎉 ${TEAM_ICON[winner]} 獲勝! ${match.score[0]} : ${match.score[1]}`) + } else if (!serving) { + lines.push('👉 請先選擇由哪一隊先發球') + } else { + lines.push( + `發球:${TEAM_ICON[serving.team]} ${escapeHtml(serving.server)}(${COURT_LABEL[serving.court]})` + + ` 接發:${escapeHtml(serving.receiver)}`, + ) + } + + return lines.join('\n') +} + +// callback_data 格式:sc: +const btn = (text, action) => ({ text, callback_data: `sc:${action}` }) + +export function renderKeyboard(match, { ended = false } = {}) { + if (ended) return { inline_keyboard: [] } + const rows = buildKeyboardRows(match) + if (match.confirmEnd) { + // 兩段式確認:把「結束」換成「確定結束 / 取消」 + return { + inline_keyboard: rows.map((row) => + row.some((b) => b.callback_data === 'sc:end') + ? [btn('✅ 確定結束', 'end'), btn('↩️ 取消', 'endcancel')] + : row, + ), + } + } + return { inline_keyboard: rows } +} + +function buildKeyboardRows(match) { + + if (match.finished) { + return [ + [btn('↩️ 上一步', 'undo'), btn('🔄 再一局', 'reset')], + [btn('🏁 結束', 'end')], + ] + } + + if (match.server === null) { + const rows = [[btn('🅰️ 先發球', 'srv0'), btn('🅱️ 先發球', 'srv1')]] + if (isDoubles(match)) { + rows.push([btn('🔀 🅰️ 換位', 'swap0'), btn('🔀 🅱️ 換位', 'swap1')]) + } + rows.push([btn('🏁 結束', 'end')]) + return rows + } + + return [ + [btn('🅰️ +1', 'pt0'), btn('🅱️ +1', 'pt1')], + [btn('↩️ 上一步', 'undo'), btn('🔄 重新開始', 'reset')], + [btn('⏱ 結算', 'settle'), btn('🏁 結束', 'end')], + ] +} + +// ---------- 選人(/記分 無參數:從今日出席名單點選 1→4) ---------- +const PICK_NUM = ['1️⃣', '2️⃣', '3️⃣', '4️⃣'] + +export function renderPickerText(picker) { + const slot = (i) => (picker.picked[i] != null ? escapeHtml(picker.names[picker.picked[i]]) : '—') + const lines = [ + `🏸 選擇上場人員 依 1 → 4 順序點選(今日出席 ${picker.names.length} 人)`, + '', + `🅰️ 1️⃣ ${slot(0)} 2️⃣ ${slot(1)}`, + `🅱️ 4️⃣ ${slot(3)} 3️⃣ ${slot(2)}`, + '', + ] + if (picker.picked.length === 0) lines.push('👉 點名字加入;1、2 一隊,3、4 一隊(位置同 web 版:上 1 2/下 4 3);點滿 4 人自動開局') + else if (picker.picked.length === 2) lines.push('👉 繼續點第 3、4 位,或按「單打開始」') + else if (picker.picked.length < 4) lines.push(`👉 繼續點第 ${picker.picked.length + 1} 位`) + return lines.join('\n') +} + +export function renderPickerKeyboard(picker) { + const rows = [] + const perRow = picker.names.length > 8 ? 3 : 2 + let row = [] + picker.names.forEach((name, i) => { + const order = picker.picked.indexOf(i) + const label = order >= 0 ? `${PICK_NUM[order]} ${name}` : name + row.push({ text: label, callback_data: `sc:pick${i}` }) + if (row.length === perRow) { + rows.push(row) + row = [] + } + }) + if (row.length) rows.push(row) + + const controls = [] + if (picker.picked.length > 0) controls.push({ text: '↩️ 取消上一位', callback_data: 'sc:unpick' }) + if (picker.picked.length === 2) controls.push({ text: '▶️ 單打開始', callback_data: 'sc:pickgo' }) + controls.push({ text: '✖️ 取消', callback_data: 'sc:end' }) + rows.push(controls) + return { inline_keyboard: rows } +} diff --git a/src/scoreboard.js b/src/scoreboard.js new file mode 100644 index 0000000..21928c3 --- /dev/null +++ b/src/scoreboard.js @@ -0,0 +1,295 @@ +// 羽球記分板:/記分 開局,訊息本身就是記分板,用 inline 按鈕記分 +// +// - /記分 A B vs C D:直接開局 +// - /記分(無參數):列出今日出席名單按鈕,依 1→4 點選上場人員(1、2 一隊,3、4 一隊) +import { + addPoint, + createMatch, + parseTeams, + resetMatch, + setFirstServer, + settleMatch, + swapCourt, + undo, +} from './match.js' +import { renderKeyboard, renderPickerKeyboard, renderPickerText, renderText } from './render.js' +import { saveHistory as defaultSaveHistory } from './history.js' +import { createStore } from './store.js' +import { commandRegex, isNotModifiedError } from './util.js' + +export const CALLBACK_PREFIX = 'sc:' + +const ACTIONS = { + pt0: (m) => addPoint(m, 0), + pt1: (m) => addPoint(m, 1), + srv0: (m) => setFirstServer(m, 0), + srv1: (m) => setFirstServer(m, 1), + swap0: (m) => swapCourt(m, 0), + swap1: (m) => swapCourt(m, 1), + undo: (m) => undo(m), + settle: (m) => settleMatch(m), + // reset(再一局)在 onCallback 另外處理:要先存歷史戰績 +} + +const ACTION_FAIL_HINT = { + undo: '沒有可以復原的步驟', + pt0: '請先選擇先發球隊', + pt1: '請先選擇先發球隊', + settle: '還沒開始比賽', +} + +export const SCOREBOARD_HELP = [ + '/記分 - 列出今天出席的人,依 1→4 點選上場人員(1、2 一隊,3、4 一隊)', + '/記分 小明 小華 vs 阿強 阿美 - 直接開一場雙打', + '/記分 小明 vs 阿強 - 開一場單打', + '/記分 小明 vs 阿強 15 - 最後加數字可改目標分數(預設 21)', + '英文別名:/score、/new', + '只有開局者或群組管理員能按記分板按鈕。', + '開局後按訊息下方按鈕:先選哪隊先發球(雙打可先「換位」),🅰️ +1 / 🅱️ +1 加分,🏸 標記發球者,↩️ 上一步可復原,⏱ 結算=時間到以目前比分定勝負,🏁 結束會鎖住記分板。', +] + +const ADMIN_CACHE_MS = 5 * 60 * 1000 +const NO_PERMISSION_HINT = '只有開局者或群組管理員可以操作記分板' + +// loadAttendees: async () => ['名字', ...](今日出席名單) +// isAdmin: async (chatId, userId) => boolean(預設用 getChatMember 判斷 creator / administrator,快取 5 分鐘) +// saveHistory: async (match) => history.id(預設寫入共用 DB 的 history 表) +export function createScoreboard( + bot, + { dataFile, targetScore = 21, log = console, loadAttendees = null, isAdmin = null, saveHistory = defaultSaveHistory }, +) { + const store = createStore(dataFile) + + // 分出勝負(含結算)且有得分的比賽才存;回傳 true 表示可以繼續往下(結束 / 再一局) + async function persistIfFinished(cq, match) { + if (!match.finished || !(match.scoreList ?? []).length || match.historyId) return true + try { + match.historyId = await saveHistory(match) + log.info(`[score] history saved id=${match.historyId} ${JSON.stringify(match.teams.map((t) => t.players))} ${match.score.join(':')}`) + return true + } catch (error) { + log.error('寫入 history 失敗:', error) + await bot.answerCallbackQuery(cq.id, { text: '⚠️ 寫入歷史戰績失敗,請稍後再試一次', show_alert: true }) + return false + } + } + const adminCache = new Map() // `${chatId}:${userId}` -> { value, at } + + async function defaultIsAdmin(chatId, userId) { + const key = `${chatId}:${userId}` + const cached = adminCache.get(key) + if (cached && Date.now() - cached.at < ADMIN_CACHE_MS) return cached.value + let value = false + try { + const member = await bot.getChatMember(chatId, userId) + value = member?.status === 'creator' || member?.status === 'administrator' + } catch (error) { + log.warn('getChatMember 失敗:', error.message) + } + adminCache.set(key, { value, at: Date.now() }) + return value + } + const checkAdmin = isAdmin ?? defaultIsAdmin + + // 只有開局者或群組管理員能操作;舊資料沒有 ownerId 則不限制 + async function canOperate(cq, entry) { + if (entry.ownerId == null) return true + if (cq.from?.id === entry.ownerId) return true + return checkAdmin(cq.message.chat.id, cq.from?.id) + } + + async function editMessage(chatId, messageId, text, replyMarkup) { + try { + await bot.editMessageText(text, { + chat_id: chatId, + message_id: messageId, + parse_mode: 'HTML', + reply_markup: replyMarkup, + }) + } catch (error) { + if (!isNotModifiedError(error)) throw error + } + } + + const editBoard = (chatId, messageId, match, options = {}) => + editMessage(chatId, messageId, renderText(match, options), renderKeyboard(match, options)) + + const editPicker = (chatId, messageId, picker) => + editMessage(chatId, messageId, renderPickerText(picker), renderPickerKeyboard(picker)) + + // 無參數:從今日出席名單選人 + async function startPicker(chatId, replyTo, ownerId) { + const names = loadAttendees ? await loadAttendees() : [] + if (names.length < 2) { + await bot.sendMessage( + chatId, + names.length === 0 + ? '今天還沒有人報名,請先用 /羽球 報名,或直接:/記分 小明 小華 vs 阿強 阿美' + : '今天只有 1 人報名,請直接:/記分 小明 小華 vs 阿強 阿美', + { reply_to_message_id: replyTo }, + ) + return + } + const picker = { phase: 'pick', names, picked: [], target: targetScore, ownerId } + const sent = await bot.sendMessage(chatId, renderPickerText(picker), { + parse_mode: 'HTML', + reply_markup: renderPickerKeyboard(picker), + }) + store.set(chatId, sent.message_id, picker) + log.info(`[score] picker chat=${chatId} mid=${sent.message_id} attendees=${names.length}`) + } + + async function start(chatId, replyTo, argsText, ownerId = null) { + if (!argsText || !argsText.trim()) { + await startPicker(chatId, replyTo, ownerId) + return + } + const parsed = parseTeams(argsText, targetScore) + if (parsed.error) { + await bot.sendMessage(chatId, `⚠️ ${parsed.error}\n\n${SCOREBOARD_HELP.join('\n')}`, { + reply_to_message_id: replyTo, + }) + return + } + const match = createMatch(parsed.teams[0], parsed.teams[1], parsed.target) + match.ownerId = ownerId + const sent = await bot.sendMessage(chatId, renderText(match), { + parse_mode: 'HTML', + reply_markup: renderKeyboard(match), + }) + store.set(chatId, sent.message_id, match) + log.info(`[score] new chat=${chatId} mid=${sent.message_id} ${JSON.stringify(parsed.teams)}`) + } + + // 選人階段的按鈕 + async function onPickerCallback(cq, picker, action) { + const chatId = cq.message.chat.id + const messageId = cq.message.message_id + const answer = (text) => bot.answerCallbackQuery(cq.id, text ? { text } : undefined) + + const startMatch = async () => { + const p = picker.picked.map((i) => picker.names[i]) + const teams = p.length === 4 ? [[p[0], p[1]], [p[2], p[3]]] : [[p[0]], [p[1]]] + const match = createMatch(teams[0], teams[1], picker.target) + match.ownerId = picker.ownerId ?? null + store.set(chatId, messageId, match) + await editBoard(chatId, messageId, match) + log.info(`[score] picker→match chat=${chatId} mid=${messageId} ${JSON.stringify(teams)}`) + } + + if (action.startsWith('pick') && action !== 'pickgo') { + const index = Number(action.slice(4)) + if (!Number.isInteger(index) || index < 0 || index >= picker.names.length) return answer() + if (picker.picked.includes(index)) return answer('這位已經選了') + if (picker.picked.length >= 4) return answer('已選滿 4 人') + picker.picked.push(index) + if (picker.picked.length === 4) { + await startMatch() + return answer('已開局') + } + store.set(chatId, messageId, picker) + await editPicker(chatId, messageId, picker) + return answer() + } + if (action === 'unpick') { + if (!picker.picked.length) return answer('還沒選人') + picker.picked.pop() + store.set(chatId, messageId, picker) + await editPicker(chatId, messageId, picker) + return answer() + } + if (action === 'pickgo') { + if (picker.picked.length !== 2) return answer('單打需要剛好選 2 人') + await startMatch() + return answer('已開局(單打)') + } + return answer() + } + + async function onCallback(cq) { + const chatId = cq.message.chat.id + const messageId = cq.message.message_id + const action = cq.data.slice(CALLBACK_PREFIX.length) + const entry = store.get(chatId, messageId) + + if (!entry) { + await bot.answerCallbackQuery(cq.id, { text: '這場比賽已失效,請重新 /記分', show_alert: true }) + return + } + if (!(await canOperate(cq, entry))) { + await bot.answerCallbackQuery(cq.id, { text: NO_PERMISSION_HINT }) + return + } + + if (entry.phase === 'pick') { + if (action === 'end') { + store.delete(chatId, messageId) + await editMessage(chatId, messageId, '⏹ 已取消選人', { inline_keyboard: [] }) + await bot.answerCallbackQuery(cq.id, { text: '已取消' }) + return + } + await onPickerCallback(cq, entry, action) + return + } + + // 結束要兩段式確認:第一次按 → 問;再按「確定結束」→ 真的結束 + if (action === 'end') { + if (!entry.confirmEnd) { + entry.confirmEnd = true + store.set(chatId, messageId, entry) + await editBoard(chatId, messageId, entry) + await bot.answerCallbackQuery(cq.id, { text: '再按一次「確定結束」才會結束' }) + return + } + if (!(await persistIfFinished(cq, entry))) return + store.delete(chatId, messageId) + delete entry.confirmEnd + await editBoard(chatId, messageId, entry, { ended: true }) + await bot.answerCallbackQuery(cq.id, { text: entry.historyId ? `已結束,戰績 #${entry.historyId} 已存` : '已結束' }) + return + } + if (action === 'endcancel') { + delete entry.confirmEnd + store.set(chatId, messageId, entry) + await editBoard(chatId, messageId, entry) + await bot.answerCallbackQuery(cq.id) + return + } + // 按了其他按鈕就取消確認狀態 + if (entry.confirmEnd) delete entry.confirmEnd + + // 再一局:先把打完的這場存進歷史戰績 + if (action === 'reset') { + if (!(await persistIfFinished(cq, entry))) return + const savedId = entry.historyId + resetMatch(entry) + delete entry.historyId + store.set(chatId, messageId, entry) + await editBoard(chatId, messageId, entry) + await bot.answerCallbackQuery(cq.id, savedId ? { text: `上一場戰績 #${savedId} 已存,開始新的一局` } : undefined) + return + } + + const handler = ACTIONS[action] + if (!handler) { + await bot.answerCallbackQuery(cq.id) + return + } + if (!handler(entry)) { + await bot.answerCallbackQuery(cq.id, { text: ACTION_FAIL_HINT[action] ?? '目前不能執行這個操作' }) + return + } + store.set(chatId, messageId, entry) + await editBoard(chatId, messageId, entry) + await bot.answerCallbackQuery(cq.id) + } + + function registerCommands(botUsername, { guard }) { + bot.onText( + commandRegex('記分|score|new', botUsername, '(?:\\s+([\\s\\S]+))?'), + guard((msg, m) => start(msg.chat.id, msg.message_id, m[1], msg.from?.id ?? null)), + ) + } + + return { start, onCallback, registerCommands, store } +} diff --git a/src/signup.js b/src/signup.js new file mode 100644 index 0000000..3c970bc --- /dev/null +++ b/src/signup.js @@ -0,0 +1,190 @@ +// 今日羽球報名(移植自 Node-RED「羽球報名」分頁的狀態機) +// +// - /羽球 [YYYYMMDD]、/badminton:發今日(或指定日期)報名訊息,附「參加 / 不參加」按鈕 +// - /羽球名單、/bdlist:回今天的名單 +// - /羽球暱稱 名字、/bdname:設定報名顯示的名字(存 tg_members,綁 user_id) +// - 按鈕 callback_data:bd|yes||YYYYMMDD / bd|no||YYYYMMDD(與舊訊息相容) +// - 每週排程自動發到正式群 +import { query as dbQuery } from './db.js' +import { commandRegex, dateLabel, escapeHtml, fromInfo, isNotModifiedError, nowHM, todayKey } from './util.js' + +const SQL_LOAD_RSVP = + 'SELECT user_id, nickname FROM attendance WHERE poll_date = ? ORDER BY joined_at ASC, user_id ASC' +const SQL_LOAD_POLL = 'SELECT chat_id, message_id FROM tg_poll WHERE poll_date = ?' +const SQL_UPSERT_MEMBER = `INSERT INTO tg_members (user_id, tg_name, username) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE tg_name=VALUES(tg_name), username=VALUES(username)` + +export const CALLBACK_PREFIX = 'bd|' + +export function keyboard(date) { + return { + inline_keyboard: [ + [ + { text: '✅ 參加', callback_data: `bd|yes||${date}` }, + { text: '❌ 不參加', callback_data: `bd|no||${date}` }, + ], + ], + } +} + +export function renderPoll(date, rows) { + const names = (rows ?? []).map((row) => row.nickname) + return ( + `🏸 今日羽球 ${dateLabel(date)}\n按下方按鈕報名,可隨時反悔\n\n` + + `✅ 參加 (${names.length}):${names.length ? escapeHtml(names.join('、')) : '—'}` + + `\n\n🕑 更新 ${nowHM()}` + ) +} + +export function createSignup(bot, { log = console, query = dbQuery } = {}) { + async function editPoll(chatId, messageId, date) { + if (!messageId) return + const rows = await query(SQL_LOAD_RSVP, [date]) + try { + await bot.editMessageText(renderPoll(date, rows), { + chat_id: chatId, + message_id: messageId, + parse_mode: 'HTML', + reply_markup: keyboard(date), + }) + } catch (error) { + if (!isNotModifiedError(error)) throw error + } + } + + // 今日出席名單(給記分板選人用) + async function attendeeNames(date = todayKey()) { + const rows = await query(SQL_LOAD_RSVP, [date]) + return rows.map((row) => row.nickname) + } + + // 發報名訊息(/羽球 或排程) + async function post(chatId, date = todayKey()) { + const rows = await query(SQL_LOAD_RSVP, [date]) + const sent = await bot.sendMessage(chatId, renderPoll(date, rows), { + parse_mode: 'HTML', + reply_markup: keyboard(date), + }) + await query( + `INSERT INTO tg_poll (poll_date, chat_id, message_id) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE chat_id=VALUES(chat_id), message_id=VALUES(message_id)`, + [date, String(chatId), sent.message_id], + ) + log.info(`[signup] post date=${date} chat=${chatId} mid=${sent.message_id}`) + return sent + } + + // /羽球名單 + async function list(chatId, replyTo, date = todayKey()) { + const rows = await query(SQL_LOAD_RSVP, [date]) + await bot.sendMessage(chatId, renderPoll(date, rows), { + parse_mode: 'HTML', + reply_to_message_id: replyTo, + }) + } + + // /羽球暱稱 名字 + async function setNickname(chatId, replyTo, from, nickname) { + const date = todayKey() + await query( + `INSERT INTO tg_members (user_id, nickname, tg_name, username) VALUES (?,?,?,?) + ON DUPLICATE KEY UPDATE nickname=VALUES(nickname), tg_name=VALUES(tg_name), username=VALUES(username)`, + [from.id, nickname, from.name, from.username], + ) + await query('UPDATE attendance SET nickname = ? WHERE user_id = ? AND poll_date >= ?', [ + nickname, + from.id, + Number(date), + ]) + await bot.sendMessage(chatId, `✅ 暱稱已設為「${escapeHtml(nickname)}」,之後報名都用這個名字`, { + parse_mode: 'HTML', + reply_to_message_id: replyTo, + }) + // 今天已有報名訊息的話,回頭更新名單 + const [poll] = await query(SQL_LOAD_POLL, [date]) + if (poll?.message_id) await editPoll(poll.chat_id, poll.message_id, date) + } + + // 按「參加 / 不參加」 + async function rsvp(cq) { + const [, choice, , date] = cq.data.split('|') + const chatId = cq.message.chat.id + const messageId = cq.message.message_id + const from = fromInfo(cq.from) + const answer = (text) => bot.answerCallbackQuery(cq.id, { text }) + + const [row = {}] = await query( + `SELECT m.nickname, m.user_id AS member, a.user_id AS joined + FROM (SELECT ? AS uid) u + LEFT JOIN tg_members m ON m.user_id = u.uid + LEFT JOIN attendance a ON a.user_id = u.uid AND a.poll_date = ?`, + [from.id, date], + ) + const nickname = row.nickname || from.name + const joined = row.joined != null + const isMember = row.member != null + + let noop = null + if (choice === 'yes') { + if (joined) noop = '你已經報名了 👌' + else + await query( + 'INSERT INTO attendance (poll_date, user_id, nickname, tg_name, username) VALUES (?,?,?,?,?)', + [date, from.id, nickname, from.name, from.username], + ) + } else if (!joined) { + // 沒報名又是新面孔 → 當作新加入 + noop = isMember ? '已取消報名 ❌' : '歡迎加入羽球團 🏸' + } else { + await query('DELETE FROM attendance WHERE poll_date = ? AND user_id = ?', [date, from.id]) + } + + // 不管有沒有實際變動,都確保這個人存在於 tg_members + await query(SQL_UPSERT_MEMBER, [from.id, from.name, from.username]) + + if (noop) { + await answer(noop) + return + } + await editPoll(chatId, messageId, date) + await answer(choice === 'yes' ? `已報名 ✅(${nickname})` : '已取消報名 ❌') + log.info(`[signup] rsvp ${choice} date=${date} user=${from.id}(${nickname})`) + } + + function registerCommands(botUsername, { guard }) { + const cmd = (names, argPattern) => commandRegex(names, botUsername, argPattern) + + bot.onText(cmd('羽球名單|bdlist'), guard((msg) => list(msg.chat.id, msg.message_id))) + + bot.onText( + cmd('羽球暱稱|bdname', '(?:\s+([\s\S]+))?'), + guard(async (msg, m) => { + const nickname = (m[1] ?? '').trim().slice(0, 30) + if (!nickname) { + await bot.sendMessage(msg.chat.id, '用法:/羽球暱稱 你的名字\n範例:/羽球暱稱 建喵', { + reply_to_message_id: msg.message_id, + }) + return + } + await setNickname(msg.chat.id, msg.message_id, fromInfo(msg.from), nickname) + }), + ) + + bot.onText( + cmd('羽球|badminton', '(?:\s+(\d{8}))?'), + guard((msg, m) => post(msg.chat.id, m[1] || todayKey())), + ) + + } + + return { post, list, setNickname, rsvp, attendeeNames, registerCommands } +} + +export const SIGNUP_HELP = [ + '/羽球 [YYYYMMDD] - 發今日(或指定日期)羽球報名,附「參加 / 不參加」按鈕', + '/羽球名單 - 看今天的報名名單', + '/羽球暱稱 名字 - 設定報名時顯示的名字(對應配對用的暱稱)', + '  範例:/羽球暱稱 建喵', + '英文別名:/badminton、/bdlist、/bdname', + '按「參加」會寫進今日出席表,按「不參加」會從表裡移除,可隨時反悔。', +] diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..05c9777 --- /dev/null +++ b/src/store.js @@ -0,0 +1,41 @@ +// 比賽狀態儲存:記憶體 Map + JSON 檔案持久化(重啟後按鈕仍可用) +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +export function createStore(filePath) { + const matches = new Map() + + if (filePath && existsSync(filePath)) { + try { + const data = JSON.parse(readFileSync(filePath, 'utf8')) + for (const [key, value] of Object.entries(data)) matches.set(key, value) + } catch (error) { + console.warn('讀取狀態檔失敗,將以空白狀態啟動:', error.message) + } + } + + function persist() { + if (!filePath) return + try { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(Object.fromEntries(matches)), 'utf8') + } catch (error) { + console.warn('寫入狀態檔失敗:', error.message) + } + } + + const keyOf = (chatId, messageId) => `${chatId}:${messageId}` + + return { + get: (chatId, messageId) => matches.get(keyOf(chatId, messageId)) ?? null, + set(chatId, messageId, match) { + matches.set(keyOf(chatId, messageId), match) + persist() + }, + delete(chatId, messageId) { + matches.delete(keyOf(chatId, messageId)) + persist() + }, + size: () => matches.size, + } +} diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..8f129c3 --- /dev/null +++ b/src/util.js @@ -0,0 +1,51 @@ +import { config } from './config.js' + +export function escapeHtml(text) { + return String(text ?? '') + .replace(/&/g, '&') + .replace(//g, '>') +} + +// YYYYMMDD(台北時間) +export function todayKey() { + return new Date().toLocaleDateString('en-CA', { timeZone: config.timezone }).replace(/-/g, '') +} + +export function nowHM() { + return new Date().toLocaleTimeString('zh-TW', { + timeZone: config.timezone, + hour12: false, + hour: '2-digit', + minute: '2-digit', + }) +} + +// 20260817 → 08/17(一) +export function dateLabel(key) { + const y = Number(key.slice(0, 4)) + const m = Number(key.slice(4, 6)) + const d = Number(key.slice(6, 8)) + const weekday = '日一二三四五六'[new Date(Date.UTC(y, m - 1, d)).getUTCDay()] + return `${String(m).padStart(2, '0')}/${String(d).padStart(2, '0')}(${weekday})` +} + +// Telegram 使用者顯示名 +export function tgName(from) { + return [from?.first_name, from?.last_name].filter(Boolean).join(' ') || from?.username || String(from?.id) +} + +export function fromInfo(from) { + return { id: from.id, name: tgName(from), username: from.username || null } +} + +// 建立指令 regex:/名稱 或 /名稱@BotName,後面可接參數 +// names: 'help' 或 '羽球|badminton' +export function commandRegex(names, botUsername, argPattern = '') { + return new RegExp(`^\/(?:${names})(?:@${botUsername})?${argPattern}(?=\s|$)`, 'i') +} + +// 忽略 Telegram 回「內容沒變」的編輯錯誤 +export function isNotModifiedError(error) { + return /message is not modified/i.test(error?.message ?? '') +} diff --git a/test/history.test.js b/test/history.test.js new file mode 100644 index 0000000..cd48e0c --- /dev/null +++ b/test/history.test.js @@ -0,0 +1,55 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { addPoint, createMatch, setFirstServer, settleMatch } from '../src/match.js' +import { buildHistoryPayload, saveHistory } from '../src/history.js' + +test('buildHistoryPayload:欄位對齊 web 版 history(雙打)', () => { + const m = createMatch(['Gary', '蓉蓉'], ['柏威', '小念']) + setFirstServer(m, 0) + addPoint(m, 0) + addPoint(m, 1) + settleMatch(m) + const now = new Date('2026-08-17T20:30:00+08:00') // 週一 + const p = buildHistoryPayload(m, now) + assert.deepEqual(p, { + time: Math.floor(now.getTime() / 1000), + dayOfWeek: 1, + score: [1, 1], + winScore: 21, + type: 0, + players: ['Gary', '蓉蓉', '柏威', '小念'], + team: [['Gary', '蓉蓉'], ['柏威', '小念']], + scoreList: [[0, 0, 0, 0], [1, 1, 0, 1]], + }) +}) + +test('buildHistoryPayload:單打 players 以 [p1,p1,p3,p3] 讓 players[starter] 對得到人', () => { + const m = createMatch(['A'], ['B'], 15) + setFirstServer(m, 1) + addPoint(m, 1) + const p = buildHistoryPayload(m) + assert.equal(p.type, 1) + assert.equal(p.winScore, 15) + assert.deepEqual(p.players, ['A', 'A', 'B', 'B']) + assert.deepEqual(p.team, [['A'], ['B']]) + assert.equal(p.players[p.scoreList[0][1]], 'B') +}) + +test('saveHistory:先 ensure table 再 INSERT,回傳 insertId', async () => { + const sqls = [] + const query = async (sql, params) => { + sqls.push({ sql: sql.replace(/\s+/g, ' ').trim(), params }) + return sql.includes('INSERT') ? { insertId: 42 } : [] + } + const m = createMatch(['A', 'B'], ['C', 'D']) + setFirstServer(m, 0) + addPoint(m, 0) + const id = await saveHistory(m, { query, table: 'history_test' }) + assert.equal(id, 42) + assert.match(sqls[0].sql, /CREATE TABLE IF NOT EXISTS `history_test`/) + assert.match(sqls[1].sql, /INSERT INTO `history_test` \(time, dayOfWeek, score, winScore, type, players, team, scoreList\)/) + assert.equal(sqls[1].params[2], '[1,0]') + assert.equal(sqls[1].params[5], '["A","B","C","D"]') + assert.equal(sqls[1].params[6], '[["A","B"],["C","D"]]') + assert.equal(sqls[1].params[7], '[[0,0,0,0]]') +}) diff --git a/test/match.test.js b/test/match.test.js new file mode 100644 index 0000000..ab72a77 --- /dev/null +++ b/test/match.test.js @@ -0,0 +1,167 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + addPoint, + createMatch, + settleMatch, + getServing, + getWinner, + parseTeams, + setFirstServer, + swapCourt, + undo, +} from '../src/match.js' +import { renderKeyboard, renderText } from '../src/render.js' + +test('parseTeams:雙打 / 單打 / 目標分數', () => { + assert.deepEqual(parseTeams('小明 小華 vs 阿強 阿美'), { + teams: [['小明', '小華'], ['阿強', '阿美']], + target: 21, + }) + assert.deepEqual(parseTeams('小明/小華 VS 阿強、阿美 15'), { + teams: [['小明', '小華'], ['阿強', '阿美']], + target: 15, + }) + assert.deepEqual(parseTeams('小明 vs 阿強').teams, [['小明'], ['阿強']]) + assert.ok(parseTeams('小明 小華 vs 阿強').error) + assert.ok(parseTeams('小明 小華').error) + assert.ok(parseTeams('').error) +}) + +test('雙打發球規則:0 分右區、同隊得分換位、失分換發不換位', () => { + const m = createMatch(['A1', 'A2'], ['B1', 'B2']) + assert.equal(addPoint(m, 0), false, '未選先發不能加分') + setFirstServer(m, 0) + + let s = getServing(m) + assert.deepEqual([s.server, s.court, s.receiver], ['A1', 'right', 'B1']) + + addPoint(m, 0) // A 1:0,A 隊換位,1 分左區 → A1 在左區發 + s = getServing(m) + assert.deepEqual([m.score, s.server, s.court, s.receiver], [[1, 0], 'A1', 'left', 'B2']) + + addPoint(m, 0) // A 2:0,再換位,2 分右區 → A1 仍在右區發 + s = getServing(m) + assert.deepEqual([s.server, s.court, s.receiver], ['A1', 'right', 'B1']) + + addPoint(m, 1) // B 得分 2:1,換發,B 隊不換位,1 分左區 → B2 發 + s = getServing(m) + assert.deepEqual([m.score, s.team, s.server, s.court], [[2, 1], 1, 'B2', 'left']) + // 接發者:A 隊左區的人。A 隊換位兩次回到原位 → A2 在左區 + assert.equal(s.receiver, 'A2') +}) + +test('單打發球:只看分數奇偶決定發球區', () => { + const m = createMatch(['A'], ['B']) + setFirstServer(m, 1) + addPoint(m, 1) + const s = getServing(m) + assert.deepEqual([s.server, s.court, s.receiver], ['B', 'left', 'A']) +}) + +test('swapCourt 只能在 0:0,且會改變先發者', () => { + const m = createMatch(['A1', 'A2'], ['B1', 'B2']) + assert.equal(swapCourt(m, 0), true) + setFirstServer(m, 0) + assert.equal(getServing(m).server, 'A2') + addPoint(m, 0) + assert.equal(swapCourt(m, 0), false) +}) + +test('undo 可復原分數、發球權與站位', () => { + const m = createMatch(['A1', 'A2'], ['B1', 'B2']) + setFirstServer(m, 0) + addPoint(m, 0) + addPoint(m, 1) + assert.equal(undo(m), true) + assert.deepEqual(m.score, [1, 0]) + assert.equal(m.server, 0) + assert.equal(getServing(m).server, 'A1') + assert.equal(undo(m), true) + assert.deepEqual(m.score, [0, 0]) + assert.equal(undo(m), false) +}) + +test('勝負判定:21 分、Deuce 領先 2 分、30 分封頂', () => { + const m = createMatch(['A'], ['B']) + setFirstServer(m, 0) + m.score = [20, 20] + addPoint(m, 0) + assert.equal(getWinner(m), null, '21:20 未分勝負') + addPoint(m, 0) + assert.equal(getWinner(m), 0, '22:20 A 勝') + assert.equal(m.finished, true) + assert.equal(addPoint(m, 1), false, '結束後不能加分') + + const n = createMatch(['A'], ['B']) + setFirstServer(n, 0) + n.score = [29, 29] + addPoint(n, 1) + assert.equal(getWinner(n), 1, '30:29 直接獲勝') +}) + +test('renderText 會標記發球者、跳脫 HTML,並依畫面位置排列(上 1 2 / 下 4 3)', () => { + const m = createMatch(['', 'A2'], ['B1', 'B2']) + setFirstServer(m, 0) + let text = renderText(m) + assert.match(text, /🅰️ 🏸<A1> \/ A2/) + assert.match(text, /🅱️ B2 \/ B1/, '下排 B2(4) 在左、B1(3) 在右') + assert.match(text, /發球:🅰️ <A1><\/b>(右區) 接發:B1/) + addPoint(m, 0) // A 得分換位:A1 到左區 → 畫面上排右邊 + text = renderText(m) + assert.match(text, /🅰️ A2 \/ 🏸<A1>/) +}) + +test('結算:時間到以目前比分定勝負,可 undo;鍵盤為三排', () => { + const m = createMatch(['A'], ['B']) + assert.equal(settleMatch(m), false, '未選先發不能結算') + setFirstServer(m, 0) + assert.deepEqual( + renderKeyboard(m).inline_keyboard.map((r) => r.map((b) => b.text)), + [['🅰️ +1', '🅱️ +1'], ['↩️ 上一步', '🔄 重新開始'], ['⏱ 結算', '🏁 結束']], + ) + addPoint(m, 0) + addPoint(m, 0) + addPoint(m, 1) + assert.equal(settleMatch(m), true) + assert.equal(m.finished, true) + assert.equal(getWinner(m), 0) + assert.match(renderText(m), /⏱ 時間到結算 2 : 1 🅰️ 獲勝/) + assert.equal(addPoint(m, 1), false) + assert.equal(undo(m), true) + assert.equal(m.finished, false) + assert.equal(m.settled, false) + assert.deepEqual(m.score, [2, 1]) + + addPoint(m, 1) + settleMatch(m) + assert.equal(getWinner(m), null) + assert.match(renderText(m), /2 : 2 平手/) +}) + +test('scoreList 沿用 web 版格式 [round, starter, winCount, winner],undo/reset 一併處理', () => { + const m = createMatch(['A1', 'A2'], ['B1', 'B2']) + setFirstServer(m, 0) + addPoint(m, 0) // 0:0 A 發,偶數 → 1 號位(0) + addPoint(m, 0) // 1:0 A 發,奇數 → 2 號位(1),連勝 1 + addPoint(m, 1) // 2:0 A 發,偶數 → 0;B 得分,連勝歸 0 + addPoint(m, 1) // 2:1 B 發,奇數 → 4 號位(3),B 連勝 1 + addPoint(m, 1) // 2:2 B 發,偶數 → 3 號位(2),B 連勝 2 + assert.deepEqual(m.scoreList, [ + [0, 0, 0, 0], + [1, 1, 1, 0], + [2, 0, 0, 1], + [3, 3, 1, 1], + [4, 2, 2, 1], + ]) + assert.ok(!/starter|winCount/.test(renderText(m)), '不顯示在 TG 記分板') + undo(m) + assert.equal(m.scoreList.length, 4) + settleMatch(m) + undo(m) + assert.equal(m.scoreList.length, 4, '結算/復原不影響 scoreList') + const singles = createMatch(['A'], ['B']) + setFirstServer(singles, 1) + addPoint(singles, 0) + assert.deepEqual(singles.scoreList, [[0, 2, 0, 0]], '單打同樣以位置編號記') +}) diff --git a/test/scoreboard.test.js b/test/scoreboard.test.js new file mode 100644 index 0000000..30ba63a --- /dev/null +++ b/test/scoreboard.test.js @@ -0,0 +1,223 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createScoreboard } from '../src/scoreboard.js' + +function fakeBot() { + const calls = [] + let nextId = 500 + return { + calls, + last: (fn) => [...calls].reverse().find((c) => c.fn === fn), + async sendMessage(chatId, text, opts) { + calls.push({ fn: 'sendMessage', chatId, text, opts }) + return { message_id: nextId++, chat: { id: chatId } } + }, + async editMessageText(text, opts) { + calls.push({ fn: 'editMessageText', text, opts }) + return true + }, + async answerCallbackQuery(id, opts) { + calls.push({ fn: 'answerCallbackQuery', id, opts }) + return true + }, + } +} +const silentLog = { info() {}, warn() {}, error() {} } +const cq = (messageId, action, fromId = 1) => ({ + id: 'q', + data: `sc:${action}`, + from: { id: fromId }, + message: { chat: { id: -100 }, message_id: messageId }, +}) + +test('/記分 無參數:列出今日出席,依 1→4 點選後自動開局(1、2 一隊,3、4 一隊)', async () => { + const bot = fakeBot() + const sb = createScoreboard(bot, { + dataFile: null, + log: silentLog, + loadAttendees: async () => ['建喵', '柏威', 'Gary', '小念', '蓉蓉'], + }) + await sb.start(-100, 1, undefined) + const sent = bot.last('sendMessage') + assert.match(sent.text, /選擇上場人員/) + const buttons = sent.opts.reply_markup.inline_keyboard.flat().map((b) => b.text) + assert.deepEqual(buttons.slice(0, 5), ['建喵', '柏威', 'Gary', '小念', '蓉蓉']) + assert.ok(buttons.includes('✖️ 取消')) + const mid = sent.chatId === -100 && 500 + + await sb.onCallback(cq(mid, 'pick0')) + await sb.onCallback(cq(mid, 'pick0')) + assert.equal(bot.last('answerCallbackQuery').opts.text, '這位已經選了') + let edit = bot.last('editMessageText') + assert.match(edit.text, /🅰️ 1️⃣ 建喵 2️⃣ —/) + assert.match(edit.text, /🅱️ 4️⃣ — 3️⃣ —/) + assert.ok(edit.opts.reply_markup.inline_keyboard.flat().some((b) => b.text === '1️⃣ 建喵')) + + await sb.onCallback(cq(mid, 'pick2')) + edit = bot.last('editMessageText') + assert.ok(edit.opts.reply_markup.inline_keyboard.flat().some((b) => b.text === '▶️ 單打開始'), '2 人時可單打開始') + + await sb.onCallback(cq(mid, 'unpick')) + edit = bot.last('editMessageText') + assert.match(edit.text, /2️⃣ —/) + + await sb.onCallback(cq(mid, 'pick1')) + await sb.onCallback(cq(mid, 'pick3')) + await sb.onCallback(cq(mid, 'pick4')) + edit = bot.last('editMessageText') + assert.match(edit.text, /羽球記分板/, '第 4 人選完自動變成記分板') + assert.match(edit.text, /🅰️ 建喵 \/ 柏威/) + assert.match(edit.text, /🅱️ 蓉蓉 \/ 小念/, '下排照 web 版 4 3 排列') + const match = sb.store.get(-100, mid) + assert.deepEqual(match.teams.map((t) => t.players), [['建喵', '柏威'], ['小念', '蓉蓉']]) + assert.equal(match.phase, undefined) +}) + +test('/記分 無參數:選 2 人按單打開始;沒人報名時提示', async () => { + const bot = fakeBot() + const sb = createScoreboard(bot, { dataFile: null, log: silentLog, loadAttendees: async () => ['A', 'B', 'C'] }) + await sb.start(-100, 1, '') + await sb.onCallback(cq(500, 'pick2')) + await sb.onCallback(cq(500, 'pick0')) + await sb.onCallback(cq(500, 'pickgo')) + assert.deepEqual(sb.store.get(-100, 500).teams.map((t) => t.players), [['C'], ['A']]) + + const bot2 = fakeBot() + const sb2 = createScoreboard(bot2, { dataFile: null, log: silentLog, loadAttendees: async () => [] }) + await sb2.start(-100, 1, undefined) + assert.match(bot2.last('sendMessage').text, /今天還沒有人報名/) +}) + +test('結束要兩段確認:第一次按只詢問,取消可回復,再按確定才結束', async () => { + const bot = fakeBot() + const sb = createScoreboard(bot, { dataFile: null, log: silentLog }) + await sb.start(-100, 1, 'A vs B') + await sb.onCallback(cq(500, 'srv0')) + await sb.onCallback(cq(500, 'pt0')) + + await sb.onCallback(cq(500, 'end')) + let edit = bot.last('editMessageText') + assert.match(edit.text, /確定要結束嗎/) + const texts = edit.opts.reply_markup.inline_keyboard.flat().map((b) => b.text) + assert.ok(texts.includes('✅ 確定結束') && texts.includes('↩️ 取消')) + assert.ok(!texts.includes('🏁 結束')) + assert.ok(sb.store.get(-100, 500), '尚未結束') + + await sb.onCallback(cq(500, 'endcancel')) + edit = bot.last('editMessageText') + assert.ok(!/確定要結束嗎/.test(edit.text)) + assert.ok(edit.opts.reply_markup.inline_keyboard.flat().some((b) => b.text === '🏁 結束')) + + await sb.onCallback(cq(500, 'end')) + await sb.onCallback(cq(500, 'pt1'), '按其他按鈕會取消確認') + edit = bot.last('editMessageText') + assert.ok(!/確定要結束嗎/.test(edit.text)) + assert.equal(sb.store.get(-100, 500).score[1], 1) + + await sb.onCallback(cq(500, 'end')) + await sb.onCallback(cq(500, 'end')) + edit = bot.last('editMessageText') + assert.match(edit.text, /已結束/) + assert.deepEqual(edit.opts.reply_markup.inline_keyboard, []) + assert.equal(sb.store.get(-100, 500), null) +}) + +test('權限:只有開局者或群組管理員能按,其他人只跳提示;舊資料無 ownerId 不限制', async () => { + const bot = fakeBot() + const admins = new Set([99]) + const sb = createScoreboard(bot, { + dataFile: null, + log: silentLog, + isAdmin: async (_chatId, userId) => admins.has(userId), + }) + await sb.start(-100, 1, 'A vs B', 1) // 開局者 user 1 + assert.equal(sb.store.get(-100, 500).ownerId, 1) + + await sb.onCallback(cq(500, 'srv0', 2)) // 路人 + assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板') + assert.equal(sb.store.get(-100, 500).server, null, '狀態不變') + + await sb.onCallback(cq(500, 'srv0', 99)) // 管理員 + assert.equal(sb.store.get(-100, 500).server, 0) + await sb.onCallback(cq(500, 'pt0', 1)) // 開局者 + assert.deepEqual(sb.store.get(-100, 500).score, [1, 0]) + + // 選人階段同樣受限,且開局者會帶到記分板 + const sb2 = createScoreboard(bot, { dataFile: null, log: silentLog, loadAttendees: async () => ['A', 'B'], isAdmin: async () => false }) + await sb2.start(-100, 1, undefined, 7) + await sb2.onCallback(cq(501, 'pick0', 8)) + assert.equal(sb2.store.get(-100, 501).picked.length, 0) + await sb2.onCallback(cq(501, 'pick0', 7)) + await sb2.onCallback(cq(501, 'pick1', 7)) + await sb2.onCallback(cq(501, 'pickgo', 7)) + assert.equal(sb2.store.get(-100, 501).ownerId, 7) + + // 舊資料沒有 ownerId → 任何人可按 + sb.store.set(-100, 600, { ...sb.store.get(-100, 500), ownerId: undefined }) + await sb.onCallback(cq(600, 'pt1', 2)) + assert.deepEqual(sb.store.get(-100, 600).score, [1, 1]) +}) + +test('歷史戰績:分出勝負後「確定結束」或「再一局」會存一次;未結算的結束不存;失敗不結束', async () => { + const bot = fakeBot() + const saved = [] + let fail = false + const saveHistory = async (m) => { + if (fail) throw new Error('db down') + saved.push({ score: [...m.score], teams: m.teams.map((t) => [...t.players]) }) + return saved.length + } + const sb = createScoreboard(bot, { dataFile: null, log: silentLog, saveHistory }) + + // 未結算就結束 → 不存 + await sb.start(-100, 1, 'A vs B') + await sb.onCallback(cq(500, 'srv0')) + await sb.onCallback(cq(500, 'pt0')) + await sb.onCallback(cq(500, 'end')) + assert.match(bot.last('editMessageText').text, /比賽未結算,不會寫入戰績/) + await sb.onCallback(cq(500, 'end')) + assert.equal(saved.length, 0) + assert.match(bot.last('editMessageText').text, /已結束(未結算,未存入戰績)/) + + // 結算後再一局 → 存一筆,並開新局 + await sb.start(-100, 1, 'A vs B') + await sb.onCallback(cq(501, 'srv0')) + await sb.onCallback(cq(501, 'pt0')) + await sb.onCallback(cq(501, 'pt1')) + await sb.onCallback(cq(501, 'pt1')) + await sb.onCallback(cq(501, 'settle')) + await sb.onCallback(cq(501, 'reset')) + assert.equal(saved.length, 1) + assert.deepEqual(saved[0].score, [1, 2]) + assert.match(bot.last('answerCallbackQuery').opts.text, /戰績 #1 已存/) + assert.deepEqual(sb.store.get(-100, 501).score, [0, 0]) + assert.equal(sb.store.get(-100, 501).historyId, undefined) + + // 新局打到分出勝負 → 確定結束存第二筆;DB 失敗時不結束 + const m = sb.store.get(-100, 501) + await sb.onCallback(cq(501, 'srv1')) + m.score = [0, 20] + await sb.onCallback(cq(501, 'pt1')) + assert.equal(m.finished, true) + fail = true + await sb.onCallback(cq(501, 'end')) + assert.match(bot.last('editMessageText').text, /會把這場寫入歷史戰績/) + await sb.onCallback(cq(501, 'end')) + assert.match(bot.last('answerCallbackQuery').opts.text, /寫入歷史戰績失敗/) + assert.ok(sb.store.get(-100, 501), '失敗時不結束') + fail = false + await sb.onCallback(cq(501, 'end')) + assert.equal(saved.length, 2) + assert.deepEqual(saved[1].score, [0, 21]) + assert.match(bot.last('editMessageText').text, /已存入歷史戰績 #2/) + assert.equal(sb.store.get(-100, 501), null) +}) + +test('選人中按取消 → 訊息鎖定並移除狀態', async () => { + const bot = fakeBot() + const sb = createScoreboard(bot, { dataFile: null, log: silentLog, loadAttendees: async () => ['A', 'B'] }) + await sb.start(-100, 1, undefined) + await sb.onCallback(cq(500, 'end')) + assert.match(bot.last('editMessageText').text, /已取消選人/) + assert.equal(sb.store.get(-100, 500), null) +}) diff --git a/test/signup.test.js b/test/signup.test.js new file mode 100644 index 0000000..5a63018 --- /dev/null +++ b/test/signup.test.js @@ -0,0 +1,177 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createSignup, renderPoll, keyboard } from '../src/signup.js' +import { commandRegex, dateLabel, todayKey } from '../src/util.js' + +// ---- 假 DB:用 SQL 開頭判斷要做什麼,維護 attendance / tg_members / tg_poll 三張表 ---- +function fakeDb() { + const attendance = [] // { poll_date, user_id, nickname } + const members = new Map() // user_id -> { nickname, tg_name, username } + const polls = new Map() // poll_date -> { chat_id, message_id } + const sqlLog = [] + + async function query(sql, params) { + const s = sql.replace(/\s+/g, ' ').trim() + sqlLog.push(s.slice(0, 40)) + if (s.startsWith('SELECT user_id, nickname FROM attendance')) { + return attendance.filter((r) => r.poll_date === params[0]).map((r) => ({ user_id: r.user_id, nickname: r.nickname })) + } + if (s.startsWith('SELECT m.nickname, m.user_id AS member')) { + const [uid, date] = params + const m = members.get(uid) + const a = attendance.find((r) => r.user_id === uid && r.poll_date === date) + return [{ nickname: m?.nickname ?? null, member: m ? uid : null, joined: a ? uid : null }] + } + if (s.startsWith('INSERT INTO attendance')) { + const [poll_date, user_id, nickname] = params + attendance.push({ poll_date, user_id, nickname }) + return { affectedRows: 1 } + } + if (s.startsWith('DELETE FROM attendance')) { + const [date, uid] = params + const i = attendance.findIndex((r) => r.poll_date === date && r.user_id === uid) + if (i >= 0) attendance.splice(i, 1) + return { affectedRows: i >= 0 ? 1 : 0 } + } + if (s.startsWith('INSERT INTO tg_members (user_id, nickname')) { + const [uid, nickname, tg_name, username] = params + members.set(uid, { ...(members.get(uid) ?? {}), nickname, tg_name, username }) + return {} + } + if (s.startsWith('INSERT INTO tg_members (user_id, tg_name')) { + const [uid, tg_name, username] = params + members.set(uid, { ...(members.get(uid) ?? {}), tg_name, username }) + return {} + } + if (s.startsWith('UPDATE attendance SET nickname')) { + const [nickname, uid, from] = params + attendance.forEach((r) => { + if (r.user_id === uid && r.poll_date >= from) r.nickname = nickname + }) + return {} + } + if (s.startsWith('INSERT INTO tg_poll')) { + const [date, chat_id, message_id] = params + polls.set(date, { chat_id, message_id }) + return {} + } + if (s.startsWith('SELECT chat_id, message_id FROM tg_poll')) { + const p = polls.get(params[0]) + return p ? [p] : [] + } + throw new Error(`fakeDb 不認得的 SQL:${s}`) + } + return { query, attendance, members, polls, sqlLog } +} + +function fakeBot() { + const calls = [] + let nextId = 100 + return { + calls, + async sendMessage(chatId, text, opts) { + calls.push({ fn: 'sendMessage', chatId, text, opts }) + return { message_id: nextId++, chat: { id: chatId } } + }, + async editMessageText(text, opts) { + calls.push({ fn: 'editMessageText', text, opts }) + return true + }, + async answerCallbackQuery(id, opts) { + calls.push({ fn: 'answerCallbackQuery', id, opts }) + return true + }, + } +} + +const silentLog = { info() {}, warn() {}, error() {} } +const DATE = '20260817' +const cq = (userId, choice, name = `U${userId}`) => ({ + id: `cq${userId}${choice}`, + data: `bd|${choice}||${DATE}`, + from: { id: userId, first_name: name, username: null }, + message: { chat: { id: -100 }, message_id: 4 }, +}) + +test('renderPoll / keyboard 與 Node-RED 版格式一致', () => { + assert.equal(dateLabel('20260817'), '08/17(一)') + const text = renderPoll('20260817', [{ nickname: '建喵' }, { nickname: '' }]) + assert.match(text, /^🏸 今日羽球 08\/17\(一\)<\/b>\n按下方按鈕報名,可隨時反悔\n\n✅ 參加 \(2\):建喵、<G>\n\n🕑 更新 \d\d:\d\d$/) + assert.match(renderPoll('20260817', []), /✅ 參加 \(0\):—/) + assert.deepEqual(keyboard('20260817').inline_keyboard[0].map((b) => b.callback_data), ['bd|yes||20260817', 'bd|no||20260817']) +}) + +test('rsvp:參加 → 寫入、名單更新;重按 → 提示不動 DB;不參加 → 刪除', async () => { + const db = fakeDb() + const bot = fakeBot() + const signup = createSignup(bot, { log: silentLog, query: db.query }) + + await signup.rsvp(cq(1, 'yes', '建喵')) + assert.deepEqual(db.attendance.map((r) => r.nickname), ['建喵']) + assert.ok(db.members.has(1), '順手寫入 tg_members') + let edit = bot.calls.find((c) => c.fn === 'editMessageText') + assert.match(edit.text, /✅ 參加 \(1\):建喵/) + assert.equal(bot.calls.at(-1).opts.text, '已報名 ✅(建喵)') + + bot.calls.length = 0 + await signup.rsvp(cq(1, 'yes', '建喵')) + assert.equal(db.attendance.length, 1) + assert.equal(bot.calls.filter((c) => c.fn === 'editMessageText').length, 0, '重按不重畫') + assert.equal(bot.calls.at(-1).opts.text, '你已經報名了 👌') + + bot.calls.length = 0 + await signup.rsvp(cq(2, 'no', '路人')) + assert.equal(bot.calls.at(-1).opts.text, '歡迎加入羽球團 🏸', '沒報名的新面孔按不參加') + await signup.rsvp(cq(2, 'no', '路人')) + assert.equal(bot.calls.at(-1).opts.text, '已取消報名 ❌', '已是成員但沒報名') + + bot.calls.length = 0 + await signup.rsvp(cq(1, 'no', '建喵')) + assert.equal(db.attendance.length, 0) + edit = bot.calls.find((c) => c.fn === 'editMessageText') + assert.match(edit.text, /✅ 參加 \(0\):—/) + assert.equal(bot.calls.at(-1).opts.text, '已取消報名 ❌') +}) + +test('rsvp 使用 tg_members 裡設定的暱稱', async () => { + const db = fakeDb() + const bot = fakeBot() + const signup = createSignup(bot, { log: silentLog, query: db.query }) + db.members.set(9, { nickname: 'RURU' }) + await signup.rsvp(cq(9, 'yes', 'Ru Chen')) + assert.equal(db.attendance[0].nickname, 'RURU') + assert.equal(bot.calls.at(-1).opts.text, '已報名 ✅(RURU)') +}) + +test('post 會發訊息並記錄 tg_poll;setNickname 會更新今日名單與訊息', async () => { + const db = fakeDb() + const bot = fakeBot() + const signup = createSignup(bot, { log: silentLog, query: db.query }) + const today = todayKey() + + await signup.post(-100) + assert.deepEqual(db.polls.get(today), { chat_id: '-100', message_id: 100 }) + assert.deepEqual(bot.calls[0].opts.reply_markup, keyboard(today)) + + await signup.rsvp({ ...cq(5, 'yes', 'Old'), data: `bd|yes||${today}` }) + bot.calls.length = 0 + await signup.setNickname(-100, 77, { id: 5, name: 'Old', username: null }, '新名') + assert.equal(db.members.get(5).nickname, '新名') + assert.equal(db.attendance[0].nickname, '新名') + assert.match(bot.calls[0].text, /暱稱已設為「新名」/) + const edit = bot.calls.find((c) => c.fn === 'editMessageText') + assert.equal(edit.opts.message_id, 100) + assert.match(edit.text, /新名/) +}) + +test('commandRegex:支援 @BotName 後綴與中英文別名', () => { + const r = commandRegex('羽球|badminton', 'JianMiauBadmintonBot', '(?:\\s+(\\d{8}))?') + assert.ok(r.test('/羽球')) + assert.ok(r.test('/badminton@JianMiauBadmintonBot 20260817')) + assert.equal('/羽球 20260817'.match(r)[1], '20260817') + assert.ok(!r.test('/羽球名單'), '不能誤吃 /羽球名單') + assert.ok(!r.test('/羽球x')) + const s = commandRegex('記分|score|new', 'JianMiauBadmintonBot', '(?:\\s+([\\s\\S]+))?') + assert.equal('/score 小明 vs 阿強'.match(s)[1], '小明 vs 阿強') + assert.equal('/記分'.match(s)[1], undefined) +})