Add LINE push integration and target mode config
This commit is contained in:
@@ -17,6 +17,14 @@ const distReady = existsSync(path.join(distDir, 'index.html'))
|
||||
|
||||
const requiredEnv = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_DATABASE']
|
||||
const missingEnv = requiredEnv.filter((key) => !process.env[key])
|
||||
const lineAccessToken =
|
||||
process.env.LINE_CHANNEL_ACCESS_TOKEN ?? process.env.channelAccessToken ?? ''
|
||||
const lineTargetMode = (process.env.LINE_TARGET_MODE ?? 'local').toLowerCase()
|
||||
const lineTargetId =
|
||||
process.env.LINE_TARGET_ID ??
|
||||
(lineTargetMode === 'prod'
|
||||
? process.env.LINE_TARGET_ID_PROD ?? ''
|
||||
: process.env.LINE_TARGET_ID_LOCAL ?? '')
|
||||
|
||||
const pool =
|
||||
missingEnv.length === 0
|
||||
@@ -39,10 +47,64 @@ app.get('/api/health', (_request, response) => {
|
||||
ok: true,
|
||||
dbReady: Boolean(pool),
|
||||
distReady,
|
||||
lineReady: Boolean(lineAccessToken && lineTargetId),
|
||||
lineTargetMode,
|
||||
missingEnv,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/line/push-match-results', async (request, response) => {
|
||||
const { time, teams } = request.body ?? {}
|
||||
|
||||
if (typeof time !== 'string' || !Array.isArray(teams)) {
|
||||
response.status(400).json({
|
||||
ok: false,
|
||||
message: '送出的 LINE 訊息資料格式不正確。',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!lineAccessToken || !lineTargetId) {
|
||||
response.status(500).json({
|
||||
ok: false,
|
||||
message:
|
||||
'LINE 推播環境變數缺少,請檢查 LINE_CHANNEL_ACCESS_TOKEN 與 LINE_TARGET_ID_LOCAL / LINE_TARGET_ID_PROD。',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const message = buildLineFlexMessage(time, teams)
|
||||
const lineResponse = await fetch('https://api.line.me/v2/bot/message/push', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${lineAccessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: lineTargetId,
|
||||
messages: [message],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!lineResponse.ok) {
|
||||
const errorText = await lineResponse.text()
|
||||
throw new Error(`LINE 推播失敗:${errorText}`)
|
||||
}
|
||||
|
||||
response.json({
|
||||
ok: true,
|
||||
message: '已推送到 LINE。',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('line push error:', error)
|
||||
response.status(500).json({
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : 'LINE 推播失敗。',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/match-results/:time', async (request, response) => {
|
||||
if (!pool) {
|
||||
response.status(500).json({
|
||||
@@ -172,6 +234,7 @@ if (distReady) {
|
||||
app.listen(port, () => {
|
||||
console.log(`Server ready on http://localhost:${port}`)
|
||||
console.log(`Static files: ${distReady ? 'loaded' : 'missing'}`)
|
||||
console.log(`LINE target mode: ${lineTargetMode}`)
|
||||
if (missingEnv.length > 0) {
|
||||
console.log(`Missing env: ${missingEnv.join(', ')}`)
|
||||
}
|
||||
@@ -187,3 +250,102 @@ async function ensureTable(poolInstance, currentTableName) {
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
`)
|
||||
}
|
||||
|
||||
function buildLineFlexMessage(time, rounds) {
|
||||
const dateText = `${time.slice(0, 4)}-${time.slice(4, 6)}-${time.slice(6, 8)}`
|
||||
|
||||
return {
|
||||
type: 'flex',
|
||||
altText: `${dateText} 羽球隊伍配對`,
|
||||
contents: {
|
||||
type: 'carousel',
|
||||
contents: rounds.map((round, roundIndex) => ({
|
||||
type: 'bubble',
|
||||
size: 'micro',
|
||||
body: {
|
||||
type: 'box',
|
||||
layout: 'vertical',
|
||||
contents: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '勝皇羽球團',
|
||||
weight: 'bold',
|
||||
color: '#1DB446',
|
||||
size: 'sm',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: dateText,
|
||||
weight: 'bold',
|
||||
size: 'xl',
|
||||
margin: 'sm',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: `第${roundIndex + 1}輪`,
|
||||
weight: 'bold',
|
||||
size: 'xxl',
|
||||
margin: 'sm',
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
margin: 'md',
|
||||
},
|
||||
{
|
||||
type: 'box',
|
||||
layout: 'horizontal',
|
||||
contents: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '一號隊友',
|
||||
size: 'sm',
|
||||
flex: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '二號隊友',
|
||||
size: 'sm',
|
||||
align: 'center',
|
||||
},
|
||||
],
|
||||
margin: 'md',
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
margin: 'sm',
|
||||
},
|
||||
{
|
||||
type: 'box',
|
||||
layout: 'vertical',
|
||||
spacing: 'sm',
|
||||
margin: 'md',
|
||||
contents: round.teams.map((team) => ({
|
||||
type: 'box',
|
||||
layout: 'horizontal',
|
||||
contents: [
|
||||
{
|
||||
type: 'text',
|
||||
text: team.a,
|
||||
size: 'sm',
|
||||
flex: 0,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: team.b,
|
||||
size: 'sm',
|
||||
align: 'center',
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
styles: {
|
||||
footer: {
|
||||
separator: true,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user