// scripts/bridge/heartbeat.js // ๐ŸŒ‰ ๆกฅๆŽฅยทๅฟƒ่ทณๆฃ€ๆต‹ // // ๅฎšๆ—ถๆฃ€ๆต‹ๆกฅๆŽฅ้€š้“็•…้€šๆ€ง๏ผš // 1. ๆต‹่ฏ• Notion API ่ฟž้€šๆ€ง // 2. ๅœจ่ฐƒๅบฆ้˜Ÿๅˆ—ๅˆ›ๅปบ HEARTBEAT ไปปๅŠก // 3. ่ฎฐๅฝ•ๅฟƒ่ทณๆ—ฅๅฟ— // // ็Žฏๅขƒๅ˜้‡๏ผš // NOTION_TOKEN Notion API token // BRIDGE_QUEUE_DB_ID ๆกฅๆŽฅ่ฐƒๅบฆ้˜Ÿๅˆ—ๆ•ฐๆฎๅบ“ ID 'use strict'; const https = require('https'); const fs = require('fs'); const path = require('path'); const NOTION_VERSION = '2022-06-28'; const NOTION_API_HOSTNAME = 'api.notion.com'; const NOTION_TITLE_MAX = 120; const NOTION_RICH_TEXT_MAX = 2000; // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // Notion API // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• function notionRequest(method, endpoint, body, token) { return new Promise((resolve, reject) => { const payload = body ? JSON.stringify(body) : ''; const opts = { hostname: NOTION_API_HOSTNAME, port: 443, path: endpoint, method: method, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', 'Notion-Version': NOTION_VERSION, }, }; if (payload) { opts.headers['Content-Length'] = Buffer.byteLength(payload); } const req = https.request(opts, res => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { const parsed = JSON.parse(data); resolve({ status: res.statusCode, data: parsed }); } catch (e) { resolve({ status: res.statusCode, data: data }); } }); }); req.on('error', reject); if (payload) req.write(payload); req.end(); }); } function richText(content) { const str = String(content || ''); const chunks = []; for (let i = 0; i < str.length; i += NOTION_RICH_TEXT_MAX) { chunks.push({ type: 'text', text: { content: str.slice(i, i + NOTION_RICH_TEXT_MAX) } }); } return chunks.length ? chunks : [{ type: 'text', text: { content: '' } }]; } function titleProp(content) { return [{ type: 'text', text: { content: String(content || '').slice(0, NOTION_TITLE_MAX) } }]; } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ๅฟƒ่ทณๆฃ€ๆต‹ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• async function checkNotionHealth(token) { const start = Date.now(); try { const result = await notionRequest('GET', '/v1/users/me', null, token); const elapsed = Date.now() - start; return { healthy: result.status >= 200 && result.status < 300, status: result.status, latency: elapsed, user: result.data?.name || 'unknown', }; } catch (e) { return { healthy: false, status: 0, latency: Date.now() - start, error: e.message, }; } } async function createHeartbeatTask(queueDbId, healthResult, token) { const now = new Date().toISOString(); const taskName = `HEARTBEAT-${now.slice(0, 10)}-${now.slice(11, 19).replace(/:/g, '')}`; const status = healthResult.healthy ? 'โœ… ้€š้“็•…้€š' : 'โŒ ้€š้“ๅผ‚ๅธธ'; const payload = JSON.stringify({ notion_healthy: healthResult.healthy, notion_status: healthResult.status, notion_latency: healthResult.latency, notion_user: healthResult.user || '', error: healthResult.error || '', timestamp: now, }); const properties = { 'ไปปๅŠกๅ็งฐ': { title: titleProp(taskName) }, '็ฑปๅž‹': { select: { name: 'HEARTBEAT' } }, 'ๅค„็†็Šถๆ€': { status: { name: healthResult.healthy ? 'ๅทฒๅฎŒๆˆ' : 'ๅพ…ๅค„็†' } }, 'ๆฅๆบๆธ ้“': { rich_text: richText('้“ธๆธŠๅฟƒ่ทณ') }, 'Payload': { rich_text: richText(payload) }, }; const body = { parent: { database_id: queueDbId }, properties }; try { const result = await notionRequest('POST', '/v1/pages', body, token); if (result.status >= 200 && result.status < 300) { return { success: true, page_id: result.data.id }; } return { success: false, error: `${result.status}: ${JSON.stringify(result.data)}` }; } catch (e) { return { success: false, error: e.message }; } } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // ไธป้€ป่พ‘ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• async function main() { const token = process.env.NOTION_TOKEN; const queueDb = process.env.BRIDGE_QUEUE_DB_ID; console.log('๐Ÿ’“ ๆกฅๆŽฅๅฟƒ่ทณๆฃ€ๆต‹ๅผ€ๅง‹โ€ฆ'); console.log(`โฐ ${new Date().toISOString()}`); const heartbeat = { timestamp: new Date().toISOString(), notion: { healthy: false }, queue: { created: false }, }; // โ‘  ๆต‹่ฏ• Notion API ่ฟž้€šๆ€ง if (token) { console.log('\n๐Ÿ“ก ๆฃ€ๆต‹ Notion API ่ฟž้€šๆ€งโ€ฆ'); heartbeat.notion = await checkNotionHealth(token); if (heartbeat.notion.healthy) { console.log(` โœ… Notion API ๆญฃๅธธ ยท ๅปถ่ฟŸ ${heartbeat.notion.latency}ms ยท ็”จๆˆท: ${heartbeat.notion.user}`); } else { console.error(` โŒ Notion API ๅผ‚ๅธธ ยท ็Šถๆ€ ${heartbeat.notion.status} ยท ${heartbeat.notion.error || ''}`); } } else { console.log(' โš ๏ธ NOTION_TOKEN ๆœช้…็ฝฎ๏ผŒ่ทณ่ฟ‡ Notion ๆฃ€ๆต‹'); } // โ‘ก ๅœจ่ฐƒๅบฆ้˜Ÿๅˆ—ๅˆ›ๅปบๅฟƒ่ทณ่ฎฐๅฝ• if (token && queueDb) { console.log('\n๐Ÿ“‹ ๅ†™ๅ…ฅๅฟƒ่ทณ่ฎฐๅฝ•ๅˆฐ่ฐƒๅบฆ้˜Ÿๅˆ—โ€ฆ'); const queueResult = await createHeartbeatTask(queueDb, heartbeat.notion, token); heartbeat.queue = queueResult; if (queueResult.success) { console.log(` โœ… ๅฟƒ่ทณ่ฎฐๅฝ•ๅทฒๅˆ›ๅปบ: ${queueResult.page_id}`); } else { console.error(` โŒ ๅฟƒ่ทณ่ฎฐๅฝ•ๅˆ›ๅปบๅคฑ่ดฅ: ${queueResult.error}`); } } else { console.log(' โš ๏ธ ่ฐƒๅบฆ้˜Ÿๅˆ—ๆœช้…็ฝฎ๏ผŒ่ทณ่ฟ‡ๅฟƒ่ทณ่ฎฐๅฝ•'); } // โ‘ข ๅ†™ๅ…ฅๆœฌๅœฐๅฟƒ่ทณๆ—ฅๅฟ— const logDir = path.join('data', 'bridge-logs'); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } const logFile = path.join(logDir, `heartbeat-${new Date().toISOString().slice(0, 10)}.json`); let logs = []; if (fs.existsSync(logFile)) { try { logs = JSON.parse(fs.readFileSync(logFile, 'utf8')); } catch (_) { logs = []; } } logs.push(heartbeat); fs.writeFileSync(logFile, JSON.stringify(logs, null, 2)); console.log(`\n๐Ÿ“ ๅฟƒ่ทณๆ—ฅๅฟ—: ${logFile}`); // โ‘ฃ ๆ€ป็ป“ const allHealthy = heartbeat.notion.healthy; console.log(`\n${allHealthy ? 'โœ…' : 'โŒ'} ๅฟƒ่ทณๆฃ€ๆต‹ๅฎŒๆˆ ยท Notion: ${heartbeat.notion.healthy ? 'ๆญฃๅธธ' : 'ๅผ‚ๅธธ'}`); if (process.env.GITHUB_OUTPUT) { fs.appendFileSync(process.env.GITHUB_OUTPUT, `notion_healthy=${heartbeat.notion.healthy}\nheartbeat_status=${allHealthy ? 'ok' : 'error'}\n` ); } // ๅฟƒ่ทณๅผ‚ๅธธไธ้€€ๅ‡บๅคฑ่ดฅ๏ผˆ้ฟๅ… workflow ้ข‘็นๆŠฅ้”™๏ผ‰ } main().catch(e => { console.error(e); process.exit(1); });