pi-braid-telegram 0.1.6 → 0.1.8

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.
package/src/index.ts CHANGED
@@ -1,12 +1,22 @@
1
1
  /**
2
2
  * @pi-braid/telegram — bidirectional Telegram channel.
3
3
  *
4
- * Extension factory registering ONE tool (`telegram_send`, outbound) and
5
- * starting the inbound long-poll injector. Enabled ONLY when
4
+ * Extension factory registering the outbound tool (`telegram_send`), the
5
+ * poller-ownership tools (`telegram_inbox`, `telegram_poller`) and the inbound
6
+ * long-poll injector. Enabled ONLY when
6
7
  * `BRAID_TG_BOT_TOKEN` is present (if-has-token guard): with no token the
7
8
  * factory returns immediately — no tool is registered, no poller starts, and
8
9
  * nothing crashes (debug log only).
9
10
  *
11
+ * Session role: a worker session carries BRAID_SESSION_ROLE=worker, exported
12
+ * into its pane shell before the agent starts (the spawn-worker instruction
13
+ * prints that launch). The factory reads it and returns before
14
+ * registering anything, so a worker gets no surface at all from this
15
+ * extension. This extension is the route to the owner's chat and a worker runs
16
+ * under the seat's own bot token and chat id, so the gate is the whole
17
+ * factory: a per-registration gate leaves the next send path (a turn-end
18
+ * notifier, a lifecycle tracker) free to appear outside it.
19
+ *
10
20
  * Live credentials: the TELEGRAM_BOT_TOKEN /
11
21
  * TELEGRAM_CHAT_ID / TELEGRAM_ALLOWED_USERS alias names are read as
12
22
  * fallbacks to the canonical BRAID_TG_* names (see
@@ -120,8 +130,13 @@ import {
120
130
  acquirePollerLock,
121
131
  defaultPollerLockPath,
122
132
  describeLockHolder,
133
+ describeLockReason,
123
134
  releasePollerLock,
135
+ startPollerHeartbeat,
136
+ stopPollerHeartbeat,
124
137
  type PollerLockInfo,
138
+ type PollerLockRefusalReason,
139
+ type PollerLockTakeoverReason,
125
140
  } from "./lib/poller-lock.js";
126
141
  import { isWorkerRole, resolveSessionRole } from "./lib/session-role.js";
127
142
  import {
@@ -467,22 +482,35 @@ let pollerWanted = false;
467
482
  /**
468
483
  * Why a start attempt did not start: `already-running` (this process's own
469
484
  * singleton), `role-forbidden` (worker session — the seat owns the poller),
470
- * `lock-held` (another LIVE process owns the machine-wide lock; `holder`
471
- * names it), or `lock-path-underivable` (no absolute braid root, so the
472
- * ownership lock has no home). Callers render the reason for their own
485
+ * `lock-held` (another process owns the machine-wide lock; `holder` names it,
486
+ * `refusalReason` says which kind of holder it is, and `path` is the file an
487
+ * operator inspects), or `lock-path-underivable` (no absolute braid root, so
488
+ * the ownership lock has no home). Callers render the reason for their own
473
489
  * surface.
474
490
  */
475
491
  type PollerStartRefusal =
476
492
  | { started: false; reason: "already-running" }
477
493
  | { started: false; reason: "role-forbidden" }
478
- | { started: false; reason: "lock-held"; holder: PollerLockInfo }
494
+ | {
495
+ started: false;
496
+ reason: "lock-held";
497
+ holder: PollerLockInfo;
498
+ refusalReason: PollerLockRefusalReason;
499
+ path: string;
500
+ }
479
501
  | { started: false; reason: "lock-path-underivable" };
480
- type PollerStartOutcome = { started: true } | PollerStartRefusal;
502
+ type PollerStartOutcome =
503
+ | {
504
+ started: true;
505
+ /** How the lock was won: an operator reads a takeover, not a silent start. */
506
+ lock: { tookOver: boolean; reason: PollerLockTakeoverReason };
507
+ }
508
+ | PollerStartRefusal;
481
509
 
482
510
  /** Refusal body call sites append to their own surface-specific prefix. */
483
511
  function describePollerStartRefusal(outcome: PollerStartRefusal): string {
484
512
  if (outcome.reason === "lock-held") {
485
- return `ownership lock held by ${describeLockHolder(outcome.holder)}`;
513
+ return `ownership lock held by ${describeLockHolder(outcome.holder)}: ${describeLockReason(outcome.refusalReason, outcome.path)}`;
486
514
  }
487
515
  if (outcome.reason === "role-forbidden") {
488
516
  return "worker role — the orchestrator seat owns the Telegram poller";
@@ -567,14 +595,30 @@ function startSingletonPoller(opts: {
567
595
  role: resolveSessionRole(),
568
596
  startedBy: opts.startedBy,
569
597
  },
570
- opts.pidAlive,
598
+ { pidAlive: opts.pidAlive },
571
599
  );
572
600
  if (!acquired.ok) {
573
601
  log(
574
602
  "warn",
575
- `telegram poller not started: ${describeLockHolder(acquired.holder)} holds the ownership lock — refusing to steal the getUpdates singleton`,
603
+ `telegram poller not started: ${describeLockHolder(acquired.holder)} holds the ownership lock — ${describeLockReason(acquired.reason, acquired.path)}`,
604
+ );
605
+ return {
606
+ started: false,
607
+ reason: "lock-held",
608
+ holder: acquired.holder,
609
+ refusalReason: acquired.reason,
610
+ path: acquired.path,
611
+ };
612
+ }
613
+ // The heartbeat is what makes this lock's liveness checkable by a process in
614
+ // another namespace: without it the pid is the only evidence, and the pid is
615
+ // the part that does not travel between namespaces.
616
+ startPollerHeartbeat(lockPath);
617
+ if (acquired.tookOver) {
618
+ log(
619
+ "info",
620
+ `telegram poller ownership lock taken over by ${opts.startedBy}: ${describeLockReason(acquired.reason, lockPath)}${acquired.previous ? `; previous holder ${describeLockHolder(acquired.previous)}` : ""}`,
576
621
  );
577
- return { started: false, reason: "lock-held", holder: acquired.holder };
578
622
  }
579
623
  activePoller = createInboundPoller({
580
624
  token: opts.token,
@@ -608,7 +652,10 @@ function startSingletonPoller(opts: {
608
652
  // poller is live — deferred to the first owner
609
653
  // message when the owner chat is not yet known.
610
654
  opts.onStart?.();
611
- return { started: true };
655
+ return {
656
+ started: true,
657
+ lock: { tookOver: acquired.tookOver, reason: acquired.reason },
658
+ };
612
659
  }
613
660
 
614
661
  /** Lock file path: explicit env override first, then the braid-home default.
@@ -626,9 +673,14 @@ function stopSingletonPoller(): boolean {
626
673
  pollerStartedBy = undefined;
627
674
  // An explicit disable gives the channel up machine-wide; the reload-path
628
675
  // shutdown does NOT release — the session_start re-arm re-acquires our own
629
- // pid's lock without a window where another process could claim it.
676
+ // pid's lock without a window where another process could claim it. The
677
+ // heartbeat stops with the release, so a released lock cannot keep being
678
+ // stamped fresh.
630
679
  const lockPath = pollerLockFile();
631
- if (lockPath !== null) releasePollerLock(lockPath);
680
+ if (lockPath !== null) {
681
+ stopPollerHeartbeat(lockPath);
682
+ releasePollerLock(lockPath);
683
+ }
632
684
  log("info", "telegram poller stopped");
633
685
  return true;
634
686
  }
@@ -676,6 +728,19 @@ export default async function telegramExtension(
676
728
  // exactly like log() itself: silent until a session binds the notify sink.
677
729
  setPathRefusalSink((message) => log("warn", message));
678
730
 
731
+ // Session role gate: a worker session has no path to the owner through this
732
+ // extension. The marker the orchestrator exports into the worker's pane shell
733
+ // is read here, and the factory returns before it registers anything: no
734
+ // telegram_send, no published send seam, no lifecycle handler that would
735
+ // send on turn_end, no poller, no command. Structural rather than a list of
736
+ // registrations, because a list is what the next send path gets added
737
+ // beside. The read-only poller liveness probe published above stays: a
738
+ // worker owns no poller, and "stopped" is its true reading.
739
+ if (isWorkerRole()) {
740
+ log("info", "telegram: worker session, no outbound surface registered");
741
+ return;
742
+ }
743
+
679
744
  // if-has-token guard
680
745
  const token = env(ENV_TOKEN);
681
746
  if (!token) {
@@ -722,11 +787,6 @@ export default async function telegramExtension(
722
787
  const fetchImpl = deps?.fetch ?? (globalThis.fetch as TelegramFetch);
723
788
  const client = createTelegramClient({ fetch: fetchImpl });
724
789
 
725
- // Session role (registration-time gate): a worker session keeps
726
- // telegram_send but never sees the poller-ownership surfaces and never
727
- // starts the poller — the orchestrator seat owns the getUpdates singleton.
728
- const workerRole = isWorkerRole();
729
-
730
790
  // Inline-keyboard pause/resume control
731
791
  // The pause flag is a signal for the CONSUMER (the orchestrator reads it via
732
792
  // the exported getPauseState()); this handler only persists the flag and
@@ -914,9 +974,7 @@ export default async function telegramExtension(
914
974
  "voice:true sends a voice note (needs a TTS source); voiceText defaults to text. " +
915
975
  "options: 1-2 rows of up to 4 {label,value} buttons attach an inline keyboard; a tap returns to the agent as an inbound `[decision] <token> → <label> (<value>)` message (the owner can always answer in words too); decisionToken is the correlation id, generated when omitted and echoed in the result. " +
916
976
  "Returns the delivery result (message_id or error). " +
917
- (workerRole
918
- ? "A successful send while the inbound poller is OFF carries a warn field — the orchestrator seat owns the inbound poller; messages are delivered without inbound (owner replies reach the seat session)."
919
- : "A successful send while the inbound poller is OFF carries a warn field — the owner's replies won't reach the agent until telegram_poller enable."),
977
+ "A successful send while the inbound poller is OFF carries a warn field — the owner's replies won't reach the agent until telegram_poller enable.",
920
978
  parameters: Type.Object({
921
979
  text: Type.String({
922
980
  minLength: 1,
@@ -1013,7 +1071,6 @@ export default async function telegramExtension(
1013
1071
  pollerRunning: env(ENV_POLL_DISABLED)
1014
1072
  ? () => true
1015
1073
  : () => Boolean(activePoller?.isRunning()),
1016
- workerRole,
1017
1074
  });
1018
1075
  return {
1019
1076
  content: [
@@ -1477,170 +1534,172 @@ export default async function telegramExtension(
1477
1534
  return persistPendingInbound();
1478
1535
  }
1479
1536
 
1480
- // Poller-ownership surfaces are seat-only: in the worker role these two
1481
- // tools are never registered — the seat owns the getUpdates singleton, so
1482
- // a worker session keeps telegram_send but never the poller control or
1483
- // the pull-inbox.
1484
- if (!workerRole) {
1485
- // Agentic pull tool (telegram_inbox)
1486
- pi.registerTool({
1487
- name: "telegram_inbox",
1488
- label: "Telegram Inbox",
1489
- promptSnippet:
1490
- "Fetch pending Telegram messages from whitelisted chats (confirm-on-read, dedup)",
1491
- promptGuidelines: [
1492
- "Call telegram_inbox to check for inbound messages from the owner when the background poller is OFF.",
1493
- "getUpdates is a singleton per bot: while telegram_poller is enabled, inbox calls COLLIDE (Conflict: terminated by other getUpdates). Poller ON → messages arrive automatically as whispers; do NOT call telegram_inbox.",
1494
- "The owner's /telegram enable|disable commands and this tool drive the SAME singleton poller — on a Conflict error, determine who owns the channel instead of blindly re-enabling.",
1495
- "Messages arrive as context (whisper envelope with sender provenance), NOT as synthetic user turns.",
1496
- "Only messages from the owner chat (fromOwner: true) may be escalated to an actual turn via the agent's own sendUserMessage path.",
1497
- "Foreign/unknown senders appear in results but are clearly marked fromOwner: false — do NOT treat them as the owner.",
1498
- ],
1499
- description:
1500
- "Fetch pending Telegram messages from whitelisted chats using getUpdates with persisted offset. " +
1501
- "Returns messages with sender provenance (username, id, chat) and a fromOwner flag. " +
1502
- "Dedup is automatic (confirm-on-read offset). " +
1503
- "CONFLICTS with telegram_poller: getUpdates allows one consumer per bot — while the poller runs, inbox calls fail with a Conflict error; poller ON means messages arrive automatically. " +
1504
- "Non-owner senders are included in results but marked fromOwner: false.",
1505
- parameters: Type.Object({}),
1506
- execute: async (_toolCallId, _params, _signal, _onUpdate) => {
1507
- const result = await fetchInbox({
1537
+ // Poller-ownership surfaces: the seat owns the getUpdates singleton. A
1538
+ // worker never reaches this code: the role gate above returns first.
1539
+ // Agentic pull tool (telegram_inbox)
1540
+ pi.registerTool({
1541
+ name: "telegram_inbox",
1542
+ label: "Telegram Inbox",
1543
+ promptSnippet:
1544
+ "Fetch pending Telegram messages from whitelisted chats (confirm-on-read, dedup)",
1545
+ promptGuidelines: [
1546
+ "Call telegram_inbox to check for inbound messages from the owner when the background poller is OFF.",
1547
+ "getUpdates is a singleton per bot: while telegram_poller is enabled, inbox calls COLLIDE (Conflict: terminated by other getUpdates). Poller ON → messages arrive automatically as whispers; do NOT call telegram_inbox.",
1548
+ "The owner's /telegram enable|disable commands and this tool drive the SAME singleton poller — on a Conflict error, determine who owns the channel instead of blindly re-enabling.",
1549
+ "Messages arrive as context (whisper envelope with sender provenance), NOT as synthetic user turns.",
1550
+ "Only messages from the owner chat (fromOwner: true) may be escalated to an actual turn via the agent's own sendUserMessage path.",
1551
+ "Foreign/unknown senders appear in results but are clearly marked fromOwner: false — do NOT treat them as the owner.",
1552
+ ],
1553
+ description:
1554
+ "Fetch pending Telegram messages from whitelisted chats using getUpdates with persisted offset. " +
1555
+ "Returns messages with sender provenance (username, id, chat) and a fromOwner flag. " +
1556
+ "Dedup is automatic (confirm-on-read offset). " +
1557
+ "CONFLICTS with telegram_poller: getUpdates allows one consumer per bot — while the poller runs, inbox calls fail with a Conflict error; poller ON means messages arrive automatically. " +
1558
+ "Non-owner senders are included in results but marked fromOwner: false.",
1559
+ parameters: Type.Object({}),
1560
+ execute: async (_toolCallId, _params, _signal, _onUpdate) => {
1561
+ const result = await fetchInbox({
1562
+ token,
1563
+ allowedUsers,
1564
+ stateFile,
1565
+ fetchFn: fetchImpl,
1566
+ mediaDir: env(ENV_MEDIA_DIR) || undefined,
1567
+ stt,
1568
+ log,
1569
+ });
1570
+ if (!result.ok) {
1571
+ return {
1572
+ content: [
1573
+ {
1574
+ type: "text" as const,
1575
+ text: `telegram_inbox: fetch failed — ${result.error ?? "unknown error"}`,
1576
+ },
1577
+ ],
1578
+ details: result,
1579
+ };
1580
+ }
1581
+ const formatted = result.messages
1582
+ .map((msg) => formatInboxMessage(msg))
1583
+ .join("\n\n---\n\n");
1584
+ return {
1585
+ content: [
1586
+ {
1587
+ type: "text" as const,
1588
+ text:
1589
+ result.messages.length === 0
1590
+ ? "telegram_inbox: no pending messages."
1591
+ : `telegram_inbox: ${result.messages.length} message(s), offset ${result.offset}\n\n${formatted}`,
1592
+ },
1593
+ ],
1594
+ details: result,
1595
+ };
1596
+ },
1597
+ });
1598
+
1599
+ // Singleton poller control tool (telegram_poller)
1600
+ pi.registerTool({
1601
+ name: "telegram_poller",
1602
+ label: "Telegram Poller Control",
1603
+ promptSnippet:
1604
+ "Enable or disable the background Telegram poller (subscribe/unsubscribe)",
1605
+ promptGuidelines: [
1606
+ "Use telegram_poller with action:'enable' to start the background long-poll injector. Only one poller may run per process.",
1607
+ "Use telegram_poller with action:'disable' to stop it. Safe to call when no poller is running.",
1608
+ "The poller delivers owner messages as braid-whisper envelopes — the same format as telegram_inbox.",
1609
+ "While the poller runs, telegram_inbox calls fail (getUpdates singleton) — that is expected, not a bug. Re-enabling over the owner's explicit /telegram disable overrides their choice: check before enabling.",
1610
+ ],
1611
+ description:
1612
+ "Control the singleton Telegram background poller. " +
1613
+ "Params: `action` (required, 'enable' or 'disable'). " +
1614
+ "The poller is OFF by default and only one can run per process. " +
1615
+ "Returns the current poller status after the action.",
1616
+ parameters: Type.Object({
1617
+ action: Type.String({
1618
+ description:
1619
+ "'enable' to start the singleton background poller, 'disable' to stop it.",
1620
+ }),
1621
+ }),
1622
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
1623
+ const action = String(params.action ?? "")
1624
+ .trim()
1625
+ .toLowerCase();
1626
+ if (action === "enable") {
1627
+ const outcome = startSingletonPoller({
1508
1628
  token,
1509
1629
  allowedUsers,
1510
1630
  stateFile,
1511
1631
  fetchFn: fetchImpl,
1632
+ intervalMs: interval,
1512
1633
  mediaDir: env(ENV_MEDIA_DIR) || undefined,
1513
1634
  stt,
1514
- log,
1635
+ onOwnerMessage: deliverOwnerMessage,
1636
+ onCallbackQuery: handleCallbackQuery,
1637
+ startedBy: "telegram_poller tool",
1638
+ onStart: () => void placeControlKeyboardOnce(),
1639
+ pidAlive: deps?.pidAlive,
1515
1640
  });
1516
- if (!result.ok) {
1641
+ if (!outcome.started) {
1642
+ const refused = {
1643
+ ok: false,
1644
+ running: isPollerRunning(),
1645
+ error: `telegram_poller enable refused: ${describePollerStartRefusal(outcome)}`,
1646
+ // The machine-readable half: an agent (or a log grep) can name the
1647
+ // case without parsing the sentence, and `lockFile` is the path an
1648
+ // operator has to inspect for the `stale-unknown` case.
1649
+ ...(outcome.reason === "lock-held"
1650
+ ? {
1651
+ holder: outcome.holder,
1652
+ reason: outcome.refusalReason,
1653
+ lockFile: outcome.path,
1654
+ }
1655
+ : {}),
1656
+ };
1517
1657
  return {
1518
- content: [
1519
- {
1520
- type: "text" as const,
1521
- text: `telegram_inbox: fetch failed — ${result.error ?? "unknown error"}`,
1522
- },
1523
- ],
1524
- details: result,
1658
+ content: [{ type: "text" as const, text: JSON.stringify(refused) }],
1659
+ details: refused,
1525
1660
  };
1526
1661
  }
1527
- const formatted = result.messages
1528
- .map((msg) => formatInboxMessage(msg))
1529
- .join("\n\n---\n\n");
1530
1662
  return {
1531
1663
  content: [
1532
1664
  {
1533
1665
  type: "text" as const,
1534
- text:
1535
- result.messages.length === 0
1536
- ? "telegram_inbox: no pending messages."
1537
- : `telegram_inbox: ${result.messages.length} message(s), offset ${result.offset}\n\n${formatted}`,
1666
+ text: JSON.stringify({
1667
+ ok: true,
1668
+ running: isPollerRunning(),
1669
+ startedBy: pollerStartedBy,
1670
+ lock: outcome.lock,
1671
+ }),
1538
1672
  },
1539
1673
  ],
1540
- details: result,
1674
+ details: { ok: true, running: isPollerRunning(), lock: outcome.lock },
1541
1675
  };
1542
- },
1543
- });
1544
-
1545
- // Singleton poller control tool (telegram_poller)
1546
- pi.registerTool({
1547
- name: "telegram_poller",
1548
- label: "Telegram Poller Control",
1549
- promptSnippet:
1550
- "Enable or disable the background Telegram poller (subscribe/unsubscribe)",
1551
- promptGuidelines: [
1552
- "Use telegram_poller with action:'enable' to start the background long-poll injector. Only one poller may run per process.",
1553
- "Use telegram_poller with action:'disable' to stop it. Safe to call when no poller is running.",
1554
- "The poller delivers owner messages as braid-whisper envelopes — the same format as telegram_inbox.",
1555
- "While the poller runs, telegram_inbox calls fail (getUpdates singleton) — that is expected, not a bug. Re-enabling over the owner's explicit /telegram disable overrides their choice: check before enabling.",
1556
- ],
1557
- description:
1558
- "Control the singleton Telegram background poller. " +
1559
- "Params: `action` (required, 'enable' or 'disable'). " +
1560
- "The poller is OFF by default and only one can run per process. " +
1561
- "Returns the current poller status after the action.",
1562
- parameters: Type.Object({
1563
- action: Type.String({
1564
- description:
1565
- "'enable' to start the singleton background poller, 'disable' to stop it.",
1566
- }),
1567
- }),
1568
- execute: async (_toolCallId, params, _signal, _onUpdate) => {
1569
- const action = String(params.action ?? "")
1570
- .trim()
1571
- .toLowerCase();
1572
- if (action === "enable") {
1573
- const outcome = startSingletonPoller({
1574
- token,
1575
- allowedUsers,
1576
- stateFile,
1577
- fetchFn: fetchImpl,
1578
- intervalMs: interval,
1579
- mediaDir: env(ENV_MEDIA_DIR) || undefined,
1580
- stt,
1581
- onOwnerMessage: deliverOwnerMessage,
1582
- onCallbackQuery: handleCallbackQuery,
1583
- startedBy: "telegram_poller tool",
1584
- onStart: () => void placeControlKeyboardOnce(),
1585
- pidAlive: deps?.pidAlive,
1586
- });
1587
- if (!outcome.started) {
1588
- const refused = {
1589
- ok: false,
1590
- running: isPollerRunning(),
1591
- error: `telegram_poller enable refused: ${describePollerStartRefusal(outcome)}`,
1592
- ...(outcome.reason === "lock-held"
1593
- ? { holder: outcome.holder }
1594
- : {}),
1595
- };
1596
- return {
1597
- content: [
1598
- { type: "text" as const, text: JSON.stringify(refused) },
1599
- ],
1600
- details: refused,
1601
- };
1602
- }
1603
- return {
1604
- content: [
1605
- {
1606
- type: "text" as const,
1607
- text: JSON.stringify({
1608
- ok: true,
1609
- running: isPollerRunning(),
1610
- startedBy: pollerStartedBy,
1611
- }),
1612
- },
1613
- ],
1614
- details: { ok: true, running: isPollerRunning() },
1615
- };
1616
- }
1617
- if (action === "disable") {
1618
- const wasRunning = stopSingletonPoller();
1619
- return {
1620
- content: [
1621
- {
1622
- type: "text" as const,
1623
- text: JSON.stringify({ ok: true, running: false, wasRunning }),
1624
- },
1625
- ],
1626
- details: { ok: true, running: false, wasRunning },
1627
- };
1628
- }
1676
+ }
1677
+ if (action === "disable") {
1678
+ const wasRunning = stopSingletonPoller();
1629
1679
  return {
1630
1680
  content: [
1631
1681
  {
1632
1682
  type: "text" as const,
1633
- text: JSON.stringify({
1634
- ok: false,
1635
- error: `unknown action: ${action}. Use 'enable' or 'disable'.`,
1636
- }),
1683
+ text: JSON.stringify({ ok: true, running: false, wasRunning }),
1637
1684
  },
1638
1685
  ],
1639
- details: { ok: false, error: `unknown action: ${action}` },
1686
+ details: { ok: true, running: false, wasRunning },
1640
1687
  };
1641
- },
1642
- });
1643
- }
1688
+ }
1689
+ return {
1690
+ content: [
1691
+ {
1692
+ type: "text" as const,
1693
+ text: JSON.stringify({
1694
+ ok: false,
1695
+ error: `unknown action: ${action}. Use 'enable' or 'disable'.`,
1696
+ }),
1697
+ },
1698
+ ],
1699
+ details: { ok: false, error: `unknown action: ${action}` },
1700
+ };
1701
+ },
1702
+ });
1644
1703
 
1645
1704
  // /telegram slash command
1646
1705
  pi.registerCommand("telegram", {