loopengine 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/core/agent-config.d.ts +2 -0
  2. package/dist/core/http-notifier.js +14 -3
  3. package/dist/core/http-notify-triggers/telegram.d.ts +20 -1
  4. package/dist/core/http-notify-triggers/telegram.js +63 -17
  5. package/dist/core/messaging/connector.d.ts +7 -0
  6. package/dist/core/messaging/connector.js +1 -1
  7. package/dist/core/messaging/connectors/slack/events.d.ts +2 -1
  8. package/dist/core/messaging/connectors/slack/events.js +6 -2
  9. package/dist/core/messaging/connectors/slack/setup.d.ts +2 -1
  10. package/dist/core/messaging/connectors/slack/setup.js +10 -5
  11. package/dist/core/messaging/connectors/telegram/format.d.ts +6 -0
  12. package/dist/core/messaging/connectors/telegram/format.js +57 -0
  13. package/dist/core/messaging/connectors/telegram/index.d.ts +39 -0
  14. package/dist/core/messaging/connectors/telegram/index.js +136 -0
  15. package/dist/core/messaging/connectors/telegram/store.d.ts +31 -0
  16. package/dist/core/messaging/connectors/telegram/store.js +69 -0
  17. package/dist/core/messaging/connectors/telegram/updates.d.ts +40 -0
  18. package/dist/core/messaging/connectors/telegram/updates.js +112 -0
  19. package/dist/core/messaging/jobs.d.ts +44 -0
  20. package/dist/core/messaging/jobs.js +100 -0
  21. package/dist/core/messaging/threads.d.ts +12 -0
  22. package/dist/core/messaging/threads.js +36 -0
  23. package/dist/core/notifier-file.d.ts +13 -5
  24. package/dist/core/notifier-file.js +41 -6
  25. package/dist/core/system-agents/workflow-planner.d.ts +37 -4
  26. package/dist/core/system-agents/workflow-planner.js +79 -5
  27. package/dist/core/workflow-triggers.d.ts +15 -5
  28. package/dist/core/workflow-triggers.js +25 -8
  29. package/dist/web/agents-config-page.js +168 -50
  30. package/dist/web/notifier-admin.d.ts +69 -13
  31. package/dist/web/notifier-admin.js +127 -31
  32. package/dist/web/workflow-admin.d.ts +10 -0
  33. package/dist/web/workflow-admin.js +42 -3
  34. package/dist/web/workflow-page.js +93 -2
  35. package/package.json +1 -1
@@ -860,7 +860,7 @@ ${consoleShellScript}
860
860
  started: ['good', 'Started a run'], filtered: ['', 'Skipped by the filter'], duplicate: ['', 'A retry, skipped'],
861
861
  busy: ['', 'Already running'], rejected: ['bad', 'Signature check failed'], invalid: ['bad', 'Couldn' + String.fromCharCode(8217) + 't start'], error: ['bad', 'Failed'],
862
862
  };
863
- var HOOK_SENDERS = { stripe: 'Stripe', github: 'GitHub', shopify: 'Shopify', standard: 'Standard Webhooks' };
863
+ var HOOK_SENDERS = { stripe: 'Stripe', github: 'GitHub', shopify: 'Shopify', standard: 'Standard Webhooks', custom: 'your app' };
864
864
 
865
865
  function hookRowHtml(t) {
866
866
  var last = (t.deliveries || []).slice(0, 3).map(function (d) {
@@ -870,20 +870,30 @@ ${consoleShellScript}
870
870
  }).join('');
871
871
  var rules = [];
872
872
  if (t.verify) rules.push(t.verify.sender === 'custom'
873
- ? 'checks the signature in ' + escapeHtml(t.verify.header) + ' (' + escapeHtml(t.verify.algorithm || 'sha256') + ', ' + escapeHtml(t.verify.encoding || 'hex') + ')'
873
+ ? 'checks the signature in ' + escapeHtml(t.verify.header || '(header not set yet)') + ' (' + escapeHtml(t.verify.algorithm || 'sha256') + ', ' + escapeHtml(t.verify.encoding || 'hex') + ')'
874
874
  : 'checks the ' + escapeHtml(HOOK_SENDERS[t.verify.sender] || t.verify.sender) + ' signature');
875
875
  if (t.filter) rules.push('only when ' + escapeHtml(t.filter.path) + ' is ' + t.filter.values.map(escapeHtml).join(' or '));
876
876
  if (t.mapping) rules.push(Object.keys(t.mapping).map(function (p) { return escapeHtml(p) + ' ' + String.fromCharCode(8592) + ' ' + escapeHtml(t.mapping[p]); }).join(', '));
877
877
  if (t.payload_param) rules.push('the whole payload goes to ' + escapeHtml(t.payload_param));
878
878
  if (t.notify) rules.push('sends each result to you');
879
879
  if (t.callback_url) rules.push('reports to ' + escapeHtml(t.callback_url));
880
+ // Made by the workflow planner without its secret: it turns on once
881
+ // the sender's signing secret is added.
882
+ var needsSecret = t.verify && !t.verify.secret_set;
880
883
  return '<div class="workflow-hook" data-trigger="' + escapeHtml(t.trigger_id) + '">' +
881
884
  '<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;align-items:baseline"><strong>' + escapeHtml(t.name) + '</strong>' +
882
- '<code class="hint">/hooks/workflows/' + escapeHtml(t.token_hint) + '</code>' + (t.enabled ? '' : ' <span class="le-pill">Off</span>') + '</div>' +
885
+ '<code class="hint">/hooks/workflows/' + escapeHtml(t.token_hint) + '</code>' + (needsSecret ? ' <span class="le-pill warn">Needs its signing secret</span>' : t.enabled ? '' : ' <span class="le-pill">Off</span>') + '</div>' +
883
886
  (rules.length ? '<p class="hint" style="margin:2px 0">' + rules.join(' ' + String.fromCharCode(183) + ' ') + '</p>' : '') +
887
+ (needsSecret
888
+ ? '<div class="workflow-param-row" style="margin:4px 0">' +
889
+ (t.verify.sender === 'custom' && !t.verify.header ? '<input class="hook-secret-header" placeholder="Signature header, e.g. X-Signature">' : '') +
890
+ '<input class="hook-secret-input" type="password" autocomplete="off" placeholder="' + escapeHtml(t.verify.sender === 'custom' ? 'The secret your app signs with' : 'The signing secret ' + (HOOK_SENDERS[t.verify.sender] || 'the sender') + ' shows') + '" style="flex:1">' +
891
+ '<button type="button" class="primary hook-secret-save">Add and turn on</button></div>' +
892
+ '<p class="hint" style="margin:0">Paste the URL into the sender first. Lost it? New URL makes another.</p>'
893
+ : '') +
884
894
  (last ? '<ul class="schedule-history">' + last + '</ul>' : '<p class="hint" style="margin:2px 0">No deliveries yet.</p>') +
885
895
  '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:4px">' +
886
- '<button type="button" class="hook-toggle">' + (t.enabled ? 'Turn off' : 'Turn on') + '</button>' +
896
+ (needsSecret ? '' : '<button type="button" class="hook-toggle">' + (t.enabled ? 'Turn off' : 'Turn on') + '</button>') +
887
897
  '<button type="button" class="hook-rotate">New URL</button>' +
888
898
  '<button type="button" class="delete-btn hook-delete">Delete</button></div></div>';
889
899
  }
@@ -930,11 +940,14 @@ ${consoleShellScript}
930
940
  workflowEditing = true;
931
941
  box.hidden = false;
932
942
  var base = '/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(w.id) + '/triggers';
943
+ var hooksById = {};
933
944
  function render(shownUrl) {
934
945
  box.innerHTML = '<p class="hint" style="margin:0">Loading' + String.fromCharCode(8230) + '</p>';
935
946
  fetchJsonRetryOnce(base).then(function (r) {
936
947
  if (!r.ok) { box.innerHTML = '<p class="error">' + escapeHtml(r.body.error || 'Could not load webhooks') + '</p>'; return; }
937
948
  var list = r.body.triggers || [];
949
+ hooksById = {};
950
+ list.forEach(function (t) { hooksById[t.trigger_id] = t; });
938
951
  box.innerHTML = '<h4 style="margin:10px 0 4px">Webhooks</h4>' +
939
952
  '<p class="hint" style="margin:0 0 6px">A secret URL an outside service (Stripe, GitHub, a form, monitoring) posts its events to; each one starts a run.</p>' +
940
953
  (shownUrl ? hookUrlHtml(shownUrl) : '') +
@@ -953,9 +966,25 @@ ${consoleShellScript}
953
966
  for (var i = 0; i < rows.length; i++) {
954
967
  (function (row) {
955
968
  var id = row.dataset.trigger;
956
- row.querySelector('.hook-toggle').addEventListener('click', function (ev) {
969
+ var toggle = row.querySelector('.hook-toggle');
970
+ if (toggle) toggle.addEventListener('click', function (ev) {
957
971
  act(id, '', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled: ev.currentTarget.textContent === 'Turn on' }) });
958
972
  });
973
+ var saveSecret = row.querySelector('.hook-secret-save');
974
+ if (saveSecret) saveSecret.addEventListener('click', function () {
975
+ var t = hooksById[id];
976
+ var secret = row.querySelector('.hook-secret-input').value.trim();
977
+ if (!secret || !t) return;
978
+ var verify = { sender: t.verify.sender, secret: secret };
979
+ if (t.verify.sender === 'custom') {
980
+ var headerInput = row.querySelector('.hook-secret-header');
981
+ verify.header = headerInput ? headerInput.value.trim() : t.verify.header;
982
+ verify.algorithm = t.verify.algorithm || 'sha256';
983
+ verify.encoding = t.verify.encoding || 'hex';
984
+ if (t.verify.prefix) verify.prefix = t.verify.prefix;
985
+ }
986
+ act(id, '', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ verify: verify, enabled: true }) });
987
+ });
959
988
  row.querySelector('.hook-rotate').addEventListener('click', function (ev) {
960
989
  var btn = ev.currentTarget;
961
990
  if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again: the old URL stops working'; return; }
@@ -1576,17 +1605,34 @@ ${consoleShellScript}
1576
1605
  return '<strong>Nowhere yet.</strong> Set up an app below; the first messaging app you save becomes primary.' + fallback + rule;
1577
1606
  }
1578
1607
 
1579
- // Slack only: whether replies in a request's thread are answered. On
1608
+ // Slack and Telegram: whether replies to a request are answered. On
1580
1609
  // unless turned off; locked on LoopEngine Cloud's free plan.
1581
- function renderConversationsField(n, f) {
1610
+ function renderConversationsField(n, f, app) {
1582
1611
  var free = n.plan === 'free';
1583
1612
  var on = !free && f.conversations !== false;
1613
+ var where = app === 'telegram' ? 'Answer replies to a request' : 'Answer replies in a request&#39;s thread';
1584
1614
  return '<div class="body-field" style="margin:4px 0 0">' +
1585
- '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="conversations" id="slackConversations"' + (on ? ' checked' : '') + (free ? ' disabled' : '') + '> ' +
1586
- 'Answer replies in a request&#39;s thread' + (free ? ' <span class="le-pill accent">Pro</span>' : '') + '</label>' +
1615
+ '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="conversations" id="' + app + 'Conversations"' + (on ? ' checked' : '') + (free ? ' disabled' : '') + '> ' +
1616
+ where + (free ? ' <span class="le-pill accent">Pro</span>' : '') + '</label>' +
1587
1617
  '<p class="hint" style="margin:2px 0 0">' + (free
1588
- ? 'Talking a request through in Slack needs LoopEngine Pro. Approve and Deny buttons work on every plan.'
1589
- : 'People can ask the agent about a request in its thread, and it answers there. Approvers (below) can also decide it there.') + '</p></div>';
1618
+ ? 'Talking a request through in ' + CHANNEL_TITLES[app] + ' needs LoopEngine Pro. Approve and Deny buttons work on every plan.'
1619
+ : (app === 'telegram'
1620
+ ? 'People can reply to a request to ask the agent about it, and it answers there. Approvers (below) can also decide it that way.'
1621
+ : 'People can ask the agent about a request in its thread, and it answers there. Approvers (below) can also decide it there.')) + '</p></div>';
1622
+ }
1623
+
1624
+ // Slack and Telegram: whether a DM to the bot, or a mention of it,
1625
+ // runs the agent. Off unless turned on; locked on the free plan.
1626
+ function renderJobsField(n, f, app) {
1627
+ var free = n.plan === 'free';
1628
+ var on = !free && f.jobs === true;
1629
+ var how = app === 'telegram'
1630
+ ? 'A private chat with the bot, or @mentioning it in a group, runs the agent on that message, and it answers with a reply. Replying to its answer carries the job on. In a group the bot only sees mentions when it&#39;s an admin or its privacy mode is off (@BotFather, /setprivacy); replies to its messages always reach it.'
1631
+ : 'A DM to the bot, or @mentioning it in a channel, runs the agent on that message, and it answers in a thread. Replies in the thread carry the job on.';
1632
+ return '<div class="body-field" style="margin:4px 0 0">' +
1633
+ '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="jobs" id="' + app + 'Jobs"' + (on ? ' checked' : '') + (free ? ' disabled' : '') + '> ' +
1634
+ 'Start jobs from messages' + (free ? ' <span class="le-pill accent">Pro</span>' : '') + '</label>' +
1635
+ '<p class="hint" style="margin:2px 0 0">' + (free ? 'Starting jobs from ' + CHANNEL_TITLES[app] + ' needs LoopEngine Pro.' : how + ' Only approvers (below) can give it jobs; with none listed, anyone in its ' + (app === 'telegram' ? 'chat' : 'channel') + ' can, but not by direct message. A request it needs approved waits in the same thread.') + '</p></div>';
1590
1636
  }
1591
1637
 
1592
1638
  // A secret's state at a glance: a pill by the label, and a placeholder
@@ -1631,7 +1677,15 @@ ${consoleShellScript}
1631
1677
  '</ol></details>' +
1632
1678
  secretField(n, 'telegram', 'Bot token') +
1633
1679
  '<label>Chat ID <span class="hint">(a group or channel id like -1001234567890, or @channelname)</span><input name="chatId" placeholder="-1001234567890" value="' + escapeHtml(f.chatId || '') + '"></label>' +
1634
- '<p class="hint" style="margin:0">Each message links to a page where the person approves, denies or answers; there are no buttons in Telegram yet.</p>';
1680
+ '<div class="body-field" style="margin:0">Replies and buttons' +
1681
+ '<p class="hint" style="margin:2px 0 6px">' + (n.telegram && n.telegram.webhookSet
1682
+ ? 'Telegram sends the bot&#39;s messages and button clicks to this server, so requests have Approve and Deny buttons and replies reach the agent.'
1683
+ : 'Each message links to a page where the person approves, denies or answers. Connect the bot to this server (save the bot token first) to add Approve and Deny buttons and let people reply.') + '</p>' +
1684
+ '<span style="display:flex;gap:6px;flex-wrap:wrap"><button type="button" id="telegramWebhookSet"' + (n.secretsSet && n.secretsSet.telegram ? '' : ' disabled') + '>' + (n.telegram && n.telegram.webhookSet ? 'Connect again' : 'Connect the bot') + '</button>' +
1685
+ (n.telegram && n.telegram.webhookSet ? '<button type="button" id="telegramWebhookCheck">Check connection</button>' : '') + '</span>' +
1686
+ '<div class="hint" id="telegramWebhookResult"></div></div>' +
1687
+ renderConversationsField(n, f, 'telegram') +
1688
+ renderJobsField(n, f, 'telegram');
1635
1689
  }
1636
1690
  if (channel === 'slack') {
1637
1691
  return '<details class="slack-step"' + (n.secretsSet && n.secretsSet.slack ? '' : ' open') + '><summary>Create the Slack app (once)</summary>' +
@@ -1652,7 +1706,8 @@ ${consoleShellScript}
1652
1706
  (n.slack && n.slack.signingSecretSet ? 'buttons and replies in Slack are on' : 'needed for buttons and replies in Slack; without it, messages only carry the approval link') + ')</span>' +
1653
1707
  '<input type="password" name="signingSecret" autocomplete="off" placeholder="' + escapeHtml(secretPlaceholder(n.slack && n.slack.signingSecretSet, 'Paste the Signing Secret')) + '"></label>' +
1654
1708
  '<p class="hint" style="margin:0">Buttons send their clicks to <code id="slackInteractivityUrl"></code> (the manifest already points there).</p>' +
1655
- renderConversationsField(n, f) +
1709
+ renderConversationsField(n, f, 'slack') +
1710
+ renderJobsField(n, f, 'slack') +
1656
1711
  '<p class="hint" style="margin:0" id="slackEventsNote"></p>';
1657
1712
  }
1658
1713
  if (channel === 'lark') {
@@ -1757,40 +1812,48 @@ ${consoleShellScript}
1757
1812
  return html;
1758
1813
  }
1759
1814
 
1760
- // Slack only: the people who can decide by replying in a request's
1761
- // thread, and whose button clicks count once any are listed. Shown once
1762
- // a Slack connection is saved; they only act while Slack is primary.
1763
- function renderApproversHtml(n) {
1764
- var slack = connectionFor(n, 'slack');
1765
- if (n.source === 'code' || !slack) return '';
1766
- var approvers = slack.approvers || [];
1815
+ // Slack and Telegram: the people who can decide by replying to a
1816
+ // request, whose button clicks count once any are listed, and who can
1817
+ // give the agent jobs. Shown once that app's connection is saved.
1818
+ function renderApproversHtml(n, app) {
1819
+ var conn = connectionFor(n, app);
1820
+ if (n.source === 'code' || !conn) return '';
1821
+ var title = CHANNEL_TITLES[app];
1822
+ var approvers = conn.approvers || [];
1767
1823
  var rows = approvers.map(function (a) {
1768
1824
  return '<tr><td>' + escapeHtml(a.name || a.user) + '</td><td><code>' + escapeHtml(a.user) + '</code></td>' +
1769
1825
  '<td><button type="button" class="delete-btn approver-remove" data-user="' + escapeHtml(a.user) + '"' + (n.canSave === false ? ' disabled' : '') + '>Remove</button></td></tr>';
1770
1826
  }).join('');
1771
- return '<section id="approversSection"><h3>Slack approvers</h3>' +
1772
- (n.primary !== 'slack' ? '<p class="hint" style="margin:0 0 8px">Slack isn&#39;t primary, so these take effect once it is.</p>' : '') +
1773
- '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request by replying in its thread, like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A plain &ldquo;yes&rdquo; runs it; a reply that&#39;s less clear (&ldquo;sure, send it&rdquo;, &ldquo;yes but check the amount&rdquo;) shows exactly what will run with a Confirm button first, and a change is shown to approve first. Once anyone is listed, only approvers&#39; button clicks count too. Anyone in the channel can still ask about a request.</p>' +
1827
+ var where = app === 'telegram' ? 'by replying to it' : 'by replying in its thread';
1828
+ return '<section class="approvers-section" data-app="' + app + '"><h3>' + title + ' approvers</h3>' +
1829
+ (n.primary !== app ? '<p class="hint" style="margin:0 0 8px">' + title + ' isn&#39;t primary, so requests aren&#39;t sent there until it is. Jobs started there still use this list.</p>' : '') +
1830
+ '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request ' + where + ', like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A plain &ldquo;yes&rdquo; runs it; a reply that&#39;s less clear (&ldquo;sure, send it&rdquo;, &ldquo;yes but check the amount&rdquo;) shows exactly what will run with a Confirm button first, and a change is shown to approve first. Once anyone is listed, only approvers&#39; button clicks count, and only approvers can give the agent jobs. Anyone can still ask about a request.</p>' +
1774
1831
  (approvers.length
1775
- ? '<div class="le-table-wrap" style="max-width:560px"><table><thead><tr><th>Name</th><th>Slack ID</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
1776
- : '<p class="hint">No approvers yet. Until you add someone, anyone in the channel can use the buttons, and deciding by reply is off.</p>') +
1832
+ ? '<div class="le-table-wrap" style="max-width:560px"><table><thead><tr><th>Name</th><th>' + title + ' ID</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
1833
+ : '<p class="hint">No approvers yet. Until you add someone, anyone can use the buttons, and deciding by reply is off.</p>') +
1777
1834
  (n.canSave === false
1778
1835
  ? '<p class="hint">Changing approvers needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server.</p>'
1779
- : '<p style="margin:10px 0 0"><button type="button" id="approversLoad">Add people from the channel</button></p>' +
1780
- '<div id="approversPicker"></div>') +
1781
- '<div id="approversResult" class="hint"></div></section>';
1836
+ : '<p style="margin:10px 0 0"><button type="button" class="approvers-load">' + (app === 'telegram' ? 'Add people who messaged the bot' : 'Add people from the channel') + '</button></p>' +
1837
+ (app === 'telegram' ? '<p class="hint" style="margin:6px 0 0">Telegram can&#39;t list a chat&#39;s members, so people show up here once they&#39;ve written to the bot (a private message to it is enough), or add someone by their numeric user id:</p>' +
1838
+ '<span style="display:flex;gap:6px;max-width:560px;margin-top:6px"><input class="approver-manual-id" placeholder="User id, like 123456789" style="flex:1;min-width:0"><input class="approver-manual-name" placeholder="Name" style="flex:1;min-width:0"><button type="button" class="approver-manual-add">Add</button></span>' : '') +
1839
+ '<div class="approvers-picker"></div>') +
1840
+ '<div class="hint approvers-result"></div></section>';
1782
1841
  }
1783
1842
 
1784
1843
  function wireApproversHandlers(name, n) {
1785
- var section = detail.querySelector('#approversSection');
1786
- if (!section) return;
1787
- var result = section.querySelector('#approversResult');
1788
- var slack = connectionFor(n, 'slack');
1789
- var current = (slack && slack.approvers) || [];
1844
+ var sections = detail.querySelectorAll('.approvers-section');
1845
+ for (var s = 0; s < sections.length; s++) wireApproversSection(name, n, sections[s]);
1846
+ }
1847
+
1848
+ function wireApproversSection(name, n, section) {
1849
+ var app = section.dataset.app;
1850
+ var result = section.querySelector('.approvers-result');
1851
+ var conn = connectionFor(n, app);
1852
+ var current = (conn && conn.approvers) || [];
1790
1853
  function save(list, message) {
1791
1854
  result.textContent = 'Saving' + String.fromCharCode(8230);
1792
1855
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/approvers', {
1793
- method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ approvers: list }),
1856
+ method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ app: app, approvers: list }),
1794
1857
  }).then(function (r) {
1795
1858
  if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
1796
1859
  loadApprovalsSection(name, message);
@@ -1801,31 +1864,46 @@ ${consoleShellScript}
1801
1864
  removes[i].addEventListener('click', function (ev) {
1802
1865
  var btn = ev.currentTarget;
1803
1866
  if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again to remove'; return; }
1804
- save(current.filter(function (a) { return a.user !== btn.dataset.user; }), 'Approver removed.');
1867
+ save(current.filter(function (a) { return a.user !== btn.dataset.user; }), CHANNEL_TITLES[app] + ' approver removed.');
1805
1868
  });
1806
1869
  }
1807
- var load = section.querySelector('#approversLoad');
1870
+ var manualAdd = section.querySelector('.approver-manual-add');
1871
+ if (manualAdd) {
1872
+ manualAdd.addEventListener('click', function () {
1873
+ var id = section.querySelector('.approver-manual-id').value.trim();
1874
+ var who = section.querySelector('.approver-manual-name').value.trim();
1875
+ if (!/^[0-9]{1,20}$/.test(id)) { result.textContent = 'A Telegram user id is a number, like 123456789.'; return; }
1876
+ if (current.some(function (a) { return a.user === id; })) { result.textContent = 'They are already an approver.'; return; }
1877
+ save(current.concat([who ? { user: id, name: who } : { user: id }]), 'Added 1 ' + CHANNEL_TITLES[app] + ' approver.');
1878
+ });
1879
+ }
1880
+ var load = section.querySelector('.approvers-load');
1808
1881
  if (!load) return;
1809
1882
  load.addEventListener('click', function () {
1810
1883
  load.disabled = true;
1811
1884
  result.textContent = 'Loading people' + String.fromCharCode(8230);
1812
- fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/slack-members').then(function (r) {
1885
+ var path = app === 'telegram' ? '/notifier/telegram-people' : '/notifier/slack-members';
1886
+ fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + path).then(function (r) {
1813
1887
  load.disabled = false;
1814
1888
  if (!r.ok) { result.textContent = r.body.error || 'Could not load people'; return; }
1815
1889
  var taken = current.map(function (a) { return a.user; });
1816
- var people = r.body.members.filter(function (m) { return taken.indexOf(m.id) === -1; });
1890
+ var people = (r.body.members || r.body.people || []).filter(function (m) { return taken.indexOf(m.id) === -1; });
1817
1891
  result.textContent = '';
1818
- var picker = section.querySelector('#approversPicker');
1819
- if (!people.length) { picker.innerHTML = '<p class="hint">Everyone in the channel is already an approver.</p>'; return; }
1892
+ var picker = section.querySelector('.approvers-picker');
1893
+ if (!people.length) {
1894
+ picker.innerHTML = '<p class="hint">' + (app === 'telegram' ? (taken.length ? 'Everyone who has written to the bot is already an approver.' : 'Nobody has written to the bot yet. Send it a message, then try again.') : 'Everyone in the channel is already an approver.') + '</p>';
1895
+ return;
1896
+ }
1820
1897
  picker.innerHTML = '<div class="tool-picker" style="max-width:560px;margin-top:8px">' + people.map(function (m) {
1821
- return '<label class="tool-picker-item" style="justify-content:flex-start"><input type="checkbox" value="' + escapeHtml(m.id) + '" data-name="' + escapeHtml(m.name) + '"> ' + escapeHtml(m.name) + ' <span class="hint">' + escapeHtml(m.id) + '</span></label>';
1822
- }).join('') + '</div><p style="margin:8px 0 0"><button type="button" class="primary" id="approversAdd">Add selected</button></p>';
1823
- picker.querySelector('#approversAdd').addEventListener('click', function () {
1898
+ var label = m.name + (m.username && m.name !== '@' + m.username ? ' (@' + m.username + ')' : '');
1899
+ return '<label class="tool-picker-item" style="justify-content:flex-start"><input type="checkbox" value="' + escapeHtml(m.id) + '" data-name="' + escapeHtml(m.name) + '"> ' + escapeHtml(label) + ' <span class="hint">' + escapeHtml(m.id) + '</span></label>';
1900
+ }).join('') + '</div><p style="margin:8px 0 0"><button type="button" class="primary approvers-add">Add selected</button></p>';
1901
+ picker.querySelector('.approvers-add').addEventListener('click', function () {
1824
1902
  var picked = picker.querySelectorAll('input:checked');
1825
1903
  if (!picked.length) { result.textContent = 'Pick at least one person.'; return; }
1826
1904
  var list = current.slice();
1827
1905
  for (var k = 0; k < picked.length; k++) list.push({ user: picked[k].value, name: picked[k].dataset.name });
1828
- save(list, picked.length === 1 ? 'Added 1 approver.' : 'Added ' + picked.length + ' approvers.');
1906
+ save(list, picked.length === 1 ? 'Added 1 ' + CHANNEL_TITLES[app] + ' approver.' : 'Added ' + picked.length + ' ' + CHANNEL_TITLES[app] + ' approvers.');
1829
1907
  });
1830
1908
  });
1831
1909
  });
@@ -1837,7 +1915,8 @@ ${consoleShellScript}
1837
1915
  '<p class="muted" style="margin:0 0 12px">' + renderNotifierStatus(n) + '</p>' +
1838
1916
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
1839
1917
  (n.source === 'code' ? '' : renderConnectionsHtml(n)) + '</section>' +
1840
- renderApproversHtml(n) +
1918
+ renderApproversHtml(n, 'slack') +
1919
+ renderApproversHtml(n, 'telegram') +
1841
1920
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
1842
1921
  }
1843
1922
 
@@ -1901,6 +1980,7 @@ ${consoleShellScript}
1901
1980
  });
1902
1981
  }
1903
1982
  if (channel === 'slack') wireSlackFields(name, form, result);
1983
+ if (channel === 'telegram') wireTelegramFields(name, form);
1904
1984
 
1905
1985
  form.addEventListener('submit', function (ev) {
1906
1986
  ev.preventDefault();
@@ -1913,12 +1993,15 @@ ${consoleShellScript}
1913
1993
  if (channel === 'email') { body.to = data.get('emailTo'); body.from = data.get('emailFrom'); body.provider = data.get('emailProvider'); }
1914
1994
  if (data.get('secret')) body.secret = data.get('secret');
1915
1995
  if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
1916
- if (channel === 'slack') {
1917
- var conversationsBox = form.querySelector('#slackConversations');
1918
- var savedSlack = lastNotifier && connectionFor(lastNotifier, 'slack');
1996
+ if (channel === 'slack' || channel === 'telegram') {
1997
+ var conversationsBox = form.querySelector('#' + channel + 'Conversations');
1998
+ var jobsBox = form.querySelector('#' + channel + 'Jobs');
1999
+ var savedConn = lastNotifier && connectionFor(lastNotifier, channel);
1919
2000
  // A locked switch (free plan) keeps whatever was saved before.
1920
2001
  if (conversationsBox && !conversationsBox.disabled) body.conversations = conversationsBox.checked;
1921
- else if (savedSlack && savedSlack.conversations !== undefined) body.conversations = savedSlack.conversations;
2002
+ else if (savedConn && savedConn.conversations !== undefined) body.conversations = savedConn.conversations;
2003
+ if (jobsBox && !jobsBox.disabled) body.jobs = jobsBox.checked;
2004
+ else if (savedConn && savedConn.jobs !== undefined) body.jobs = savedConn.jobs;
1922
2005
  }
1923
2006
  // Ticked "Make this the primary messaging app" (or none is primary yet,
1924
2007
  // which the server treats the same) makes this one primary.
@@ -1958,6 +2041,37 @@ ${consoleShellScript}
1958
2041
  }
1959
2042
  }
1960
2043
 
2044
+ // Telegram's section: Connect the bot points its updates at this
2045
+ // server; Check connection says where they go now.
2046
+ function wireTelegramFields(name, form) {
2047
+ var out = form.querySelector('#telegramWebhookResult');
2048
+ var url = '/agents/' + encodeURIComponent(name) + '/notifier/telegram-webhook';
2049
+ var setBtn = form.querySelector('#telegramWebhookSet');
2050
+ if (setBtn) {
2051
+ setBtn.addEventListener('click', function () {
2052
+ setBtn.disabled = true;
2053
+ out.textContent = 'Connecting' + String.fromCharCode(8230);
2054
+ fetchJsonRetryOnce(url, { method: 'POST' }).then(function (r) {
2055
+ setBtn.disabled = false;
2056
+ if (!r.ok) { out.textContent = r.body.error || 'Could not connect the bot'; return; }
2057
+ loadApprovalsSection(name, 'Telegram connected' + (r.body.botUsername ? ': people can now reply to @' + r.body.botUsername + String.fromCharCode(8217) + 's requests.' : '.'));
2058
+ });
2059
+ });
2060
+ }
2061
+ var checkBtn = form.querySelector('#telegramWebhookCheck');
2062
+ if (checkBtn) {
2063
+ checkBtn.addEventListener('click', function () {
2064
+ out.textContent = 'Checking' + String.fromCharCode(8230);
2065
+ fetchJsonRetryOnce(url).then(function (r) {
2066
+ if (!r.ok) { out.textContent = r.body.error || 'Could not check'; return; }
2067
+ var b = r.body;
2068
+ out.textContent = (b.pointsHere ? 'Connected' + (b.botUsername ? ' (@' + b.botUsername + ')' : '') + '.' : b.url ? 'The bot sends its updates somewhere else: ' + b.url + '. Connect again to point it here.' : 'The bot isn' + String.fromCharCode(8217) + 't sending its updates anywhere. Connect again.') +
2069
+ (b.lastError ? ' Telegram' + String.fromCharCode(8217) + 's last delivery error: ' + b.lastError : '');
2070
+ });
2071
+ });
2072
+ }
2073
+ }
2074
+
1961
2075
  // Slack's section: the manifest follows the link address; Load channels
1962
2076
  // fills the picker.
1963
2077
  function wireSlackFields(name, form, result) {
@@ -2040,7 +2154,11 @@ ${consoleShellScript}
2040
2154
  }
2041
2155
  showPendingCount(pending.length);
2042
2156
  if (message) {
2043
- var r = content.querySelector(message.indexOf('pprover') !== -1 ? '#approversResult' : '#notifierResult');
2157
+ var approverApp = message.indexOf('Telegram') !== -1 ? 'telegram' : 'slack';
2158
+ // Otherwise the section that was open, which is the one just saved.
2159
+ var r = message.indexOf('pprover') !== -1
2160
+ ? content.querySelector('.approvers-section[data-app="' + approverApp + '"] .approvers-result')
2161
+ : content.querySelector('details.msg-conn[open] .notifier-result') || content.querySelector('#notifierResult');
2044
2162
  if (r) r.textContent = message;
2045
2163
  }
2046
2164
  }).catch(function (err) {
@@ -4,6 +4,7 @@ import { type DeliveryResult } from '../core/http-notify-triggers/webhook.js';
4
4
  import type { HttpNotifierConfig } from '../core/agent-config.js';
5
5
  import { type MessagingApprover, type NotifierFile, type NotifierFileChannel } from '../core/notifier-file.js';
6
6
  import { type SlackChannel, type SlackMember } from '../core/messaging/connectors/slack/index.js';
7
+ import { type TelegramPerson } from '../core/messaging/connectors/telegram/index.js';
7
8
  /** The deployment-wide default webhook (LOOPENGINE_DEFAULT_WEBHOOK_URL /
8
9
  * _SECRET), which applies to the plain /messages route when an agent has
9
10
  * no notifier of its own. */
@@ -31,6 +32,10 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
31
32
  eventsPath: string;
32
33
  manifestTemplate: Record<string, unknown>;
33
34
  };
35
+ telegram: {
36
+ webhookSet: boolean;
37
+ webhookPath: string;
38
+ };
34
39
  deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
35
40
  channel?: HttpNotifierConfig["channel"];
36
41
  events?: string[];
@@ -56,24 +61,75 @@ export declare function slackCredentialsFor(config: AgentConfig): {
56
61
  botToken?: string;
57
62
  signingSecret?: string;
58
63
  };
59
- /** Whether replies in a Slack request's thread are answered: on unless
60
- * the Messaging tab turned Conversations off. An agent whose Slack
61
- * notifier is set in code always answers. */
64
+ /** The apps people can talk to an agent in (core/messaging/). */
65
+ export type TwoWayApp = 'slack' | 'telegram';
66
+ /** Whether replies in an app's request threads are answered: on unless
67
+ * that app's section of the Messaging tab turned Conversations off. An
68
+ * agent whose notifier is set in code answers in that app only. */
69
+ export declare function conversationsOn(config: AgentConfig, app: TwoWayApp): boolean;
62
70
  export declare function slackConversationsOn(config: AgentConfig): boolean;
63
- /** The people who can decide this agent's requests from Slack: from
64
- * messaging.yml; none when the Slack notifier is set in code. */
65
- export declare function agentApprovers(config: AgentConfig): MessagingApprover[];
66
- /** Whether a Slack user's button click counts: anyone's does while the
67
- * agent has no approvers (as before approvers existed), only theirs once
68
- * it has some. `message` says who can, for a refused click. */
71
+ /** Whether a DM to the agent's bot in `app`, or a mention of it, starts a
72
+ * job (core/messaging/jobs.ts). Off unless turned on in that app's
73
+ * section; never for a notifier set in code. */
74
+ export declare function jobsOn(config: AgentConfig, app: TwoWayApp): boolean;
75
+ /** The people who can decide this agent's requests from `app`: from that
76
+ * app's connection in messaging.yml; none when the notifier is set in
77
+ * code. */
78
+ export declare function agentApprovers(config: AgentConfig, app?: TwoWayApp): MessagingApprover[];
79
+ /** Whether a person's button click counts: anyone's does while the
80
+ * agent has no approvers in that app (as before approvers existed), only
81
+ * theirs once it has some. `message` says who can, for a refused click. */
82
+ export declare function clickAllowed(config: AgentConfig, app: TwoWayApp, user: string | undefined): {
83
+ allowed: boolean;
84
+ message?: string;
85
+ };
69
86
  export declare function slackClickAllowed(config: AgentConfig, user: string | undefined): {
70
87
  allowed: boolean;
71
88
  message?: string;
72
89
  };
73
- /** Replaces the approver list on the agent's saved Slack connection,
74
- * primary or not. Throws NotifierFileError when there's no Slack
75
- * connection to add them to. */
76
- export declare function setAgentApprovers(config: AgentConfig, approvers: unknown): MessagingApprover[];
90
+ /** Whether a person may start a job from a message, or continue one: an
91
+ * approver, once the app has any; with none, anyone in the agent's own
92
+ * channel or chat, but nobody by DM. */
93
+ export declare function jobAllowed(config: AgentConfig, app: TwoWayApp, message: {
94
+ user: string;
95
+ conversation: string;
96
+ direct?: boolean;
97
+ }): {
98
+ allowed: boolean;
99
+ message?: string;
100
+ };
101
+ /** Replaces the approver list on the agent's saved connection for `app`,
102
+ * primary or not. Throws NotifierFileError when there's no connection
103
+ * for that app to add them to. */
104
+ export declare function setAgentApprovers(config: AgentConfig, approvers: unknown, app?: TwoWayApp): MessagingApprover[];
105
+ /** The bot token behind an agent's Telegram notifier and the secret its
106
+ * webhook was set up with, like slackCredentialsFor. */
107
+ export declare function telegramCredentialsFor(config: AgentConfig): {
108
+ botToken?: string;
109
+ webhookSecret?: string;
110
+ };
111
+ /** The address remembered for links when the app's connection was saved. */
112
+ export declare function connectionPublicUrl(config: AgentConfig, app: TwoWayApp): string | undefined;
113
+ /** The Telegram chat the agent posts requests in, if any. */
114
+ export declare function telegramHomeChat(config: AgentConfig): string | undefined;
115
+ /** People who have written to the agent's Telegram bot, newest first,
116
+ * for picking approvers: the Bot API can't list a chat's members. */
117
+ export declare function listAgentTelegramPeople(config: AgentConfig): TelegramPerson[];
118
+ /** Points the agent's Telegram bot at POST /hooks/telegram/<agent> on
119
+ * `baseUrl` (LOOPENGINE_PUBLIC_URL wins), with a secret made once and
120
+ * kept in the agent's env. From then on requests get buttons and replies
121
+ * reach the agent. */
122
+ export declare function setupAgentTelegramWebhook(config: AgentConfig, baseUrl: string): Promise<{
123
+ url: string;
124
+ botUsername?: string;
125
+ }>;
126
+ /** Where the agent's Telegram bot sends its updates now. */
127
+ export declare function agentTelegramWebhookInfo(config: AgentConfig): Promise<{
128
+ url?: string;
129
+ lastError?: string;
130
+ pointsHere: boolean;
131
+ botUsername?: string;
132
+ }>;
77
133
  /** The people in the agent's Slack channel, for picking approvers. */
78
134
  export declare function listAgentSlackMembers(config: AgentConfig): Promise<SlackMember[]>;
79
135
  /** Channels for the console's picker, with the token being typed in or,