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