/** * alexa-api/server.js * Windmill から Echo デバイスに TTS を送る API サーバー * 直接 Alexa API を叩く実装(alexa-remote2 不使用) * * Endpoints: * POST /speak { device: "デバイス名 or serial", text: "しゃべる内容" } * GET /devices デバイス一覧取得 * GET /health ヘルスチェック */ const https = require('https'); const express = require('express'); const app = express(); app.use(express.json()); const PORT = process.env.PORT || 3500; const ALEXA_HOST = 'alexa.amazon.co.jp'; const ALEXA_COOKIE = process.env.ALEXA_COOKIE; if (!ALEXA_COOKIE) { console.error('[ERROR] ALEXA_COOKIE 環境変数が設定されていません。'); process.exit(1); } // ---- キャッシュ ---- let cachedDevices = null; let cachedCustomerId = null; let deviceCacheExpires = 0; // ---- HTTP ヘルパー ---- function httpsRequest(path, options, extraCookies) { options = options || {}; extraCookies = extraCookies || ''; return new Promise(function(resolve, reject) { var allCookies = ALEXA_COOKIE + (extraCookies ? '; ' + extraCookies : ''); var reqOpts = { hostname: ALEXA_HOST, path: path, method: options.method || 'GET', headers: Object.assign({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'ja-JP,ja;q=0.9', 'Cookie': allCookies, }, options.headers || {}), }; var req = https.request(reqOpts, function(res) { var body = ''; res.on('data', function(d) { body += d; }); res.on('end', function() { resolve({ status: res.statusCode, headers: res.headers, body: body }); }); }); req.on('error', reject); if (options.body) req.write(options.body); req.end(); }); } // ---- Alexa API ヘルパー ---- async function getCsrfToken() { var res = await httpsRequest('/api/language'); var setCookies = res.headers['set-cookie'] || []; var csrfCookieStr = setCookies.find(function(c) { return c.startsWith('csrf='); }); if (!csrfCookieStr) throw new Error('CSRF token not found'); return csrfCookieStr.split('=')[1].split(';')[0]; } async function getCustomerId() { if (cachedCustomerId) return cachedCustomerId; var res = await httpsRequest('/api/bootstrap'); if (res.status !== 200) throw new Error('Bootstrap API failed: ' + res.status); var data = JSON.parse(res.body); cachedCustomerId = data.authentication && data.authentication.customerId; if (!cachedCustomerId) throw new Error('customerId not found'); return cachedCustomerId; } async function getDevices(force) { var now = Date.now(); if (!force && cachedDevices && now < deviceCacheExpires) return cachedDevices; var res = await httpsRequest('/api/devices-v2/device?cached=false'); if (res.status !== 200) throw new Error('Devices API failed: ' + res.status); var data = JSON.parse(res.body); cachedDevices = data.devices || []; deviceCacheExpires = now + 5 * 60 * 1000; return cachedDevices; } function findDevice(devices, nameOrSerial) { var bySerial = devices.find(function(d) { return d.serialNumber === nameOrSerial; }); if (bySerial) return bySerial; var lower = nameOrSerial.toLowerCase(); var byName = devices.find(function(d) { return d.accountName && d.accountName.toLowerCase() === lower; }); if (byName) return byName; return devices.find(function(d) { return d.accountName && d.accountName.toLowerCase().includes(lower); }); } // ---- API エンドポイント ---- // POST /speak app.post('/speak', async function(req, res) { var body = req.body || {}; var device = body.device; var text = body.text; if (!device || !text) { return res.status(400).json({ error: 'device と text は必須です' }); } console.log('[SPEAK] device="' + device + '" text="' + text + '"'); try { var results = await Promise.all([getCsrfToken(), getCustomerId(), getDevices()]); var csrfToken = results[0]; var customerId = results[1]; var devices = results[2]; var target = findDevice(devices, device); if (!target) { var names = devices.map(function(d) { return d.accountName; }).join(', '); return res.status(404).json({ error: 'デバイス "' + device + '" が見つかりません', available: names }); } console.log(' -> ' + target.accountName + ' (type=' + target.deviceType + ', serial=' + target.serialNumber + ')'); var sequenceObj = { '@type': 'com.amazon.alexa.behaviors.model.Sequence', startNode: { '@type': 'com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode', type: 'Alexa.Speak', operationPayload: { deviceType: target.deviceType, deviceSerialNumber: target.serialNumber, customerId: customerId, locale: 'ja-JP', textToSpeak: text, }, }, }; var bodyStr = JSON.stringify({ behaviorId: 'PREVIEW', sequenceJson: JSON.stringify(sequenceObj), status: 'ENABLED', }); var ttsRes = await httpsRequest('/api/behaviors/preview', { method: 'POST', headers: { 'Content-Type': 'application/json', 'csrf': csrfToken, 'Referer': 'https://alexa.amazon.co.jp/spa/index.html', 'Origin': 'https://alexa.amazon.co.jp', }, body: bodyStr, }, 'csrf=' + csrfToken); if (ttsRes.status === 200 || ttsRes.status === 202) { console.log(' [OK] TTS sent to ' + target.accountName); res.json({ ok: true, device: target.accountName, text: text }); } else { console.error(' [ERROR] TTS failed: ' + ttsRes.status + ' ' + ttsRes.body); res.status(502).json({ error: 'Alexa API error: ' + ttsRes.status, body: ttsRes.body }); } } catch (err) { console.error('[ERROR] /speak:', err.message); res.status(500).json({ error: err.message }); } }); // GET /devices app.get('/devices', async function(req, res) { try { var devices = await getDevices(true); res.json(devices.map(function(d) { return { name: d.accountName, type: d.deviceType, serial: d.serialNumber, online: d.online, family: d.deviceFamily }; })); } catch (err) { console.error('[ERROR] /devices:', err.message); res.status(500).json({ error: err.message }); } }); // GET /health app.get('/health', function(req, res) { res.json({ ok: true, cookieLength: ALEXA_COOKIE.length }); }); // ---- 起動 ---- app.listen(PORT, '0.0.0.0', async function() { console.log('[INFO] alexa-api server listening on port ' + PORT); try { var customerId = await getCustomerId(); console.log('[INFO] Customer ID: ' + customerId); var devices = await getDevices(); var echoDevices = devices.filter(function(d) { return (d.deviceType && (d.deviceType.startsWith('A4ZXE') || d.deviceType.startsWith('ASQZWP'))); }); console.log('[INFO] Echo devices: ' + echoDevices.map(function(d) { return d.accountName; }).join(', ')); } catch (err) { console.error('[WARN] Startup init failed:', err.message); } });