建立建喵打羽球 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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.env
|
||||
data
|
||||
*.log
|
||||
.git
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/*.json
|
||||
*.log
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
Generated
+2578
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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://<host>: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()
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+131
@@ -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)
|
||||
})
|
||||
+219
@@ -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 }
|
||||
}
|
||||
+151
@@ -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(`🏸 <b>羽球記分板</b> ${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)} <b>${match.score[teamIndex]}</b>${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('❓ <b>確定要結束嗎?</b>會把這場寫入歷史戰績')
|
||||
else if (hasPoints) lines.push('❓ <b>確定要結束嗎?</b>比賽未結算,不會寫入戰績(要存請先按 ⏱ 結算)')
|
||||
else lines.push('❓ <b>確定要結束嗎?</b>')
|
||||
} 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]} <b>${escapeHtml(serving.server)}</b>(${COURT_LABEL[serving.court]})` +
|
||||
` 接發:${escapeHtml(serving.receiver)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// callback_data 格式:sc:<action>
|
||||
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 = [
|
||||
`🏸 <b>選擇上場人員</b> 依 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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
+190
@@ -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 (
|
||||
`🏸 <b>今日羽球 ${dateLabel(date)}</b>\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',
|
||||
'按「參加」會寫進今日出席表,按「不參加」會從表裡移除,可隨時反悔。',
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { config } from './config.js'
|
||||
|
||||
export function escapeHtml(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.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 ?? '')
|
||||
}
|
||||
@@ -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]]')
|
||||
})
|
||||
@@ -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(['<A1>', '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, /發球:🅰️ <b><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]], '單打同樣以位置編號記')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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: '<G>' }])
|
||||
assert.match(text, /^🏸 <b>今日羽球 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)
|
||||
})
|
||||
Reference in New Issue
Block a user