建立建喵打羽球 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:
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 把完成的比賽寫入 badminton-scoreboard 共用的 history 表
|
||||
// 欄位與 web 版 server/server.mjs ensureHistoryTable / POST /api/history 相同:
|
||||
// time INT(unix 秒)、dayOfWeek、score "[A,B]"、winScore、type(0 雙打 / 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
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,190 @@
|
||||
// 今日羽球報名(移植自 Node-RED「羽球報名」分頁的狀態機)
|
||||
//
|
||||
// - /羽球 [YYYYMMDD]、/badminton:發今日(或指定日期)報名訊息,附「參加 / 不參加」按鈕
|
||||
// - /羽球名單、/bdlist:回今天的名單
|
||||
// - /羽球暱稱 名字、/bdname:設定報名顯示的名字(存 tg_members,綁 user_id)
|
||||
// - 按鈕 callback_data:bd|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',
|
||||
'按「參加」會寫進今日出席表,按「不參加」會從表裡移除,可隨時反悔。',
|
||||
]
|
||||
@@ -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
@@ -0,0 +1,51 @@
|
||||
import { config } from './config.js'
|
||||
|
||||
export function escapeHtml(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// 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 ?? '')
|
||||
}
|
||||
Reference in New Issue
Block a user