UNPKG

ln-telegram

Version:
382 lines (310 loc) 12.5 kB
const asyncAuto = require('async/auto'); const asyncMap = require('async/map'); const asyncReflect = require('async/reflect'); const {DateTime} = require('luxon'); const {decodeChanId} = require('bolt07'); const {findKey} = require('ln-sync'); const {getBorderCharacters} = require('table'); const {getHeight} = require('ln-service'); const {getNode} = require('ln-service'); const {getNodeAlias} = require('ln-sync'); const {parsePaymentRequest} = require('ln-service'); const renderTable = require('table').table; const {returnResult} = require('asyncjs-util'); const {checkAccess} = require('./../authentication'); const {formatTokens} = require('./../interface'); const {icons} = require('./../interface'); const {makeRemoveButton} = require('./../buttons'); const {isArray} = Array; const argsFromText = text => text.split(' '); const bigType = 'large_channels'; const blockTime = (now, start) => Date.now() - 1000 * 60 * 10 * (now - start); const border = getBorderCharacters('void'); const displayFee = (n, rate) => !n.length ? '' : `${(rate / 1e4).toFixed(2)}%`; const escape = text => text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, '\\\$&'); const expectedQueryErrorMessage = 'ExpectedQueryForGraphCommand'; const formatAmount = tokens => formatTokens({tokens}).display; const fromNow = ms => !ms ? undefined : DateTime.fromMillis(ms).toRelative(); const header = [' ', ' ', 'In %', 'Capacity', 'Out %']; const ipv4Match = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/; const ipv6Match = /^[a-fA-F0-9:]+$/; const isEmoji = /[^\p{L}\p{N}\p{P}\p{Z}{\^\$}]/gu; const limitPeers = peers => peers.slice(0, 6); const markup = {parse_mode: 'MarkdownV2'}; const {max} = Math; const niceAlias = (alias, id) => (alias.trim() || id).substring(0, 16); const noEmoji = str => str.replace(isEmoji, String()).trim(); const noQueryMsg = 'Missing graph query, try `/graph (public key/peer alias)`'; const none = ' '; const notFoundCode = 404; const notFoundMsg = query => `\`${query}\` not found\\\. Wrong public key?`; const replyMarkdownV1 = reply => n => reply(n, {parse_mode: 'Markdown'}); const sanitize = n => (n || '').replace(/_/g, '\\_').replace(/[*~`]/g, ''); const shortKey = key => key.substring(0, 16); const socketHost = n => n.split(':').slice(0, -1).join(':'); const sumOf = arr => arr.reduce((sum, n) => sum + n, 0); const torV3Match = /[a-z2-7]{56}.onion/i; const uniq = arr => Array.from(new Set(arr)); /** Get details about a node in the graph Syntax of command: /graph <pubkey> { from: <Command From User Id Number> id: <Connected User Id Number> nodes: [{ from: <From Name String> lnd: <Authenticated LND API Object> public_key: <Public Key Hex String> }] reply: <Reply Function> text: <Original Command Text String> working: <Working Function> } */ module.exports = ({from, id, nodes, remove, reply, text, working}, cbk) => { return new Promise((resolve, reject) => { return asyncAuto({ // Check arguments validate: cbk => { if (!from) { return cbk([400, 'ExpectedFromUserIdNumberForGraphCommand']); } if (!isArray(nodes)) { return cbk([400, 'ExpectedNodesForGraphCommand']); } if (!reply) { return cbk([400, 'ExpectedReplyFunctionForGraphCommand']); } if (!text) { return cbk([400, 'ExpectedOriginalCommandTextForGraphCommand']); } if (!working) { return cbk([400, 'ExpectedWorkingFunctionForGraphCommand']); } return cbk(); }, // Authenticate that the caller is authorized to call this command checkAccess: ['validate', ({}, cbk) => checkAccess({from, id}, cbk)], // Remove the query remove: ['checkAccess', async ({}) => { try { return await remove(); } catch (err) { // Ignore errors return; } }], // Derive the public key query if present query: ['checkAccess', ({}, cbk) => { const [, ...query] = argsFromText(text); const request = query.join(' '); // Check if a payment request was entered try { const {destination} = parsePaymentRequest({request}); return cbk(null, destination); } catch (err) { // Ignore errors } return cbk(null, request); }], // Send indication that the graph command has started working init: ['query', async ({}) => await working()], // Get public key filter getKey: ['query', asyncReflect(({query}, cbk) => { if (!query) { return cbk([400, expectedQueryErrorMessage]); } // Look for a match return asyncMap(nodes, ({lnd}, cbk) => { return findKey({lnd, query}, (err, res) => { if (!!err) { return cbk(null, {}); } return cbk(null, {lnd, id: res.public_key}); }); }, cbk); })], // Get the current block height for looking at peer age getHeight: ['getKey', ({getKey}, cbk) => { const [node] = (getKey.value || []).filter(n => !!n.id); // Exit early when there is no key if (!node) { return cbk(); } return getHeight({lnd: node.lnd}, cbk); }], // Get node info, exit early if one saved node returns data getNodeInfo: ['query', 'getKey', asyncReflect(({query, getKey}, cbk) => { // Exit early when there is no get key result if (!!getKey.error) { return cbk(); } const [node] = getKey.value.filter(n => !!n.id); if (!node) { return cbk([404, 'FailedToFindMatchingNodeForQuery']); } return getNode({lnd: node.lnd, public_key: node.id}, (err, res) => { if (!!err) { return cbk(err); } const keys = res.channels.map(({policies}) => { return policies.find(n => n.public_key !== node.id).public_key; }); const isMissingCapacity = !!res.channels.find(n => !n.capacity); return cbk(null, { alias: res.alias, capacity: !isMissingCapacity ? res.capacity : undefined, channels: res.channels, features: res.features, id: node.id, lnd: node.lnd, peers: uniq(keys), sockets: res.sockets.map(n => n.socket), }); }); })], // Put together a summary of recent peers latest: ['getHeight', 'getNodeInfo', ({getHeight, getNodeInfo}, cbk) => { // Exit early when there is no node info if (!getNodeInfo.value) { return cbk(); } try { const nodeInfo = getNodeInfo.value; const peers = nodeInfo.peers.map(peerKey => { const capacity = nodeInfo.channels .filter(n => !!n.policies.find(n => n.public_key === peerKey)) .reduce((sum, {capacity}) => sum + capacity, Number()); const height = max(...nodeInfo.channels .filter(n => !!n.policies.find(n => n.public_key === peerKey)) .map(({id}) => decodeChanId({channel: id}).block_height)); const inPolicies = nodeInfo.channels .map(n => n.policies.find(n => n.public_key === peerKey)) .filter(n => !!n && n.fee_rate !== undefined); const outPolicies = nodeInfo.channels .filter(n => !!n.policies.find(n => n.public_key === peerKey)) .map(n => n.policies.find(n => n.public_key !== peerKey)) .filter(n => n.fee_rate !== undefined); const inboundFeeRate = max(...inPolicies.map(n => n.fee_rate)); const outFeeRate = max(...outPolicies.map(n => n.fee_rate)); const row = [ peerKey, fromNow(blockTime(getHeight.current_block_height, height)), displayFee(inPolicies, inboundFeeRate), formatTokens({none, tokens: capacity}).display, displayFee(outPolicies, outFeeRate), ]; return {height, row}; }); peers.sort((a, b) => b.height - a.height); return cbk(null, { lnd: nodeInfo.lnd, rows: limitPeers(peers.map(n => n.row)), }); } catch (err) { return cbk([503, 'UnexpectedErrorAssemblingPeers', {err}]); } }], // Get peer rows but substitute in aliases peers: ['latest', ({latest}, cbk) => { if (!latest) { return cbk(); } return asyncMap(latest.rows, ([id], cbk) => { return getNodeAlias({id, lnd: latest.lnd}, cbk); }, (err, nodes) => { if (!!err) { return cbk(err); } const withAliases = latest.rows.map(row => { const [id, ...cols] = row; const node = nodes.find(n => n.id === id); return [niceAlias(noEmoji(node.alias), node.id)].concat(cols); }); try { const chart = renderTable([header].concat(withAliases), { border, singleLine: true, }); return cbk(null, `\`${escape(chart)}\``); } catch (err) { return cbk(null, ''); } }); }], // Put together the fetched node info into a concise summary of the node summary: ['getNodeInfo', 'peers', ({getNodeInfo, peers}, cbk) => { // Exit early when there is no node info if (!getNodeInfo.value) { return cbk(); } const node = getNodeInfo.value; const capacity = `${formatAmount(node.capacity)} capacity `; const isBig = !!node.features.find(n => n.type === bigType); const isIpV4 = !!node.sockets.find(n => ipv4Match.test(socketHost(n))); const isIpV6 = !!node.sockets.find(n => ipv6Match.test(socketHost(n))); const isTor = !!node.sockets.find(n => torV3Match.test(socketHost(n))); const isClearnet = isIpV4 || isIpV6; const isUnconnectable = !isClearnet && !isTor; const [connection] = [ isUnconnectable ? `They do not publish any network address.` : '', !isClearnet && isTor ? 'Only Tor connections are supported.' : '', isClearnet ? 'Clearnet connections are accepted.' : '', ].filter(n => !!n); const summary = [ `A ${!!node.capacity ? escape(capacity) : ''}node`, ` with ${node.peers.length} peer${node.peers.length > 1 ? 's' : ''}`, isBig ? ' that accepts large channels' : '', escape('.'), !!connection ? ` ${escape(connection)}` : '', ]; const report = [ `Node: *${escape(node.alias) || shortKey(node.id)}* \`${node.id}\``, summary.filter(n => !!n).join(''), peers, ]; return cbk(null, report.join('\n')); }], // Send a failure message sendFailure: [ 'getKey', 'getNodeInfo', 'query', async ({getKey, getNodeInfo, query}) => { // Exit early when there is no failure to send if (!getKey.error && !getNodeInfo.error) { return; } const [code, msg] = getKey.error || getNodeInfo.error; const icon = icons.bot; const parseMode = markup.parse_mode; const removeButton = makeRemoveButton({}).markup; const options = {parse_mode: parseMode, reply_markup: removeButton}; // Exit early when the user entered a public key that can't be found if (code === notFoundCode) { const entry = escape(query); return await reply(`${icon} ${notFoundMsg(entry)}`, options); } // Exit early when the user entered no query message at all if (msg === expectedQueryErrorMessage) { return await reply(`${icon} ${noQueryMsg}`, options); } // Return the unexpected failure message const message = `${icon} Failed to find match: \`${escape(msg)}\``; return await reply(message, options); }], // Send the summary response sendSuccess: ['summary', async ({summary}) => { // Exit early when there is no summary to send if (!summary) { return; } return await reply(summary, markup); }], }, returnResult({reject, resolve}, cbk)); }); };