@bytesbrains/pi-telegram-bridge 1.3.1 → 1.4.0

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 CHANGED
@@ -1,153 +1,45 @@
1
1
  # pi-telegram-bridge
2
2
 
3
- Telegram bot bridge for pi agents — send messages, ask questions, receive photos, and get a live project dashboard on "hi".
3
+ Telegram bot bridge for pi agents — send messages, ask questions, and listen for human replies via Telegram.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- pi install npm:@bytesbrains/pi-telegram-bridge@1.1.6
8
+ pi install npm:@bytesbrains/pi-telegram-bridge
9
9
  ```
10
10
 
11
- ## Quick Setup
11
+ ## Configuration
12
12
 
13
- ### 1. Create a bot with @BotFather
14
-
15
- Open Telegram and chat with [@BotFather](https://t.me/BotFather):
16
-
17
- ```
18
- /newbot
19
- ```
20
-
21
- Follow the prompts. You'll get a token like:
22
-
23
- ```
24
- 123456:ABC-DEF1234ghikl-zyx57W2v1u123ew11
25
- ```
26
-
27
- ### 2. Get your Chat ID
28
-
29
- Send ANY message to your new bot on Telegram, then visit this URL in your browser:
30
-
31
- ```
32
- https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates
33
- ```
34
-
35
- Look for `"chat":{"id":123456789}` in the response. That's your chat ID.
36
-
37
- > **For group chats:** Add the bot to a group, send a message mentioning the bot, then check the same URL. Use the group chat ID.
38
-
39
- ### 3. Set environment variables
13
+ Set the following environment variables:
40
14
 
41
15
  ```bash
42
16
  export TELEGRAM_BOT_TOKEN="123456:ABC-DEF1234ghikl-zyx57W2v1u123ew11"
43
17
  export TELEGRAM_CHAT_ID="123456789"
44
18
  ```
45
19
 
46
- Add them to your shell profile (`~/.zshrc`, `~/.bashrc`) to persist across sessions.
47
-
48
- ### 4. Verify
49
-
50
- Start pi and run `telegram_status()`. You should see:
51
-
52
- ```
53
- Active. Bot: @your_bot_name (listener: 🟢 running)
54
- ```
55
-
56
- ## Environment Variables
57
-
58
- | Variable | Required | Default | Description |
59
- | -------------------- | -------- | ----------------------- | --------------------------------------------------------------- |
60
- | `TELEGRAM_BOT_TOKEN` | ✅ | — | Bot token from @BotFather |
61
- | `TELEGRAM_CHAT_ID` | ✅ | — | Chat ID to send/receive messages |
62
- | `TELEGRAM_MENTION` | ❌ | `@pi` (or bot username) | Only respond to messages containing this mention in group chats |
63
-
64
- ### TELEGRAM_MENTION
65
-
66
- Controls which messages the agent responds to in **group chats**:
67
-
68
- ```bash
69
- # Use a custom mention trigger
70
- export TELEGRAM_MENTION="@mybot"
71
-
72
- # Or let it auto-detect from the bot username
73
- export TELEGRAM_MENTION="" # defaults to @bot_username
74
- ```
75
-
76
- - **Private chat** (direct message to bot): All messages are treated as directed — no mention needed
77
- - **Group chat**: Only messages containing `@botname` are forwarded to the agent. Everything else is ignored.
78
-
79
- ## Features
80
-
81
- ### Status Dashboard
82
-
83
- Send `hi`, `/status`, or `status` to your bot on Telegram. It replies with a live project summary:
84
-
85
- ```
86
- 📊 pi-ext Status
87
-
88
- 📋 Open Issues: 3
89
- #1 telegram-bridge HTML parse crash
90
- #5 status dashboard on hi message
91
-
92
- 🔀 Open PRs: 2
93
- ✅ #2 fix(telegram-bridge): escape HTML entities
94
-
95
- 🔧 Last CI: ⏳ queued (5 recent runs)
96
- ```
97
-
98
- ### Photo Support
99
-
100
- **Send photos to the agent:** Send any photo to the bot — the agent downloads it and can use `read()` to view it. Add a caption for context:
101
-
102
- > 📸 Photo: `login page broken, 404 in console`
103
-
104
- **Send photos from the agent:** Use `telegram_send_photo()` to share screenshots or diagrams:
105
-
106
- ```
107
- telegram_send_photo(photoPath="/tmp/screenshot.png", caption="Build output")
108
- ```
109
-
110
- ### @Mention Support
111
-
112
- In group chats, the bot only responds to messages that mention it. This lets humans talk freely without triggering the agent.
113
-
114
- | Chat type | Message | Bot responds? |
115
- | --------- | ------------------------ | ---------------------------------------- |
116
- | Private | `hi` | ✅ |
117
- | Private | `@pi what's the status?` | ✅ (mention stripped) |
118
- | Group | `hi` | ❌ |
119
- | Group | `@pi what's the status?` | ✅ (agent receives `what's the status?`) |
20
+ | Variable | Required | Description |
21
+ |----------|----------|-------------|
22
+ | `TELEGRAM_BOT_TOKEN` | ✅ | Bot token from [@BotFather](https://t.me/BotFather) |
23
+ | `TELEGRAM_CHAT_ID` | ✅ | Target chat ID (your personal chat or a group) |
120
24
 
121
25
  ## Tools
122
26
 
123
27
  ### telegram_listen
124
28
 
125
- Check for new inbound messages (text and photos) from the human.
29
+ Check for new inbound messages from the human.
126
30
 
127
31
  ```
128
32
  telegram_listen()
129
33
  ```
130
34
 
131
- Returns photo paths and captions if a photo was received.
132
-
133
35
  ### telegram_send
134
36
 
135
- Send a one-way text message.
37
+ Send a one-way message. Use for status updates and progress reports.
136
38
 
137
39
  ```
138
40
  telegram_send(message="Build completed successfully ✅")
139
41
  ```
140
42
 
141
- ### telegram_send_photo
142
-
143
- Send a photo — supports local file paths and remote URLs.
144
-
145
- ```
146
- telegram_send_photo(photoPath="/tmp/screenshot.png")
147
- telegram_send_photo(photoPath="/tmp/screenshot.png", caption="Error in console")
148
- telegram_send_photo(photoPath="https://example.com/image.jpg")
149
- ```
150
-
151
43
  ### telegram_ask
152
44
 
153
45
  Ask a question with inline keyboard options and **wait** for a human reply. Blocks until answered or timeout (default 30 min).
@@ -195,7 +87,6 @@ telegram_override(
195
87
  | `timedOut` | `boolean` | Whether the request timed out |
196
88
 
197
89
  **Agent workflow:**
198
-
199
90
  1. Agent gets blocked by supervisor
200
91
  2. Calls `telegram_override(command, reason, context)`
201
92
  3. Checks `details.action`:
@@ -212,11 +103,33 @@ Check if the bridge is configured and running.
212
103
  telegram_status()
213
104
  ```
214
105
 
106
+ ## Background Listener
107
+
108
+ The extension starts a background listener on session start that polls for incoming Telegram messages. Any non-bot text message in the configured chat is forwarded to the agent as a user message. The agent responds with a confirmation.
109
+
110
+ The listener runs automatically — no manual setup needed.
111
+
112
+ ## How It Works
113
+
114
+ 1. **Session start**: The bridge restores the last processed update ID from session state and starts polling
115
+ 2. **Inbound messages**: Non-bot text messages in the configured chat are forwarded to the agent
116
+ 3. **Session end**: The listener stops cleanly on session shutdown
117
+ 4. **Update ID is persisted** across sessions to avoid processing duplicate messages
118
+
119
+ ## Get a Bot Token
120
+
121
+ 1. Open Telegram and chat with [@BotFather](https://t.me/BotFather)
122
+ 2. Send `/newbot` and follow the prompts
123
+ 3. Copy the token and set `TELEGRAM_BOT_TOKEN`
124
+ 4. Send a message to your bot, then visit:
125
+ `https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates`
126
+ 5. Copy the `chat.id` from the response and set `TELEGRAM_CHAT_ID`
127
+
215
128
  ## Requirements
216
129
 
217
130
  - Node.js >= 18
218
- - A Telegram bot token (from @BotFather)
219
- - A chat ID
131
+ - A Telegram bot token
132
+ - A chat ID to send/receive messages
220
133
 
221
134
  ## License
222
135
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytesbrains/pi-telegram-bridge",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Telegram bot bridge for pi agents — send messages, ask questions, and listen for human replies via Telegram.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,200 @@
1
+ /**
2
+ * pi-telegram-bridge — Commands Tests
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from "vitest";
5
+
6
+ // ── Mock helpers module ───────────────────────────────────────────
7
+ // Must be before any import of the module under test
8
+ let mockSendMsg: any;
9
+ let mockGetChatId: any;
10
+
11
+ vi.mock("../helpers", () => ({
12
+ sendMsg: (...args: any[]) => mockSendMsg(...args),
13
+ getChatId: () => mockGetChatId(),
14
+ }));
15
+
16
+ // Import after mocks
17
+ import { isPossibleCommand, handleCommand } from "../commands";
18
+
19
+ function mockPi() {
20
+ const messages: string[] = [];
21
+ return {
22
+ sendUserMessage: vi.fn((msg: string) => messages.push(msg)),
23
+ getMessages: () => messages,
24
+ };
25
+ }
26
+
27
+ beforeEach(() => {
28
+ vi.restoreAllMocks();
29
+ mockSendMsg = vi.fn().mockResolvedValue(12345);
30
+ mockGetChatId = vi.fn().mockReturnValue("987654321");
31
+ });
32
+
33
+ // ── isPossibleCommand ─────────────────────────────────────────────
34
+
35
+ describe("isPossibleCommand", () => {
36
+ it("detects slash-prefixed messages", () => {
37
+ expect(isPossibleCommand("/stop")).toBe(true);
38
+ expect(isPossibleCommand("/s")).toBe(true);
39
+ expect(isPossibleCommand("/help")).toBe(true);
40
+ expect(isPossibleCommand("/status")).toBe(true);
41
+ expect(isPossibleCommand("/i")).toBe(true);
42
+ });
43
+
44
+ it("rejects non-slash messages", () => {
45
+ expect(isPossibleCommand("hello")).toBe(false);
46
+ expect(isPossibleCommand("stop")).toBe(false);
47
+ expect(isPossibleCommand("")).toBe(false);
48
+ });
49
+
50
+ it("detects slash with leading whitespace", () => {
51
+ expect(isPossibleCommand(" /stop")).toBe(true);
52
+ });
53
+
54
+ it("rejects non-slash with leading whitespace", () => {
55
+ expect(isPossibleCommand(" hello")).toBe(false);
56
+ });
57
+ });
58
+
59
+ // ── handleCommand — Bridge Commands ───────────────────────────────
60
+
61
+ describe("handleCommand — bridge commands", () => {
62
+ it("handles /help and /status as bridge commands", async () => {
63
+ for (const cmd of ["/help", "/h", "/status", "/st", "/HELP", "/Status"]) {
64
+ const pi = mockPi();
65
+ const result = await handleCommand(cmd, pi as any, false);
66
+ expect(result.handled).toBe(true);
67
+ if (result.handled) expect(result.type).toBe("bridge");
68
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
69
+ }
70
+ });
71
+ });
72
+
73
+ // ── handleCommand — Agent Commands ────────────────────────────────
74
+
75
+ describe("handleCommand — agent commands", () => {
76
+ const agentCommands = [
77
+ "/stop",
78
+ "/s",
79
+ "/go",
80
+ "/g",
81
+ "/sum",
82
+ "/redo",
83
+ "/r",
84
+ "/commit",
85
+ "/c",
86
+ "/push",
87
+ "/p",
88
+ "/skip",
89
+ "/files",
90
+ "/f",
91
+ "/issue",
92
+ "/i",
93
+ ];
94
+
95
+ for (const cmd of agentCommands) {
96
+ it(`handles ${cmd} and injects to agent`, async () => {
97
+ const pi = mockPi();
98
+ const result = await handleCommand(cmd, pi as any, false);
99
+ expect(result.handled).toBe(true);
100
+ if (result.handled) expect(result.type).toBe("agent");
101
+ expect(pi.sendUserMessage).toHaveBeenCalledTimes(1);
102
+ expect(pi.getMessages()[0]).toContain("[COMMAND:");
103
+ });
104
+ }
105
+
106
+ it("handles commands case-insensitively", async () => {
107
+ const pi = mockPi();
108
+ const result = await handleCommand("/STOP", pi as any, false);
109
+ expect(result.handled).toBe(true);
110
+ });
111
+
112
+ it("handles commands with extra text", async () => {
113
+ const pi = mockPi();
114
+ const result = await handleCommand("/stop now please", pi as any, false);
115
+ expect(result.handled).toBe(true);
116
+ });
117
+
118
+ it("/issue injects full behavior spec", async () => {
119
+ const pi = mockPi();
120
+ await handleCommand("/issue", pi as any, false);
121
+ const injected = pi.getMessages()[0];
122
+ expect(injected).toContain("RESEARCH the project");
123
+ expect(injected).toContain("project_create_issue");
124
+ expect(injected).toContain("Do NOT ask permission to create");
125
+ });
126
+ });
127
+
128
+ // ── handleCommand — Unknown Commands ──────────────────────────────
129
+
130
+ describe("handleCommand — unknown commands", () => {
131
+ it("returns not handled for unknown / slash inputs", async () => {
132
+ const pi = mockPi();
133
+ const result = await handleCommand("/foobar", pi as any, false);
134
+ expect(result.handled).toBe(false);
135
+ expect(pi.sendUserMessage).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it("returns not handled for paths like /usr/bin/node", async () => {
139
+ const pi = mockPi();
140
+ const result = await handleCommand("/usr/bin/node", pi as any, false);
141
+ expect(result.handled).toBe(false);
142
+ });
143
+
144
+ it("returns not handled for empty string", async () => {
145
+ const pi = mockPi();
146
+ const result = await handleCommand("", pi as any, false);
147
+ expect(result.handled).toBe(false);
148
+ });
149
+ });
150
+
151
+ // ── handleCommand — Ack Behavior ──────────────────────────────────
152
+
153
+ describe("handleCommand — ack behavior", () => {
154
+ it("sends ack when sendAck is true", async () => {
155
+ const pi = mockPi();
156
+ await handleCommand("/stop", pi as any, true);
157
+ expect(mockSendMsg).toHaveBeenCalledWith(
158
+ expect.stringContaining("Stopping"),
159
+ );
160
+ });
161
+
162
+ it("does not send ack when sendAck is false", async () => {
163
+ const pi = mockPi();
164
+ mockSendMsg.mockClear();
165
+ await handleCommand("/stop", pi as any, false);
166
+ expect(mockSendMsg).not.toHaveBeenCalled();
167
+ });
168
+ });
169
+
170
+ // ── Alias Disambiguation ──────────────────────────────────────────
171
+
172
+ describe("alias disambiguation", () => {
173
+ it("/st maps to /status, not /stop", async () => {
174
+ const pi = mockPi();
175
+ const result = await handleCommand("/st", pi as any, false);
176
+ expect(result.handled).toBe(true);
177
+ if (result.handled) expect(result.type).toBe("bridge");
178
+ });
179
+
180
+ it("single-letter aliases map correctly", async () => {
181
+ const tests: Array<[string, string]> = [
182
+ ["/s", "/stop"],
183
+ ["/g", "/go"],
184
+ ["/r", "/redo"],
185
+ ["/c", "/commit"],
186
+ ["/p", "/push"],
187
+ ["/f", "/files"],
188
+ ["/i", "/issue"],
189
+ ["/h", "/help"],
190
+ ];
191
+ for (const [_alias, _canonical] of tests) {
192
+ const pi = mockPi();
193
+ const result = await handleCommand(_alias, pi as any, false);
194
+ expect(result.handled).toBe(true);
195
+ if (result.handled) {
196
+ expect(["bridge", "agent"]).toContain(result.type);
197
+ }
198
+ }
199
+ });
200
+ });
@@ -440,7 +440,7 @@ describe("pollUpdates", () => {
440
440
  controller.signal, onMessage, vi.fn(), onUpdateId,
441
441
  );
442
442
 
443
- expect(onMessage).toHaveBeenCalledWith('Hello agent!', '987654321');
443
+ expect(onMessage).toHaveBeenCalledWith("Hello agent!");
444
444
  expect(onUpdateId).toHaveBeenCalledWith(100);
445
445
  }, 15000);
446
446
 
@@ -618,7 +618,7 @@ describe("pollUpdates", () => {
618
618
  controller.signal, onMessage, vi.fn(), onUpdateId,
619
619
  );
620
620
 
621
- expect(onMessage).toHaveBeenCalledWith('recovered', '987654321');
621
+ expect(onMessage).toHaveBeenCalledWith("recovered");
622
622
  }, 20000);
623
623
 
624
624
  it("handles network errors with retry", async () => {
@@ -663,7 +663,7 @@ describe("pollUpdates", () => {
663
663
  controller.signal, onMessage, vi.fn(), onUpdateId,
664
664
  );
665
665
 
666
- expect(onMessage).toHaveBeenCalledWith('eventually', '987654321');
666
+ expect(onMessage).toHaveBeenCalledWith("eventually");
667
667
  }, 25000);
668
668
 
669
669
  it("stops gracefully when aborted immediately", async () => {
@@ -734,7 +734,7 @@ describe("pollUpdates", () => {
734
734
  controller.signal, onMessage, vi.fn(), onUpdateId,
735
735
  );
736
736
 
737
- expect(onMessage).toHaveBeenCalledWith('after not-ok', '987654321');
737
+ expect(onMessage).toHaveBeenCalledWith("after not-ok");
738
738
  }, 20000);
739
739
 
740
740
  it("ignores messages without text field", async () => {
@@ -577,86 +577,4 @@ describe("session lifecycle", () => {
577
577
  await new Promise((r) => setTimeout(r, 100));
578
578
  expect(mockPollUpdates).toHaveBeenCalledTimes(1);
579
579
  });
580
-
581
- it("does not crash when TELEGRAM_BOT_TOKEN is not set (graceful degradation)", async () => {
582
- vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
583
- vi.stubEnv("TELEGRAM_CHAT_ID", "");
584
- const consoleWarn = vi
585
- .spyOn(console, "warn")
586
- .mockImplementation(() => {});
587
-
588
- const pi = createMockPI();
589
- telegramBridge(pi as any);
590
-
591
- const ctx = {
592
- sessionManager: { getEntries: () => [] },
593
- };
594
-
595
- // Should NOT throw
596
- await expect(
597
- pi._listeners["session_start"][0]({}, ctx),
598
- ).resolves.toBeUndefined();
599
-
600
- // Should have warned about missing config
601
- expect(consoleWarn).toHaveBeenCalledWith(
602
- expect.stringContaining("TELEGRAM_BOT_TOKEN"),
603
- );
604
-
605
- // Listener should NOT have been started
606
- expect(mockPollUpdates).not.toHaveBeenCalled();
607
-
608
- consoleWarn.mockRestore();
609
- });
610
-
611
- it("does not crash when only TELEGRAM_BOT_TOKEN is missing", async () => {
612
- vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
613
- // CHAT_ID is set
614
- const consoleWarn = vi
615
- .spyOn(console, "warn")
616
- .mockImplementation(() => {});
617
-
618
- const pi = createMockPI();
619
- telegramBridge(pi as any);
620
-
621
- const ctx = {
622
- sessionManager: { getEntries: () => [] },
623
- };
624
-
625
- await expect(
626
- pi._listeners["session_start"][0]({}, ctx),
627
- ).resolves.toBeUndefined();
628
-
629
- expect(consoleWarn).toHaveBeenCalledWith(
630
- expect.stringContaining("TELEGRAM_BOT_TOKEN"),
631
- );
632
- expect(mockPollUpdates).not.toHaveBeenCalled();
633
-
634
- consoleWarn.mockRestore();
635
- });
636
-
637
- it("does not crash when only TELEGRAM_CHAT_ID is missing", async () => {
638
- vi.stubEnv("TELEGRAM_CHAT_ID", "");
639
- // BOT_TOKEN is set
640
- const consoleWarn = vi
641
- .spyOn(console, "warn")
642
- .mockImplementation(() => {});
643
-
644
- const pi = createMockPI();
645
- telegramBridge(pi as any);
646
-
647
- const ctx = {
648
- sessionManager: { getEntries: () => [] },
649
- };
650
-
651
- await expect(
652
- pi._listeners["session_start"][0]({}, ctx),
653
- ).resolves.toBeUndefined();
654
-
655
- expect(consoleWarn).toHaveBeenCalledWith(
656
- expect.stringContaining("TELEGRAM_BOT_TOKEN"),
657
- );
658
- expect(mockPollUpdates).not.toHaveBeenCalled();
659
-
660
- consoleWarn.mockRestore();
661
- });
662
580
  });
@@ -0,0 +1,285 @@
1
+ /**
2
+ * pi-telegram-bridge — Slash Command Handler
3
+ *
4
+ * Detects slash-prefixed messages and either:
5
+ * - Handles directly (bridge-only commands: /help, /status)
6
+ * - Injects structured instruction to agent (control commands)
7
+ * - Falls through for unknown commands (could be paths)
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { sendMsg, getChatId } from "./helpers";
12
+ import * as cp from "node:child_process";
13
+ import * as util from "node:util";
14
+ const execAsync = util.promisify(cp.exec);
15
+
16
+ // ── Command Definitions ─────────────────────────────────────────────
17
+
18
+ interface CommandDef {
19
+ name: string;
20
+ aliases: string[];
21
+ type: "bridge" | "agent";
22
+ description: string;
23
+ /** For agent-type commands: structured instruction injected to the agent */
24
+ inject?: string;
25
+ /** Acknowledgment sent back to Telegram */
26
+ ack: string;
27
+ }
28
+
29
+ const COMMANDS: CommandDef[] = [
30
+ {
31
+ name: "/stop",
32
+ aliases: ["/s"],
33
+ type: "agent",
34
+ description: "Pause current action at next safe point",
35
+ ack: "⏹ Stopping at next safe point...",
36
+ inject:
37
+ "[COMMAND: /stop]\n" +
38
+ "⚠️ Human requested you pause your current action. Stop at the next safe point and wait for further instructions.",
39
+ },
40
+ {
41
+ name: "/go",
42
+ aliases: ["/g"],
43
+ type: "agent",
44
+ description: "Proceed with current plan",
45
+ ack: "▶️ Proceeding...",
46
+ inject:
47
+ "[COMMAND: /go]\n" +
48
+ "✅ Human confirmed — proceed with your current plan.",
49
+ },
50
+ {
51
+ name: "/sum",
52
+ aliases: [],
53
+ type: "agent",
54
+ description: "Give a 3-bullet summary of current work",
55
+ ack: "📋 Summarizing...",
56
+ inject:
57
+ "[COMMAND: /sum]\n" +
58
+ "📋 SUMMARIZE: Give a quick 3-bullet summary of what you're doing right now — current task, progress, next step.",
59
+ },
60
+ {
61
+ name: "/redo",
62
+ aliases: ["/r"],
63
+ type: "agent",
64
+ description: "Retry last failed action",
65
+ ack: "🔄 Retrying last failed action...",
66
+ inject: "[COMMAND: /redo]\n" + "🔄 REDO: Retry your last failed action.",
67
+ },
68
+ {
69
+ name: "/commit",
70
+ aliases: ["/c"],
71
+ type: "agent",
72
+ description: "Commit current changes (checkpoint)",
73
+ ack: "📦 Committing changes...",
74
+ inject:
75
+ "[COMMAND: /commit]\n" +
76
+ "📦 COMMIT: This is a good checkpoint — commit your current staged/working changes now via contrib_propose.",
77
+ },
78
+ {
79
+ name: "/push",
80
+ aliases: ["/p"],
81
+ type: "agent",
82
+ description: "Push commits",
83
+ ack: "🚀 Pushing...",
84
+ inject: "[COMMAND: /push]\n" + "🚀 PUSH: Push your commits now.",
85
+ },
86
+ {
87
+ name: "/skip",
88
+ aliases: [],
89
+ type: "agent",
90
+ description: "Skip current step, move to next",
91
+ ack: "⏭ Skipping current step...",
92
+ inject:
93
+ "[COMMAND: /skip]\n" +
94
+ "⏭ SKIP: Skip the current step and move on to what's next.",
95
+ },
96
+ {
97
+ name: "/files",
98
+ aliases: ["/f"],
99
+ type: "agent",
100
+ description: "List modified files",
101
+ ack: "📁 Listing modified files...",
102
+ inject:
103
+ "[COMMAND: /files]\n" +
104
+ "📁 FILES: List what files you've modified so far in this session.",
105
+ },
106
+ {
107
+ name: "/issue",
108
+ aliases: ["/i"],
109
+ type: "agent",
110
+ description: "Create an issue from session context",
111
+ ack: "🎫 Creating issue from session context...",
112
+ inject:
113
+ "[COMMAND: /issue]\n" +
114
+ "🎫 Create a well-structured issue from the current session context. Follow this workflow:\n" +
115
+ "\n" +
116
+ "1. READ the session context — review conversation history, current branch, modified files.\n" +
117
+ "2. RESEARCH the project — read README, AGENTS.md, check for duplicate issues via project_list_issues.\n" +
118
+ "3. DECIDE if clarification is genuinely needed:\n" +
119
+ " - YES only if: scope is ambiguous, multiple valid approaches exist, or critical info is missing.\n" +
120
+ " - NO if: problem is clear from context, you can infer a good title and body.\n" +
121
+ "4. If NO clarification needed: draft the issue with a clear conventional title and structured body, create it via project_create_issue, share the ID and URL.\n" +
122
+ "5. If YES clarification needed: ask 1-2 focused questions via telegram_ask, then create the issue.\n" +
123
+ "6. NOTIFY the human via telegram_notify(kind='issue') with the created issue details.\n" +
124
+ "\n" +
125
+ "CRITICAL: Do NOT ask permission to create — /issue IS permission. Limit clarification to 1 round max. If no context found, say so and ask for a title.",
126
+ },
127
+ {
128
+ name: "/help",
129
+ aliases: ["/h"],
130
+ type: "bridge",
131
+ description: "Show available commands",
132
+ ack: "📖 Sending command list...",
133
+ },
134
+ {
135
+ name: "/status",
136
+ aliases: ["/st"],
137
+ type: "bridge",
138
+ description: "Show session status",
139
+ ack: "📊 Checking session status...",
140
+ },
141
+ ];
142
+
143
+ // ── Lookup ──────────────────────────────────────────────────────────
144
+
145
+ /** Case-insensitive, aliases included. Returns the canonical CommandDef. */
146
+ function findCommand(input: string): CommandDef | null {
147
+ const normalized = input.trim().toLowerCase();
148
+ // Exact match on name or any alias
149
+ for (const cmd of COMMANDS) {
150
+ if (normalized === cmd.name || cmd.aliases.includes(normalized)) {
151
+ return cmd;
152
+ }
153
+ }
154
+ // Also match prefix if it starts with the command name followed by space or end
155
+ // e.g. "/stop now please" should match /stop
156
+ const firstWord = normalized.split(/\s+/)[0];
157
+ for (const cmd of COMMANDS) {
158
+ if (firstWord === cmd.name || cmd.aliases.includes(firstWord)) {
159
+ return cmd;
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+
165
+ // ── Bridge-Handled Commands ────────────────────────────────────────
166
+
167
+ async function handleHelp(): Promise<string> {
168
+ const lines: string[] = ["<b>⌨️ Available Commands</b>\n"];
169
+ lines.push("<b>━━━━━━━━━━━━━━━━━━━━</b>\n");
170
+
171
+ lines.push("<b>🎛 Control</b>");
172
+ for (const cmd of COMMANDS.filter((c) => c.type === "agent")) {
173
+ const aliases = cmd.aliases.length > 0 ? cmd.aliases.join(" ") + " " : "";
174
+ lines.push(` <code>${aliases}${cmd.name}</code> — ${cmd.description}`);
175
+ }
176
+
177
+ lines.push("\n<b>ℹ️ Info</b>");
178
+ for (const cmd of COMMANDS.filter((c) => c.type === "bridge")) {
179
+ const aliases = cmd.aliases.length > 0 ? cmd.aliases.join(" ") + " " : "";
180
+ lines.push(` <code>${aliases}${cmd.name}</code> — ${cmd.description}`);
181
+ }
182
+
183
+ return lines.join("\n");
184
+ }
185
+
186
+ async function handleStatus(): Promise<string> {
187
+ let branch = "unknown";
188
+ try {
189
+ const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
190
+ timeout: 3000,
191
+ });
192
+ branch = stdout.trim();
193
+ } catch {
194
+ /* ignore */
195
+ }
196
+
197
+ let nodeVersion = "unknown";
198
+ try {
199
+ nodeVersion = process.version;
200
+ } catch {
201
+ /* ignore */
202
+ }
203
+
204
+ const lines: string[] = [];
205
+ lines.push("<b>📊 Session Status</b>");
206
+ lines.push("<b>━━━━━━━━━━━━━━━━━━━━</b>");
207
+ lines.push(`<b>Branch:</b> <code>${escapeHtmlCmd(branch)}</code>`);
208
+ lines.push(`<b>Node:</b> <code>${escapeHtmlCmd(nodeVersion)}</code>`);
209
+ lines.push(`<b>Chat ID:</b> <code>${escapeHtmlCmd(getChatId())}</code>`);
210
+
211
+ // Uptime from process
212
+ const uptime = Math.floor(process.uptime());
213
+ const mins = Math.floor(uptime / 60);
214
+ const hrs = Math.floor(mins / 60);
215
+ const uptimeStr =
216
+ hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m ${uptime % 60}s`;
217
+ lines.push(`<b>Process uptime:</b> <code>${uptimeStr}</code>`);
218
+
219
+ return lines.join("\n");
220
+ }
221
+
222
+ function escapeHtmlCmd(s: string): string {
223
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
224
+ }
225
+
226
+ // ── Main Handler ────────────────────────────────────────────────────
227
+
228
+ export type CommandResult =
229
+ | { handled: true; type: "bridge" }
230
+ | { handled: true; type: "agent" }
231
+ | { handled: false };
232
+
233
+ /**
234
+ * Process a slash command from a Telegram message.
235
+ *
236
+ * @param text - The raw message text (e.g., "/stop" or "/status")
237
+ * @param pi - ExtensionAPI for injecting agent messages
238
+ * @param sendAck - Whether to send an acknowledgment to Telegram
239
+ * @returns Result indicating how the command was handled
240
+ */
241
+ export async function handleCommand(
242
+ text: string,
243
+ pi: ExtensionAPI,
244
+ sendAck = true,
245
+ ): Promise<CommandResult> {
246
+ const cmd = findCommand(text);
247
+ if (!cmd) return { handled: false };
248
+
249
+ // Send acknowledgment
250
+ if (sendAck) {
251
+ await sendMsg(cmd.ack).catch(() => {});
252
+ }
253
+
254
+ if (cmd.type === "bridge") {
255
+ if (cmd.name === "/help") {
256
+ const helpText = await handleHelp();
257
+ await sendMsg(helpText, undefined, "HTML").catch(() => {});
258
+ } else if (cmd.name === "/status") {
259
+ const statusText = await handleStatus();
260
+ await sendMsg(statusText, undefined, "HTML").catch(() => {});
261
+ }
262
+ return { handled: true, type: "bridge" };
263
+ }
264
+
265
+ // Agent-directed: inject structured instruction
266
+ if (cmd.inject) {
267
+ pi.sendUserMessage(cmd.inject);
268
+ }
269
+ return { handled: true, type: "agent" };
270
+ }
271
+
272
+ /**
273
+ * Check if a message starts with a slash (potential command).
274
+ * Used as a quick pre-filter before calling handleCommand.
275
+ */
276
+ export function isPossibleCommand(text: string): boolean {
277
+ return text.trimStart().startsWith("/");
278
+ }
279
+
280
+ /**
281
+ * Get all command definitions (for /help formatting).
282
+ */
283
+ export function getCommands(): CommandDef[] {
284
+ return COMMANDS;
285
+ }
package/src/helpers.ts CHANGED
@@ -57,11 +57,7 @@ export async function sendMsg(
57
57
  } catch (e: unknown) {
58
58
  // Retry without parse_mode if formatting caused a 400 parse error
59
59
  const msg = e instanceof Error ? e.message : String(e);
60
- if (
61
- parseMode &&
62
- msg.includes("400") &&
63
- (msg.includes("parse") || msg.includes("entity"))
64
- ) {
60
+ if (parseMode && msg.includes("400") && (msg.includes("parse") || msg.includes("entity"))) {
65
61
  delete body.parse_mode;
66
62
  const r2 = (await telegramApi("sendMessage", body)) as {
67
63
  ok: boolean;
@@ -70,8 +66,7 @@ export async function sendMsg(
70
66
  return r2.result.message_id;
71
67
  }
72
68
  throw e;
73
- }
74
- }
69
+ }}
75
70
 
76
71
  // ── Photo / File Support ────────────────────────────────────────────
77
72
 
@@ -82,11 +77,14 @@ export async function sendMsg(
82
77
  export async function getFileUrl(fileId: string): Promise<string | null> {
83
78
  const token = getToken();
84
79
  try {
85
- const res = await fetch(`${BASE_URL}${token}/getFile`, {
86
- method: "POST",
87
- headers: { "Content-Type": "application/json" },
88
- body: JSON.stringify({ file_id: fileId }),
89
- });
80
+ const res = await fetch(
81
+ `${BASE_URL}${token}/getFile`,
82
+ {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({ file_id: fileId }),
86
+ },
87
+ );
90
88
  if (!res.ok) return null;
91
89
  const data = (await res.json()) as {
92
90
  ok: boolean;
@@ -104,10 +102,7 @@ export async function getFileUrl(fileId: string): Promise<string | null> {
104
102
  * Returns the file path on success, null on failure.
105
103
  * Creates parent directories automatically.
106
104
  */
107
- export async function downloadFile(
108
- fileId: string,
109
- destPath: string,
110
- ): Promise<string | null> {
105
+ export async function downloadFile(fileId: string, destPath: string): Promise<string | null> {
111
106
  try {
112
107
  const fileUrl = await getFileUrl(fileId);
113
108
  if (!fileUrl) return null;
@@ -151,7 +146,7 @@ export async function sendPhoto(
151
146
  const token = getToken();
152
147
  const formData = new FormData();
153
148
  formData.set("chat_id", getChatId());
154
- const blob = new Blob([fileData]);
149
+ const blob = new Blob([fileData] as any);
155
150
  formData.set("photo", blob, filename);
156
151
  if (caption) {
157
152
  formData.set("caption", caption);
@@ -167,11 +162,7 @@ export async function sendPhoto(
167
162
  if (!res.ok) {
168
163
  const errText = await res.text();
169
164
  // Retry without caption if parse_mode caused a 400
170
- if (
171
- caption &&
172
- res.status === 400 &&
173
- (errText.includes("parse") || errText.includes("entity"))
174
- ) {
165
+ if (caption && res.status === 400 && (errText.includes("parse") || errText.includes("entity"))) {
175
166
  return sendPhoto(photoPath);
176
167
  }
177
168
  throw new Error(`sendPhoto failed: ${res.status} ${errText}`);
@@ -317,7 +308,7 @@ export async function pollUpdates(
317
308
  botId: number | null,
318
309
  lastUpdateId: number,
319
310
  signal: AbortSignal,
320
- onMessage: (text: string, msgChatId: string) => void,
311
+ onMessage: (text: string) => void,
321
312
  onPhoto: (photoId: string, caption?: string) => void,
322
313
  onUpdateId: (id: number) => void,
323
314
  ): Promise<void> {
@@ -342,12 +333,7 @@ export async function pollUpdates(
342
333
  chat: { id: number };
343
334
  from?: { id: number; is_bot?: boolean };
344
335
  text?: string;
345
- photo?: Array<{
346
- file_id: string;
347
- file_unique_id: string;
348
- width: number;
349
- height: number;
350
- }>;
336
+ photo?: Array<{ file_id: string; file_unique_id: string; width: number; height: number }>;
351
337
  caption?: string;
352
338
  };
353
339
  callback_query?: unknown;
@@ -363,7 +349,10 @@ export async function pollUpdates(
363
349
  // Skip callback queries (handled by telegram_ask)
364
350
  if (u.callback_query) continue;
365
351
  // Process messages from the configured chat, not from our bot
366
- if (u.message && String(u.message.chat.id) === chatId) {
352
+ if (
353
+ u.message &&
354
+ String(u.message.chat.id) === chatId
355
+ ) {
367
356
  if (u.message.from?.is_bot || u.message.from?.id === botId) {
368
357
  continue;
369
358
  }
@@ -375,7 +364,7 @@ export async function pollUpdates(
375
364
  }
376
365
  // Text message
377
366
  if (u.message.text) {
378
- onMessage(u.message.text, String(u.message.chat.id));
367
+ onMessage(u.message.text);
379
368
  }
380
369
  }
381
370
  }
package/src/index.ts CHANGED
@@ -32,149 +32,12 @@ import {
32
32
  notifySchema,
33
33
  sendPhotoSchema,
34
34
  } from "./tools/telegram";
35
+ import { handleCommand, isPossibleCommand } from "./commands";
35
36
 
36
37
  export default function telegramBridge(pi: ExtensionAPI) {
37
38
  let botId: number | null = null;
38
39
  let listenerAbort: AbortController | null = null;
39
40
  let lastUpdateId = 0;
40
- let mentionTrigger = "@pi"; // configurable via TELEGRAM_MENTION env var
41
- const sessionStartTime = Date.now();
42
- let messageCount = 0;
43
-
44
- // ── Mention support ────────────────────────────────────────────
45
-
46
- async function resolveMentionTrigger(): Promise<string> {
47
- // 1. Explicit env var overrides everything
48
- const envMention = process.env.TELEGRAM_MENTION;
49
- if (envMention)
50
- return envMention.startsWith("@") ? envMention : "@" + envMention;
51
- // 2. Try bot username from getMe
52
- try {
53
- const r = (await telegramApi("getMe", {})) as {
54
- result: { username: string };
55
- };
56
- if (r.result?.username) return "@" + r.result.username;
57
- } catch {
58
- /* fall through */
59
- }
60
- return "@pi";
61
- }
62
-
63
- /** Strip mention from message text and return cleaned text + whether it was mentioned. */
64
- function checkMention(
65
- text: string,
66
- isPrivateChat: boolean,
67
- ): { mentioned: boolean; cleanedText: string } {
68
- if (isPrivateChat) return { mentioned: true, cleanedText: text };
69
- const trigger = mentionTrigger.slice(1).toLowerCase(); // strip @
70
- const patterns = [
71
- new RegExp(`@${trigger}\\b`, "i"), // @pi at start or middle
72
- new RegExp(`^${trigger}\\b`, "i"), // pi at start (no @)
73
- ];
74
- for (const re of patterns) {
75
- if (re.test(text)) {
76
- const cleaned = text.replace(re, "").trim();
77
- return { mentioned: true, cleanedText: cleaned || text };
78
- }
79
- }
80
- return { mentioned: false, cleanedText: text };
81
- }
82
-
83
- // ── Dashboard helper ────────────────────────────────────────────
84
-
85
- async function sendDashboard() {
86
- const token = getToken();
87
- const giteaToken = process.env.GITEA_TOKEN || process.env.GIT_TOKEN || "";
88
- const apiBase = "http://127.0.0.1:3001/api/v1/repos/factory/pi-ext";
89
- const headers: Record<string, string> = {
90
- "Content-Type": "application/json",
91
- };
92
- if (giteaToken) headers["Authorization"] = `token ${giteaToken}`;
93
-
94
- try {
95
- const [issuesR, prsR, runsR] = await Promise.all([
96
- fetch(`${apiBase}/issues?state=open&limit=10`, { headers })
97
- .then((r) => r.json())
98
- .catch(() => []),
99
- fetch(`${apiBase}/pulls?state=open&limit=10`, { headers })
100
- .then((r) => r.json())
101
- .catch(() => []),
102
- fetch(`${apiBase}/actions/runs?limit=3`, { headers })
103
- .then((r) => r.json())
104
- .catch(() => ({ workflow_runs: [] })),
105
- ]);
106
-
107
- const issues = Array.isArray(issuesR) ? issuesR : [];
108
- const prs = Array.isArray(prsR) ? prsR : [];
109
- const runs = (runsR as any).workflow_runs ?? [];
110
-
111
- const lines: string[] = [];
112
- lines.push("📊 <b>pi-ext Status</b>");
113
- lines.push("");
114
-
115
- // Open issues
116
- lines.push(`📋 <b>Open Issues:</b> ${issues.length}`);
117
- if (issues.length > 0) {
118
- for (const i of (issues as any[]).slice(0, 5)) {
119
- const labels = i.labels?.length
120
- ? ` [${i.labels.map((l: any) => l.name).join(", ")}]`
121
- : "";
122
- lines.push(` #${i.number} ${i.title.slice(0, 60)}${labels}`);
123
- }
124
- } else {
125
- lines.push(" (none)");
126
- }
127
- lines.push("");
128
-
129
- // Open PRs
130
- lines.push(`🔀 <b>Open PRs:</b> ${prs.length}`);
131
- if (prs.length > 0) {
132
- for (const p of (prs as any[]).slice(0, 5)) {
133
- const mergeIcon = p.mergeable ? "✅" : "❌";
134
- lines.push(` ${mergeIcon} #${p.number} ${p.title.slice(0, 55)}`);
135
- }
136
- } else {
137
- lines.push(" (none)");
138
- }
139
- lines.push("");
140
-
141
- // CI status
142
- if (runs.length > 0) {
143
- const latest = runs[0];
144
- const statusIcon =
145
- latest.status === "success"
146
- ? "✅"
147
- : latest.status === "failure"
148
- ? "❌"
149
- : latest.status === "running"
150
- ? "🔄"
151
- : "⏳";
152
- lines.push(
153
- `🔧 <b>Last CI:</b> ${statusIcon} ${latest.status} (${runs.length} recent runs)`,
154
- );
155
- } else {
156
- lines.push("🔧 <b>Last CI:</b> no runs");
157
- }
158
- lines.push("");
159
-
160
- // Session metrics
161
- const uptimeMin = Math.floor((Date.now() - sessionStartTime) / 60000);
162
- const uptimeStr =
163
- uptimeMin < 60
164
- ? `${uptimeMin}m`
165
- : `${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m`;
166
- lines.push("🤖 <b>Session</b>");
167
- lines.push(` Uptime: ${uptimeStr}`);
168
- lines.push(` Messages: ${messageCount}`);
169
- lines.push(` Mention: ${mentionTrigger}`);
170
-
171
- await sendMsg(lines.join("\n"), undefined, "HTML");
172
- } catch {
173
- await sendMsg(
174
- "⚠️ Could not fetch project dashboard. Gitea may be unreachable.",
175
- );
176
- }
177
- }
178
41
 
179
42
  // ── Session lifecycle ───────────────────────────────────────────
180
43
 
@@ -189,23 +52,8 @@ export default function telegramBridge(pi: ExtensionAPI) {
189
52
  lastUpdateId = (entry.data as { update_id: number }).update_id;
190
53
  }
191
54
  }
192
- // Skip listener if Telegram is not configured
193
- if (!process.env.TELEGRAM_BOT_TOKEN || !process.env.TELEGRAM_CHAT_ID) {
194
- console.warn(
195
- "pi-telegram-bridge: TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID not set. Listener disabled.",
196
- );
197
- return;
198
- }
199
55
  // Start background listener
200
- try {
201
- mentionTrigger = await resolveMentionTrigger();
202
- startListener();
203
- } catch (e: unknown) {
204
- console.warn(
205
- "pi-telegram-bridge: Failed to start listener:",
206
- e instanceof Error ? e.message : e,
207
- );
208
- }
56
+ startListener();
209
57
  });
210
58
 
211
59
  pi.on("session_shutdown", () => {
@@ -242,21 +90,16 @@ export default function telegramBridge(pi: ExtensionAPI) {
242
90
  botId,
243
91
  lastUpdateId,
244
92
  signal,
245
- // onMessage: forward to pi (with mention check in groups), or dashboard on "hi"
246
- async (text: string, msgChatId: string) => {
247
- const isPrivate = msgChatId === chatId;
248
- const { mentioned, cleanedText } = checkMention(text, isPrivate);
249
- if (!mentioned) return; // ignore unmentioned messages in groups
250
-
251
- const trimmed = cleanedText.trim().toLowerCase();
252
- if (trimmed === "hi" || trimmed === "/status" || trimmed === "status") {
253
- await sendDashboard();
254
- return;
93
+ // onMessage: check for slash commands first, otherwise forward to pi
94
+ async (text: string) => {
95
+ if (isPossibleCommand(text)) {
96
+ const result = await handleCommand(text, pi, true);
97
+ if (result.handled) return; // bridge or agent handled it
98
+ // Unknown command — fall through to forward as regular message
255
99
  }
256
- messageCount++;
257
- pi.sendUserMessage(cleanedText);
100
+ pi.sendUserMessage(text);
258
101
  await sendMsg(
259
- `👂 Got it! Working on: _${cleanedText.slice(0, 100)}_`,
102
+ `👂 Got it! Working on: _${text.slice(0, 100)}_`,
260
103
  undefined,
261
104
  "Markdown",
262
105
  );
@@ -395,14 +238,39 @@ export default function telegramBridge(pi: ExtensionAPI) {
395
238
  }
396
239
  // Text message
397
240
  if (u.message.text) {
241
+ const text = u.message.text;
242
+ // Check for slash commands
243
+ if (isPossibleCommand(text)) {
244
+ const result = await handleCommand(text, pi, false);
245
+ if (result.handled) {
246
+ const label =
247
+ result.type === "bridge"
248
+ ? "(handled by bridge)"
249
+ : "(injected to agent)";
250
+ return {
251
+ content: [
252
+ {
253
+ type: "text",
254
+ text: `🔧 Command processed: "${text}" ${label}`,
255
+ },
256
+ ],
257
+ details: {
258
+ message: text,
259
+ command: true,
260
+ commandType: result.type,
261
+ },
262
+ };
263
+ }
264
+ // Unknown command — fall through to return as regular message
265
+ }
398
266
  return {
399
267
  content: [
400
268
  {
401
269
  type: "text",
402
- text: `📩 New message: "${u.message.text}"`,
270
+ text: `📩 New message: "${text}"`,
403
271
  },
404
272
  ],
405
- details: { message: u.message.text },
273
+ details: { message: text },
406
274
  };
407
275
  }
408
276
  }
package/src/templates.ts CHANGED
@@ -12,7 +12,10 @@ const BAR = "━".repeat(20);
12
12
 
13
13
  /** Escape user text for safe embedding inside HTML tags. */
14
14
  function escapeHtml(s: string): string {
15
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
15
+ return s
16
+ .replace(/&/g, "&amp;")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;");
16
19
  }
17
20
 
18
21
  function bold(s: string) {
@@ -28,7 +31,7 @@ function strike(s: string) {
28
31
  return `<s>${escapeHtml(s)}</s>`;
29
32
  }
30
33
  function labelValue(label: string, value: string) {
31
- return `${bold(label + ":")} ${value}`;
34
+ return `${bold(label + ":")} ${escapeHtml(value)}`;
32
35
  }
33
36
  function link(url: string, text: string) {
34
37
  return `<a href="${url}">${escapeHtml(text)}</a>`;
@@ -254,7 +257,7 @@ export function ciPipeline(p: CIPipeline): string {
254
257
  msg += `${BAR}\n`;
255
258
  msg += labelValue("Run", `#${p.runNumber}`) + "\n";
256
259
  msg += labelValue("Branch", code(p.branch)) + "\n";
257
- msg += labelValue("Trigger", escapeHtml(p.event)) + "\n";
260
+ msg += labelValue("Trigger", p.event) + "\n";
258
261
  if (p.triggeredBy) msg += labelValue("By", code(p.triggeredBy)) + "\n";
259
262
  msg += `${BAR}\n`;
260
263