建立建喵打羽球 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:
+151
@@ -0,0 +1,151 @@
|
||||
// 把比賽狀態轉成 Telegram 訊息文字(HTML)與 inline 鍵盤
|
||||
import { getServing, getWinner, isDoubles } from './match.js'
|
||||
import { escapeHtml } from './util.js'
|
||||
|
||||
const TEAM_ICON = ['🅰️', '🅱️']
|
||||
const COURT_LABEL = { right: '右區', left: '左區' }
|
||||
|
||||
// 隊員名單,依「畫面位置」排列(同 web 版:上排 1 2、下排 4 3),發球者前面加 🏸
|
||||
// 上排 🅰️ 為鏡像:畫面左 = 自己的右發球區;下排 🅱️:畫面右 = 自己的右發球區
|
||||
function renderPlayers(match, teamIndex, serving) {
|
||||
const team = match.teams[teamIndex]
|
||||
let ordered = team.players
|
||||
if (team.players.length === 2) {
|
||||
const rightPlayer = team.players[team.right]
|
||||
const leftPlayer = team.players[1 - team.right]
|
||||
ordered = teamIndex === 0 ? [rightPlayer, leftPlayer] : [leftPlayer, rightPlayer]
|
||||
}
|
||||
return ordered
|
||||
.map((name) => {
|
||||
const isServer = serving && serving.team === teamIndex && serving.server === name
|
||||
return `${isServer ? '🏸' : ''}${escapeHtml(name)}`
|
||||
})
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
export function renderText(match, { ended = false } = {}) {
|
||||
const serving = getServing(match)
|
||||
const winner = getWinner(match)
|
||||
const lines = []
|
||||
|
||||
lines.push(`🏸 <b>羽球記分板</b> ${isDoubles(match) ? '雙打' : '單打'}・${match.target} 分制`)
|
||||
lines.push('')
|
||||
|
||||
for (const teamIndex of [0, 1]) {
|
||||
const marker = winner === teamIndex ? ' 🏆' : ''
|
||||
lines.push(
|
||||
`${TEAM_ICON[teamIndex]} ${renderPlayers(match, teamIndex, serving)} <b>${match.score[teamIndex]}</b>${marker}`,
|
||||
)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
const hasPoints = (match.scoreList ?? []).length > 0
|
||||
if (ended) {
|
||||
if (match.historyId) lines.push(`⏹ 已結束 📝 已存入歷史戰績 #${match.historyId}`)
|
||||
else if (hasPoints && !match.finished) lines.push('⏹ 已結束(未結算,未存入戰績)')
|
||||
else lines.push('⏹ 已結束')
|
||||
} else if (match.confirmEnd) {
|
||||
if (match.finished) lines.push('❓ <b>確定要結束嗎?</b>會把這場寫入歷史戰績')
|
||||
else if (hasPoints) lines.push('❓ <b>確定要結束嗎?</b>比賽未結算,不會寫入戰績(要存請先按 ⏱ 結算)')
|
||||
else lines.push('❓ <b>確定要結束嗎?</b>')
|
||||
} else if (match.settled) {
|
||||
const result = winner !== null ? `${TEAM_ICON[winner]} 獲勝` : '平手'
|
||||
lines.push(`⏱ 時間到結算 ${match.score[0]} : ${match.score[1]} ${result}`)
|
||||
} else if (winner !== null) {
|
||||
lines.push(`🎉 ${TEAM_ICON[winner]} 獲勝! ${match.score[0]} : ${match.score[1]}`)
|
||||
} else if (!serving) {
|
||||
lines.push('👉 請先選擇由哪一隊先發球')
|
||||
} else {
|
||||
lines.push(
|
||||
`發球:${TEAM_ICON[serving.team]} <b>${escapeHtml(serving.server)}</b>(${COURT_LABEL[serving.court]})` +
|
||||
` 接發:${escapeHtml(serving.receiver)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// callback_data 格式:sc:<action>
|
||||
const btn = (text, action) => ({ text, callback_data: `sc:${action}` })
|
||||
|
||||
export function renderKeyboard(match, { ended = false } = {}) {
|
||||
if (ended) return { inline_keyboard: [] }
|
||||
const rows = buildKeyboardRows(match)
|
||||
if (match.confirmEnd) {
|
||||
// 兩段式確認:把「結束」換成「確定結束 / 取消」
|
||||
return {
|
||||
inline_keyboard: rows.map((row) =>
|
||||
row.some((b) => b.callback_data === 'sc:end')
|
||||
? [btn('✅ 確定結束', 'end'), btn('↩️ 取消', 'endcancel')]
|
||||
: row,
|
||||
),
|
||||
}
|
||||
}
|
||||
return { inline_keyboard: rows }
|
||||
}
|
||||
|
||||
function buildKeyboardRows(match) {
|
||||
|
||||
if (match.finished) {
|
||||
return [
|
||||
[btn('↩️ 上一步', 'undo'), btn('🔄 再一局', 'reset')],
|
||||
[btn('🏁 結束', 'end')],
|
||||
]
|
||||
}
|
||||
|
||||
if (match.server === null) {
|
||||
const rows = [[btn('🅰️ 先發球', 'srv0'), btn('🅱️ 先發球', 'srv1')]]
|
||||
if (isDoubles(match)) {
|
||||
rows.push([btn('🔀 🅰️ 換位', 'swap0'), btn('🔀 🅱️ 換位', 'swap1')])
|
||||
}
|
||||
rows.push([btn('🏁 結束', 'end')])
|
||||
return rows
|
||||
}
|
||||
|
||||
return [
|
||||
[btn('🅰️ +1', 'pt0'), btn('🅱️ +1', 'pt1')],
|
||||
[btn('↩️ 上一步', 'undo'), btn('🔄 重新開始', 'reset')],
|
||||
[btn('⏱ 結算', 'settle'), btn('🏁 結束', 'end')],
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- 選人(/記分 無參數:從今日出席名單點選 1→4) ----------
|
||||
const PICK_NUM = ['1️⃣', '2️⃣', '3️⃣', '4️⃣']
|
||||
|
||||
export function renderPickerText(picker) {
|
||||
const slot = (i) => (picker.picked[i] != null ? escapeHtml(picker.names[picker.picked[i]]) : '—')
|
||||
const lines = [
|
||||
`🏸 <b>選擇上場人員</b> 依 1 → 4 順序點選(今日出席 ${picker.names.length} 人)`,
|
||||
'',
|
||||
`🅰️ 1️⃣ ${slot(0)} 2️⃣ ${slot(1)}`,
|
||||
`🅱️ 4️⃣ ${slot(3)} 3️⃣ ${slot(2)}`,
|
||||
'',
|
||||
]
|
||||
if (picker.picked.length === 0) lines.push('👉 點名字加入;1、2 一隊,3、4 一隊(位置同 web 版:上 1 2/下 4 3);點滿 4 人自動開局')
|
||||
else if (picker.picked.length === 2) lines.push('👉 繼續點第 3、4 位,或按「單打開始」')
|
||||
else if (picker.picked.length < 4) lines.push(`👉 繼續點第 ${picker.picked.length + 1} 位`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function renderPickerKeyboard(picker) {
|
||||
const rows = []
|
||||
const perRow = picker.names.length > 8 ? 3 : 2
|
||||
let row = []
|
||||
picker.names.forEach((name, i) => {
|
||||
const order = picker.picked.indexOf(i)
|
||||
const label = order >= 0 ? `${PICK_NUM[order]} ${name}` : name
|
||||
row.push({ text: label, callback_data: `sc:pick${i}` })
|
||||
if (row.length === perRow) {
|
||||
rows.push(row)
|
||||
row = []
|
||||
}
|
||||
})
|
||||
if (row.length) rows.push(row)
|
||||
|
||||
const controls = []
|
||||
if (picker.picked.length > 0) controls.push({ text: '↩️ 取消上一位', callback_data: 'sc:unpick' })
|
||||
if (picker.picked.length === 2) controls.push({ text: '▶️ 單打開始', callback_data: 'sc:pickgo' })
|
||||
controls.push({ text: '✖️ 取消', callback_data: 'sc:end' })
|
||||
rows.push(controls)
|
||||
return { inline_keyboard: rows }
|
||||
}
|
||||
Reference in New Issue
Block a user