建立建喵打羽球 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:
2026-08-18 11:10:27 +08:00
co-authored by Claude Fable 5
commit 2e88a7d85a
24 changed files with 4643 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.env
data
*.log
.git
+25
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
data/*.json
*.log
+7
View File
@@ -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"]
+111
View File
@@ -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 APIdeploy 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
```
View File
+11
View File
@@ -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
+2578
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+52
View File
@@ -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 APIhttp://<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()
+30
View File
@@ -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))
}
+27
View File
@@ -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
}
+71
View File
@@ -0,0 +1,71 @@
// 把完成的比賽寫入 badminton-scoreboard 共用的 history 表
// 欄位與 web 版 server/server.mjs ensureHistoryTable / POST /api/history 相同:
// time INTunix 秒)、dayOfWeek、score "[A,B]"、winScore、type0 雙打 / 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
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
}
+295
View File
@@ -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
View File
@@ -0,0 +1,190 @@
// 今日羽球報名(移植自 Node-RED「羽球報名」分頁的狀態機)
//
// - /羽球 [YYYYMMDD]、/badminton:發今日(或指定日期)報名訊息,附「參加 / 不參加」按鈕
// - /羽球名單、/bdlist:回今天的名單
// - /羽球暱稱 名字、/bdname:設定報名顯示的名字(存 tg_members,綁 user_id
// - 按鈕 callback_databd|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',
'按「參加」會寫進今日出席表,按「不參加」會從表裡移除,可隨時反悔。',
]
+41
View File
@@ -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
View File
@@ -0,0 +1,51 @@
import { config } from './config.js'
export function escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
// 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 ?? '')
}
+55
View File
@@ -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]]')
})
+167
View File
@@ -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, /🅰️ 🏸&lt;A1&gt; \/ A2/)
assert.match(text, /🅱️ B2 \/ B1/, '下排 B2(4) 在左、B1(3) 在右')
assert.match(text, /發球:🅰️ <b>&lt;A1&gt;<\/b>(右區) 接發:B1/)
addPoint(m, 0) // A 得分換位:A1 到左區 → 畫面上排右邊
text = renderText(m)
assert.match(text, /🅰️ A2 \/ 🏸&lt;A1&gt;/)
})
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]], '單打同樣以位置編號記')
})
+223
View File
@@ -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)
})
+177
View File
@@ -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\):建喵、&lt;G&gt;\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_pollsetNickname 會更新今日名單與訊息', 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)
})