建立建喵打羽球 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
+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)
})