shadok-ai 0.16.20 → 0.16.21

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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The Telegram pairing code: the ONLY way a Telegram account gets to bind this
3
+ * instance's board group, or to become the owner of its DMs.
4
+ *
5
+ * It replaces two first-come rules. `/setup` bound whichever group sent it
6
+ * first, with no check on the sender, so anyone who found the bot's username
7
+ * could add it to their own group, send `/setup`, and drive agents — i.e. run
8
+ * commands on the host. And DMs belonged to whoever wrote to the bot first.
9
+ * Both now require a code shown in the web cockpit's Telegram panel, which is
10
+ * admin-only: whoever can read the code is someone the cockpit already trusts,
11
+ * and the Telegram account that types it is the one that account vouches for.
12
+ *
13
+ * The code is 8 digits, single-use, lives 10 minutes, and dies after
14
+ * `MAX_ATTEMPTS` wrong guesses — 10^8 codes against 5 tries leaves a guesser
15
+ * nothing. Issuing a new one revokes the previous one. It lives in memory only:
16
+ * a server restart invalidating a 10-minute code costs one click.
17
+ *
18
+ * The state transitions are pure (`redeemPairing`), so the rules are tested
19
+ * without a clock or a bot.
20
+ */
21
+ import { randomInt, timingSafeEqual } from "node:crypto";
22
+ export const PAIRING_DIGITS = 8;
23
+ export const PAIRING_TTL_MS = 10 * 60_000;
24
+ export const PAIRING_MAX_ATTEMPTS = 5;
25
+ /** A fresh code. `rand` is injectable for the tests. */
26
+ export function newPairing(now, rand = randomInt) {
27
+ let code = "";
28
+ for (let i = 0; i < PAIRING_DIGITS; i++)
29
+ code += String(rand(10));
30
+ return { code, expiresAt: now + PAIRING_TTL_MS, failures: 0 };
31
+ }
32
+ /** Pull the code out of what was typed: tolerant of spaces and dashes
33
+ * ("1234 5678", "1234-5678"), since people copy codes the way they read them. */
34
+ export function normalizeCode(input) {
35
+ return input.replace(/[\s-]/g, "");
36
+ }
37
+ function sameCode(a, b) {
38
+ const x = Buffer.from(a);
39
+ const y = Buffer.from(b);
40
+ return x.length === y.length && timingSafeEqual(x, y);
41
+ }
42
+ /**
43
+ * Try a code against the current state. Returns the verdict and the state to
44
+ * keep: `null` once the code is spent (used, expired or locked out), so nothing
45
+ * can be retried against a dead code. Pure — unit tested.
46
+ */
47
+ export function redeemPairing(state, input, now) {
48
+ if (!state)
49
+ return { verdict: "none", next: null };
50
+ if (now >= state.expiresAt)
51
+ return { verdict: "expired", next: null };
52
+ if (sameCode(normalizeCode(input), state.code))
53
+ return { verdict: "ok", next: null };
54
+ const failures = state.failures + 1;
55
+ if (failures >= PAIRING_MAX_ATTEMPTS)
56
+ return { verdict: "locked", next: null };
57
+ return { verdict: "invalid", next: { ...state, failures } };
58
+ }
59
+ /** What a refused redemption tells the Telegram sender. Never says how many
60
+ * tries are left: that is information for the guesser, not for the owner. */
61
+ export function pairingRefusal(verdict) {
62
+ const where = "Open the cockpit's Telegram panel (admin) and click “Pair Telegram” to get a code.";
63
+ switch (verdict) {
64
+ case "none":
65
+ return `🔒 This bot is not paired yet. ${where}`;
66
+ case "expired":
67
+ return `⌛ That code has expired. ${where}`;
68
+ case "locked":
69
+ return `⛔ Too many wrong codes — that code is cancelled. ${where}`;
70
+ case "invalid":
71
+ return "❌ Wrong code.";
72
+ }
73
+ }
74
+ // The live code. One per process: the server and the bridge share it.
75
+ let current = null;
76
+ /** Issue a new code, revoking any previous one. */
77
+ export function issuePairingCode(now = Date.now()) {
78
+ current = newPairing(now);
79
+ return current;
80
+ }
81
+ /** Redeem against the live code, advancing its state. */
82
+ export function redeemPairingCode(input, now = Date.now()) {
83
+ const { verdict, next } = redeemPairing(current, input, now);
84
+ current = next;
85
+ return verdict;
86
+ }
87
+ /** Revoke the live code, if any (the panel's "cancel"). */
88
+ export function cancelPairingCode() {
89
+ current = null;
90
+ }
91
+ /** Is a code live right now, and until when? For the panel, never the code. */
92
+ export function pairingStatus(now = Date.now()) {
93
+ if (current && now < current.expiresAt)
94
+ return { active: true, expiresAt: current.expiresAt };
95
+ return { active: false, expiresAt: null };
96
+ }
97
+ //# sourceMappingURL=tg-pairing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tg-pairing.js","sourceRoot":"","sources":["../src/tg-pairing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.16.20",
3
+ "version": "0.16.21",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test --test-timeout=120000 test/*.ts context/*/test/*.test.mjs",
package/public/index.html CHANGED
@@ -1066,6 +1066,9 @@
1066
1066
  .tg-guide ol { margin: 8px 0 0; padding-left: 20px; font-size: 13px; line-height: 1.5; }
1067
1067
  .tg-guide li { margin-bottom: 4px; }
1068
1068
  .tg-guide code { font-size: 12px; }
1069
+ .tg-pair { margin-bottom: 12px; }
1070
+ .tg-pair-code { font: 600 26px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 3px; margin: 6px 0; user-select: all; }
1071
+ .tg-pair-links { display: flex; gap: 8px; flex-wrap: wrap; margin: 6px 0; }
1069
1072
  /* A field with no effect in the current mode (resume/continue) — visible but inert. */
1070
1073
  .na { opacity: .45; pointer-events: none; }
1071
1074
 
@@ -2958,11 +2961,23 @@
2958
2961
  <li>Paste that token below and click <b>Save</b>. The status above turns 🟢 with the bot's name.</li>
2959
2962
  <li>Create a Telegram <b>group</b>, open its settings and turn on <b>Topics</b>.</li>
2960
2963
  <li>Add your bot to the group and make it an <b>admin</b> with <i>Manage topics</i> (and <i>Delete messages</i>, so <code>/secret</code> can scrub values).</li>
2961
- <li>In the group, send <code>/setup</code>. The status above now shows the bound group.</li>
2964
+ <li>Click <b>Pair Telegram</b> below, then send <code>/setup &lt;code&gt;</code> in the group (or use the one-click link). The status above now shows the bound group, and you own the bot's DMs.</li>
2962
2965
  <li>Send <code>/spawn &lt;name&gt;</code> (or create a topic by hand): each topic is one agent, also visible here as a tab.</li>
2963
2966
  </ol>
2964
2967
  <p class="check-hint">Optional: list chat ids under <i>Allowed chats</i> so only they can reach the bot.</p>
2965
2968
  </details>
2969
+ <div class="tg-pair">
2970
+ <button id="tgPair" title="Show a one-time code: only the Telegram account that enters it can bind the board group or own the bot's DMs">Pair Telegram</button>
2971
+ <div id="tgPairBox" hidden>
2972
+ <div class="tg-pair-code" id="tgPairCode"></div>
2973
+ <p class="check-hint">Send <code>/setup <span id="tgPairCodeInline"></span></code> in the group to make it the board, or in a private chat with the bot to own its DMs. Single use · <span id="tgPairLeft"></span></p>
2974
+ <div class="tg-pair-links">
2975
+ <a id="tgPairGroup" target="_blank" rel="noopener noreferrer" hidden>Add to a group ↗</a>
2976
+ <a id="tgPairDm" target="_blank" rel="noopener noreferrer" hidden>Open a private chat ↗</a>
2977
+ <button id="tgPairCancel">Cancel code</button>
2978
+ </div>
2979
+ </div>
2980
+ </div>
2966
2981
  <button id="tgUnbind" hidden title="Unbind the board group so you can /setup a fresh one (e.g. after it became a supergroup)">Unbind group</button>
2967
2982
  <label class="check"><input type="checkbox" id="tgEnabled"><span>Bot enabled</span></label>
2968
2983
  <div class="field">
@@ -10750,7 +10765,7 @@
10750
10765
  tgAllowed = s.allowedChats.slice();
10751
10766
  const dot = s.running && s.username ? "🟢" : s.tokenError ? "🟠" : "⚪";
10752
10767
  const who = s.username ? s.username : s.hasToken ? "(token set)" : "no token";
10753
- const grp = s.boundGroup ? ` · group ${s.boundGroup.chatId}` : " · no group bound (run /setup in Telegram)";
10768
+ const grp = s.boundGroup ? ` · group ${s.boundGroup.chatId}` : " · no group bound (pair it below)";
10754
10769
  const err = s.tokenError ? ` — ${s.tokenError}` : "";
10755
10770
  $("tgStatus").textContent = `${dot} ${who}${grp}${err}`;
10756
10771
  $("tgUnbind").hidden = !s.boundGroup; // only offer unbind when a group is bound
@@ -10777,6 +10792,41 @@
10777
10792
  ul.appendChild(li);
10778
10793
  }
10779
10794
  }
10795
+ // Pairing code: shown once, as issued — the server never returns it again.
10796
+ let tgPairTimer = null;
10797
+ function hidePairing() {
10798
+ clearInterval(tgPairTimer);
10799
+ tgPairTimer = null;
10800
+ $("tgPairBox").hidden = true;
10801
+ }
10802
+ function showPairing(p) {
10803
+ $("tgPairCode").textContent = p.code.slice(0, 4) + " " + p.code.slice(4);
10804
+ $("tgPairCodeInline").textContent = p.code;
10805
+ for (const [id, href] of [["tgPairGroup", p.groupLink], ["tgPairDm", p.dmLink]]) {
10806
+ const a = $(id);
10807
+ a.hidden = !href;
10808
+ if (href) a.href = href;
10809
+ }
10810
+ $("tgPairBox").hidden = false;
10811
+ const tick = () => {
10812
+ const left = Math.max(0, p.expiresAt - Date.now());
10813
+ if (left === 0) { hidePairing(); return; }
10814
+ const m = Math.floor(left / 60000), sec = Math.floor((left % 60000) / 1000);
10815
+ $("tgPairLeft").textContent = `expires in ${m}:${String(sec).padStart(2, "0")}`;
10816
+ };
10817
+ clearInterval(tgPairTimer);
10818
+ tick();
10819
+ tgPairTimer = setInterval(tick, 1000);
10820
+ }
10821
+ async function issuePairing() {
10822
+ const r = await fetch("/telegram/pairing", { method: "POST" });
10823
+ if (r.ok) showPairing(await r.json());
10824
+ else alert((await r.json().catch(() => ({}))).error || "could not issue a pairing code");
10825
+ }
10826
+ async function cancelPairing() {
10827
+ await fetch("/telegram/pairing", { method: "DELETE" });
10828
+ hidePairing();
10829
+ }
10780
10830
  async function loadTelegram() {
10781
10831
  delete $("tgGuide").dataset.auto; // each opening of the panel re-decides the fold
10782
10832
  renderTelegram(await (await fetch("/telegram")).json());
@@ -11184,7 +11234,9 @@
11184
11234
  // Backdrop click closes a dismissable (voluntary) card; the panel itself does not.
11185
11235
  $("authOverlay").addEventListener("click", (e) => { if (e.target === $("authOverlay")) closeAuthCard(); });
11186
11236
 
11187
- $("tgUnbind").addEventListener("click", () => { if (confirm("Unbind the current board group? You'll need to /setup a group again in Telegram.")) putTelegram({ unbindGroup: true }); });
11237
+ $("tgPair").addEventListener("click", issuePairing);
11238
+ $("tgPairCancel").addEventListener("click", cancelPairing);
11239
+ $("tgUnbind").addEventListener("click", () => { if (confirm("Unbind the current board group? You'll need to pair a group again (Pair Telegram, then /setup <code>).")) putTelegram({ unbindGroup: true }); });
11188
11240
  $("telegramBtn").addEventListener("click", () => { $("telegramOverlay").hidden = false; loadTelegram(); });
11189
11241
  $("telegramClose").addEventListener("click", () => { $("telegramOverlay").hidden = true; });
11190
11242
  $("telegramOverlay").addEventListener("click", (e) => { if (e.target === $("telegramOverlay")) $("telegramOverlay").hidden = true; });