UNPKG

node-red-contrib-telegrambot

Version:
226 lines (212 loc) 9.09 kB
// A small HTTP server that impersonates api.telegram.org well enough for // node-telegram-bot-api's polling and send paths to talk to it. // // Usage: // const { startMock } = require('./telegram-mock'); // const mock = await startMock(); // const flow = [{ ..., baseapiurl: mock.url, updatemode: 'polling' }]; // ... // mock.pushUpdate({ update_id: 1, message: { ... } }); // ... // await mock.stop(); // // All endpoints respond with the standard Bot API envelope: // { ok: true, result: <result> } on success // { ok: false, error_code: N, description: "..." } on failure // // State is exposed for assertions: // mock.calls array of { method, body, query } in arrival order // mock.pushUpdate(u) enqueues an update for the next getUpdates poll // mock.failNext(spec) forces the next call to one method to return the given // error; useful for retry-path tests // // The server uses HTTP (not HTTPS) and binds to 127.0.0.1 on a random port. const http = require('http'); const url = require('url'); const { Buffer } = require('buffer'); function parseFormBody(buf) { const text = buf.toString('utf8'); const out = {}; if (!text) return out; text.split('&').forEach(function (pair) { const eq = pair.indexOf('='); const k = decodeURIComponent(pair.substr(0, eq).replace(/\+/g, ' ')); const v = decodeURIComponent(pair.substr(eq + 1).replace(/\+/g, ' ')); out[k] = v; }); return out; } function parseMultipart(buf, boundary) { // Bare-bones multipart parser sufficient for the form-field name=value // pairs node-telegram-bot-api sends for sendPhoto etc. File parts are // captured as Buffer values. const out = {}; const sep = Buffer.from('--' + boundary); let start = 0; while (true) { const idx = buf.indexOf(sep, start); if (idx === -1) break; const next = buf.indexOf(sep, idx + sep.length); if (next === -1) break; const partStart = idx + sep.length; // Skip the CRLF after the boundary let s = partStart; if (buf[s] === 0x0d && buf[s + 1] === 0x0a) s += 2; // Read headers const headerEnd = buf.indexOf(Buffer.from('\r\n\r\n'), s); if (headerEnd === -1 || headerEnd >= next) break; const headers = buf.slice(s, headerEnd).toString('utf8'); const bodyStart = headerEnd + 4; // Body ends 2 bytes before the next boundary (CRLF) const bodyEnd = next - 2; const body = buf.slice(bodyStart, bodyEnd); const m = headers.match(/name="([^"]+)"/); if (m) { out[m[1]] = body.length < 1024 && !/filename=/.test(headers) ? body.toString('utf8') : body; } start = next; } return out; } function startMock() { const state = { calls: [], updates: [], failures: {}, // method -> { code, description, retry_after } webhookInfo: { url: '' }, nextMessageId: 1000, }; function readBody(req) { return new Promise(function (resolve) { const chunks = []; req.on('data', function (c) { chunks.push(c); }); req.on('end', function () { resolve(Buffer.concat(chunks)); }); }); } const server = http.createServer(async function (req, res) { try { const parsed = url.parse(req.url, true); const m = parsed.pathname.match(/^\/bot([^/]+)\/([^/?#]+)$/); if (!m) { res.statusCode = 404; res.end('not found'); return; } const method = m[2]; const raw = await readBody(req); let body = {}; const ct = req.headers['content-type'] || ''; if (ct.includes('application/json')) { try { body = JSON.parse(raw.toString('utf8')); } catch (e) { body = {}; } } else if (ct.includes('application/x-www-form-urlencoded')) { body = parseFormBody(raw); } else if (ct.includes('multipart/form-data')) { const boundary = (ct.match(/boundary=(.+)$/) || [])[1]; if (boundary) body = parseMultipart(raw, boundary); } // node-telegram-bot-api hits inconsistently-cased URLs across methods // (setWebHook keeps the capital H, but deleteWebhook / getWebhookInfo lowercase // the h). Normalise to lowercase internally so the response dispatch and the // tests' callsTo()/failNext() lookups work regardless of which case any given // version of the library happens to use today. const methodKey = method.toLowerCase(); state.calls.push({ method: method, methodKey: methodKey, body: body, query: parsed.query }); // Force-failure injection for the next call to this method if (state.failures[methodKey]) { const fail = state.failures[methodKey]; delete state.failures[methodKey]; res.setHeader('content-type', 'application/json'); res.statusCode = fail.code || 400; res.end( JSON.stringify({ ok: false, error_code: fail.code || 400, description: fail.description || 'forced failure', parameters: fail.retry_after ? { retry_after: fail.retry_after } : undefined, }) ); return; } let result; if (methodKey === 'getme') { result = { id: 1, is_bot: true, first_name: 'mock', username: 'mockbot' }; } else if (methodKey === 'getupdates') { // The library passes the next-expected `offset`; we just return what we have. const updates = state.updates.slice(); state.updates = []; result = updates; } else if (methodKey === 'sendmessage') { result = { message_id: state.nextMessageId++, chat: { id: Number(body.chat_id), type: 'private' }, date: Math.floor(Date.now() / 1000), text: body.text, }; } else if (methodKey === 'setwebhook') { state.webhookInfo = { url: body.url || (parsed.query && parsed.query.url) || '', has_custom_certificate: false, pending_update_count: 0 }; result = true; } else if (methodKey === 'deletewebhook') { state.webhookInfo = { url: '' }; result = true; } else if (methodKey === 'getwebhookinfo') { result = state.webhookInfo; } else if (methodKey === 'setmycommands' || methodKey === 'deletemycommands') { result = true; } else if (methodKey === 'answercallbackquery') { result = true; } else if (methodKey.startsWith('send') || methodKey.startsWith('edit') || methodKey.startsWith('forward') || methodKey.startsWith('copy')) { result = { message_id: state.nextMessageId++ }; } else { result = true; } res.setHeader('content-type', 'application/json'); res.statusCode = 200; res.end(JSON.stringify({ ok: true, result: result })); } catch (err) { res.statusCode = 500; res.end(String(err && err.message)); } }); return new Promise(function (resolve) { server.listen(0, '127.0.0.1', function () { const port = server.address().port; resolve({ url: 'http://127.0.0.1:' + port, state: state, calls: state.calls, pushUpdate: function (u) { if (typeof u.update_id !== 'number') u.update_id = (u.update_id = (state.updates.length || 0) + 1); state.updates.push(u); }, failNext: function (method, spec) { state.failures[method.toLowerCase()] = spec; }, callsTo: function (method) { const key = method.toLowerCase(); return state.calls.filter(function (c) { return c.methodKey === key; }); }, clearCalls: function () { state.calls.length = 0; }, stop: function () { return new Promise(function (resolveStop) { server.close(function () { resolveStop(); }); }); }, }); }); }); } module.exports = { startMock };