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