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/README.md +69 -211
- package/build-info.json +5 -5
- package/docs/turn-complete-notify.md +2 -2
- package/package.json +9 -10
- package/src/index.ts +222 -163
- package/src/lib/poller-lock.ts +469 -48
- package/src/lib/session-role.ts +7 -6
- package/src/lib/telegram-client.ts +1 -1
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
|
|
5
|
-
*
|
|
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
|
|
471
|
-
*
|
|
472
|
-
*
|
|
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
|
-
| {
|
|
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 =
|
|
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 —
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
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
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1635
|
+
onOwnerMessage: deliverOwnerMessage,
|
|
1636
|
+
onCallbackQuery: handleCallbackQuery,
|
|
1637
|
+
startedBy: "telegram_poller tool",
|
|
1638
|
+
onStart: () => void placeControlKeyboardOnce(),
|
|
1639
|
+
pidAlive: deps?.pidAlive,
|
|
1515
1640
|
});
|
|
1516
|
-
if (!
|
|
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
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1666
|
+
text: JSON.stringify({
|
|
1667
|
+
ok: true,
|
|
1668
|
+
running: isPollerRunning(),
|
|
1669
|
+
startedBy: pollerStartedBy,
|
|
1670
|
+
lock: outcome.lock,
|
|
1671
|
+
}),
|
|
1538
1672
|
},
|
|
1539
1673
|
],
|
|
1540
|
-
details:
|
|
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:
|
|
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", {
|