@bytesbrains/pi-telegram-bridge 1.1.2 → 1.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytesbrains/pi-telegram-bridge",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
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",
@@ -49,6 +49,7 @@
49
49
  "test:watch": "vitest"
50
50
  },
51
51
  "devDependencies": {
52
+ "@types/node": "^25.8.0",
52
53
  "typescript": "^6.0.3",
53
54
  "vitest": "^2.1.9"
54
55
  }
package/src/helpers.ts CHANGED
@@ -6,246 +6,241 @@ const BASE_URL = "https://api.telegram.org/bot";
6
6
  // ── Config ──────────────────────────────────────────────────────────
7
7
 
8
8
  export function getToken(): string {
9
- const tok = process.env.TELEGRAM_BOT_TOKEN;
10
- if (!tok) throw new Error("TELEGRAM_BOT_TOKEN not set");
11
- return tok;
9
+ const tok = process.env.TELEGRAM_BOT_TOKEN;
10
+ if (!tok) throw new Error("TELEGRAM_BOT_TOKEN not set");
11
+ return tok;
12
12
  }
13
13
 
14
14
  export function getChatId(): string {
15
- const id = process.env.TELEGRAM_CHAT_ID;
16
- if (!id) throw new Error("TELEGRAM_CHAT_ID not set");
17
- return id;
15
+ const id = process.env.TELEGRAM_CHAT_ID;
16
+ if (!id) throw new Error("TELEGRAM_CHAT_ID not set");
17
+ return id;
18
18
  }
19
19
 
20
20
  // ── API ─────────────────────────────────────────────────────────────
21
21
 
22
22
  export async function telegramApi(
23
- method: string,
24
- body: Record<string, unknown>,
23
+ method: string,
24
+ body: Record<string, unknown>,
25
25
  ): Promise<unknown> {
26
- const url = `${BASE_URL}${getToken()}/${method}`;
27
- const res = await fetch(url, {
28
- method: "POST",
29
- headers: { "Content-Type": "application/json" },
30
- body: JSON.stringify(body),
31
- });
32
- if (!res.ok) {
33
- throw new Error(`Telegram API ${res.status}: ${await res.text()}`);
34
- }
35
- return res.json();
26
+ const url = `${BASE_URL}${getToken()}/${method}`;
27
+ const res = await fetch(url, {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify(body),
31
+ });
32
+ if (!res.ok) {
33
+ throw new Error(`Telegram API ${res.status}: ${await res.text()}`);
34
+ }
35
+ return res.json();
36
36
  }
37
37
 
38
38
  // ── Messaging ───────────────────────────────────────────────────────
39
39
 
40
40
  export async function sendMsg(
41
- text: string,
42
- replyMarkup?: object,
41
+ text: string,
42
+ replyMarkup?: object,
43
+ parseMode: "Markdown" | "HTML" | undefined = undefined,
43
44
  ): Promise<number> {
44
- const body: Record<string, unknown> = {
45
- chat_id: getChatId(),
46
- text,
47
- parse_mode: "Markdown",
48
- };
49
- if (replyMarkup) body.reply_markup = JSON.stringify(replyMarkup);
50
- const r = (await telegramApi("sendMessage", body)) as {
51
- ok: boolean;
52
- result: { message_id: number };
53
- };
54
- return r.result.message_id;
45
+ const body: Record<string, unknown> = {
46
+ chat_id: getChatId(),
47
+ text,
48
+ parse_mode: parseMode,
49
+ };
50
+ if (replyMarkup) body.reply_markup = JSON.stringify(replyMarkup);
51
+ const r = (await telegramApi("sendMessage", body)) as {
52
+ ok: boolean;
53
+ result: { message_id: number };
54
+ };
55
+ return r.result.message_id;
55
56
  }
56
57
 
57
58
  // ── Polling ─────────────────────────────────────────────────────────
58
59
 
59
60
  export async function pollReply(
60
- sentId: number,
61
- chatId: string,
62
- timeoutMs: number,
61
+ sentId: number,
62
+ chatId: string,
63
+ timeoutMs: number,
63
64
  ): Promise<string | null> {
64
- const token = getToken();
65
- const deadline = Date.now() + timeoutMs;
65
+ const token = getToken();
66
+ const deadline = Date.now() + timeoutMs;
66
67
 
67
- // Drain pending updates to start fresh
68
- let lastId = 0;
69
- try {
70
- const drain = await fetch(`${BASE_URL}${token}/getUpdates`, {
71
- method: "POST",
72
- headers: { "Content-Type": "application/json" },
73
- body: JSON.stringify({ timeout: 0 }),
74
- });
75
- if (drain.ok) {
76
- const d = (await drain.json()) as {
77
- ok: boolean;
78
- result: Array<{ update_id: number }>;
79
- };
80
- if (d.ok && d.result.length > 0) {
81
- lastId = d.result[d.result.length - 1].update_id;
82
- }
83
- }
84
- } catch {
85
- /* ignore drain errors */
86
- }
68
+ // Drain pending updates to start fresh
69
+ let lastId = 0;
70
+ try {
71
+ const drain = await fetch(`${BASE_URL}${token}/getUpdates`, {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({ timeout: 0 }),
75
+ });
76
+ if (drain.ok) {
77
+ const d = (await drain.json()) as {
78
+ ok: boolean;
79
+ result: Array<{ update_id: number }>;
80
+ };
81
+ if (d.ok && d.result.length > 0) {
82
+ lastId = d.result[d.result.length - 1].update_id;
83
+ }
84
+ }
85
+ } catch {
86
+ /* ignore drain errors */
87
+ }
87
88
 
88
- while (Date.now() < deadline) {
89
- const res = await fetch(
90
- `${BASE_URL}${token}/getUpdates`,
91
- {
92
- method: "POST",
93
- headers: { "Content-Type": "application/json" },
94
- body: JSON.stringify({ offset: lastId + 1, timeout: 5 }),
95
- },
96
- );
97
- if (!res.ok) {
98
- await new Promise((r) => setTimeout(r, 3000));
99
- continue;
100
- }
101
- const data = (await res.json()) as {
102
- ok: boolean;
103
- result: Array<{
104
- update_id: number;
105
- message?: {
106
- message_id: number;
107
- chat: { id: number };
108
- text?: string;
109
- };
110
- callback_query?: {
111
- id: string;
112
- message: { message_id: number; chat: { id: number } };
113
- data: string;
114
- };
115
- }>;
116
- };
117
- if (!data.ok) {
118
- await new Promise((r) => setTimeout(r, 2000));
119
- continue;
120
- }
121
- for (const u of data.result) {
122
- lastId = u.update_id;
123
- // Check for text reply
124
- if (
125
- u.message &&
126
- u.message.chat.id === Number(chatId) &&
127
- u.message.message_id > sentId &&
128
- u.message.text
129
- ) {
130
- return u.message.text;
131
- }
132
- // Check for callback query (inline button)
133
- if (
134
- u.callback_query &&
135
- u.callback_query.message.chat.id === Number(chatId)
136
- ) {
137
- await telegramApi("answerCallbackQuery", {
138
- callback_query_id: u.callback_query.id,
139
- });
140
- return u.callback_query.data;
141
- }
142
- }
143
- await new Promise((r) => setTimeout(r, 2000));
144
- }
145
- return null; // timeout
89
+ while (Date.now() < deadline) {
90
+ const res = await fetch(`${BASE_URL}${token}/getUpdates`, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({ offset: lastId + 1, timeout: 5 }),
94
+ });
95
+ if (!res.ok) {
96
+ await new Promise((r) => setTimeout(r, 3000));
97
+ continue;
98
+ }
99
+ const data = (await res.json()) as {
100
+ ok: boolean;
101
+ result: Array<{
102
+ update_id: number;
103
+ message?: {
104
+ message_id: number;
105
+ chat: { id: number };
106
+ text?: string;
107
+ };
108
+ callback_query?: {
109
+ id: string;
110
+ message: { message_id: number; chat: { id: number } };
111
+ data: string;
112
+ };
113
+ }>;
114
+ };
115
+ if (!data.ok) {
116
+ await new Promise((r) => setTimeout(r, 2000));
117
+ continue;
118
+ }
119
+ for (const u of data.result) {
120
+ lastId = u.update_id;
121
+ // Check for text reply
122
+ if (
123
+ u.message &&
124
+ u.message.chat.id === Number(chatId) &&
125
+ u.message.message_id > sentId &&
126
+ u.message.text
127
+ ) {
128
+ return u.message.text;
129
+ }
130
+ // Check for callback query (inline button)
131
+ if (
132
+ u.callback_query &&
133
+ u.callback_query.message.chat.id === Number(chatId)
134
+ ) {
135
+ await telegramApi("answerCallbackQuery", {
136
+ callback_query_id: u.callback_query.id,
137
+ });
138
+ return u.callback_query.data;
139
+ }
140
+ }
141
+ await new Promise((r) => setTimeout(r, 2000));
142
+ }
143
+ return null; // timeout
146
144
  }
147
145
 
148
146
  // ── Listener State ──────────────────────────────────────────────────
149
147
 
150
148
  export async function getBotId(): Promise<number | null> {
151
- try {
152
- const r = (await telegramApi("getMe", {})) as { result: { id: number } };
153
- return r.result.id;
154
- } catch {
155
- return null;
156
- }
149
+ try {
150
+ const r = (await telegramApi("getMe", {})) as { result: { id: number } };
151
+ return r.result.id;
152
+ } catch {
153
+ return null;
154
+ }
157
155
  }
158
156
 
159
157
  export async function seedLastUpdateId(
160
- lastUpdateId: number,
161
- signal?: AbortSignal,
158
+ lastUpdateId: number,
159
+ signal?: AbortSignal,
162
160
  ): Promise<number> {
163
- if (lastUpdateId !== 0) return lastUpdateId;
164
- const token = getToken();
165
- try {
166
- const res = await fetch(`${BASE_URL}${token}/getUpdates`, {
167
- method: "POST",
168
- headers: { "Content-Type": "application/json" },
169
- body: JSON.stringify({ timeout: 0 }),
170
- signal,
171
- });
172
- if (res.ok) {
173
- const d = (await res.json()) as {
174
- ok: boolean;
175
- result: Array<{ update_id: number }>;
176
- };
177
- if (d.ok && d.result.length > 0) {
178
- return d.result[d.result.length - 1].update_id;
179
- }
180
- }
181
- } catch {
182
- /* ignore */
183
- }
184
- return lastUpdateId;
161
+ if (lastUpdateId !== 0) return lastUpdateId;
162
+ const token = getToken();
163
+ try {
164
+ const res = await fetch(`${BASE_URL}${token}/getUpdates`, {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/json" },
167
+ body: JSON.stringify({ timeout: 0 }),
168
+ signal,
169
+ });
170
+ if (res.ok) {
171
+ const d = (await res.json()) as {
172
+ ok: boolean;
173
+ result: Array<{ update_id: number }>;
174
+ };
175
+ if (d.ok && d.result.length > 0) {
176
+ return d.result[d.result.length - 1].update_id;
177
+ }
178
+ }
179
+ } catch {
180
+ /* ignore */
181
+ }
182
+ return lastUpdateId;
185
183
  }
186
184
 
187
185
  export async function pollUpdates(
188
- token: string,
189
- chatId: string,
190
- botId: number | null,
191
- lastUpdateId: number,
192
- signal: AbortSignal,
193
- onMessage: (text: string) => void,
194
- onUpdateId: (id: number) => void,
186
+ token: string,
187
+ chatId: string,
188
+ botId: number | null,
189
+ lastUpdateId: number,
190
+ signal: AbortSignal,
191
+ onMessage: (text: string) => void,
192
+ onUpdateId: (id: number) => void,
195
193
  ): Promise<void> {
196
- while (!signal.aborted) {
197
- try {
198
- const res = await fetch(
199
- `${BASE_URL}${token}/getUpdates`,
200
- {
201
- method: "POST",
202
- headers: { "Content-Type": "application/json" },
203
- body: JSON.stringify({ offset: lastUpdateId + 1, timeout: 10 }),
204
- signal,
205
- },
206
- );
207
- if (!res.ok) {
208
- await new Promise((r) => setTimeout(r, 3000));
209
- continue;
210
- }
211
- const data = (await res.json()) as {
212
- ok: boolean;
213
- result: Array<{
214
- update_id: number;
215
- message?: {
216
- message_id: number;
217
- chat: { id: number };
218
- from?: { id: number; is_bot?: boolean };
219
- text?: string;
220
- };
221
- callback_query?: unknown;
222
- }>;
223
- };
224
- if (!data.ok) {
225
- await new Promise((r) => setTimeout(r, 2000));
226
- continue;
227
- }
228
- for (const u of data.result) {
229
- lastUpdateId = u.update_id;
230
- onUpdateId(lastUpdateId);
231
- // Skip callback queries (handled by telegram_ask)
232
- if (u.callback_query) continue;
233
- // Only process text messages from the configured chat, not from our bot
234
- if (
235
- u.message &&
236
- u.message.text &&
237
- String(u.message.chat.id) === chatId
238
- ) {
239
- if (u.message.from?.is_bot || u.message.from?.id === botId) {
240
- continue;
241
- }
242
- onMessage(u.message.text);
243
- }
244
- }
245
- } catch (e: unknown) {
246
- if (e instanceof Error && e.name === "AbortError") break;
247
- await new Promise((r) => setTimeout(r, 3000));
248
- }
249
- await new Promise((r) => setTimeout(r, 5000));
250
- }
194
+ while (!signal.aborted) {
195
+ try {
196
+ const res = await fetch(`${BASE_URL}${token}/getUpdates`, {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify({ offset: lastUpdateId + 1, timeout: 10 }),
200
+ signal,
201
+ });
202
+ if (!res.ok) {
203
+ await new Promise((r) => setTimeout(r, 3000));
204
+ continue;
205
+ }
206
+ const data = (await res.json()) as {
207
+ ok: boolean;
208
+ result: Array<{
209
+ update_id: number;
210
+ message?: {
211
+ message_id: number;
212
+ chat: { id: number };
213
+ from?: { id: number; is_bot?: boolean };
214
+ text?: string;
215
+ };
216
+ callback_query?: unknown;
217
+ }>;
218
+ };
219
+ if (!data.ok) {
220
+ await new Promise((r) => setTimeout(r, 2000));
221
+ continue;
222
+ }
223
+ for (const u of data.result) {
224
+ lastUpdateId = u.update_id;
225
+ onUpdateId(lastUpdateId);
226
+ // Skip callback queries (handled by telegram_ask)
227
+ if (u.callback_query) continue;
228
+ // Only process text messages from the configured chat, not from our bot
229
+ if (
230
+ u.message &&
231
+ u.message.text &&
232
+ String(u.message.chat.id) === chatId
233
+ ) {
234
+ if (u.message.from?.is_bot || u.message.from?.id === botId) {
235
+ continue;
236
+ }
237
+ onMessage(u.message.text);
238
+ }
239
+ }
240
+ } catch (e: unknown) {
241
+ if (e instanceof Error && e.name === "AbortError") break;
242
+ await new Promise((r) => setTimeout(r, 3000));
243
+ }
244
+ await new Promise((r) => setTimeout(r, 5000));
245
+ }
251
246
  }
package/src/index.ts CHANGED
@@ -77,7 +77,11 @@ export default function telegramBridge(pi: ExtensionAPI) {
77
77
  // onMessage: forward to pi as user message
78
78
  async (text: string) => {
79
79
  pi.sendUserMessage(text);
80
- await sendMsg(`👂 Got it! Working on: _${text.slice(0, 100)}_`);
80
+ await sendMsg(
81
+ `👂 Got it! Working on: _${text.slice(0, 100)}_`,
82
+ undefined,
83
+ "Markdown",
84
+ );
81
85
  },
82
86
  // onUpdateId: persist offset
83
87
  (id: number) => {
@@ -226,9 +230,13 @@ export default function telegramBridge(pi: ExtensionAPI) {
226
230
  const opts = params.options ?? ["Yes", "No", "Explain"];
227
231
  const kb = opts.map((o: string) => [{ text: o, callback_data: o }]);
228
232
  const timeoutMs = (params.timeoutMinutes ?? 30) * 60000;
229
- const mid = await sendMsg(`❓ *${params.question}*`, {
230
- inline_keyboard: kb,
231
- });
233
+ const mid = await sendMsg(
234
+ `❓ *${params.question}*`,
235
+ {
236
+ inline_keyboard: kb,
237
+ },
238
+ "Markdown",
239
+ );
232
240
  const reply = await pollReply(mid, chatId, timeoutMs);
233
241
  if (!reply) {
234
242
  await sendMsg("⏰ No reply. Proceeding autonomously.");
@@ -296,7 +304,7 @@ export default function telegramBridge(pi: ExtensionAPI) {
296
304
  message += `\n_What should I do?_`;
297
305
 
298
306
  const kb = opts.map((o: string) => [{ text: o, callback_data: o }]);
299
- const mid = await sendMsg(message, { inline_keyboard: kb });
307
+ const mid = await sendMsg(message, { inline_keyboard: kb }, "Markdown");
300
308
  const reply = await pollReply(mid, chatId, timeoutMs);
301
309
 
302
310
  if (!reply) {
@@ -328,7 +336,7 @@ export default function telegramBridge(pi: ExtensionAPI) {
328
336
  action = "explain";
329
337
  }
330
338
 
331
- await sendMsg(`✅ Choice received: _${choice}_`);
339
+ await sendMsg(`✅ Choice received: _${choice}_`, undefined, "Markdown");
332
340
 
333
341
  return {
334
342
  content: [
@@ -381,7 +389,7 @@ export default function telegramBridge(pi: ExtensionAPI) {
381
389
  const message = buildNotification(
382
390
  params as unknown as import("./templates").NotifyInput,
383
391
  );
384
- const mid = await sendMsg(message);
392
+ const mid = await sendMsg(message, undefined, "HTML");
385
393
  return {
386
394
  content: [{ type: "text", text: `Notification sent (id:${mid})` }],
387
395
  details: {},
package/src/templates.ts CHANGED
@@ -2,33 +2,37 @@
2
2
  * pi-telegram-bridge — Rich Message Templates
3
3
  *
4
4
  * Well-designed notification templates for Telegram.
5
- * All use Telegram MarkdownV2 format (no tables, no triple backticks).
5
+ * Uses HTML parse mode for reliable formatting with special characters.
6
6
  * Supports: issue, PR, CI/CD, work progress, agent session, factory, alerts.
7
7
  */
8
8
 
9
9
  // ── Helpers ─────────────────────────────────────────────────────────
10
10
 
11
- const BAR = "━".repeat(30);
11
+ const BAR = "━".repeat(20);
12
12
 
13
13
  function bold(s: string) {
14
- return `*${s}*`;
14
+ return `<b>${s}</b>`;
15
15
  }
16
16
  function italic(s: string) {
17
- return `_${s}_`;
17
+ return `<i>${s}</i>`;
18
18
  }
19
19
  function code(s: string) {
20
- return `\`${s}\``;
20
+ return `<code>${s}</code>`;
21
21
  }
22
22
  function strike(s: string) {
23
- return `~${s}~`;
23
+ return `<s>${s}</s>`;
24
24
  }
25
25
  function labelValue(label: string, value: string) {
26
26
  return `${bold(label + ":")} ${value}`;
27
27
  }
28
+ function link(url: string, text: string) {
29
+ return `<a href="${url}">${text}</a>`;
30
+ }
28
31
  function progressBar(pct: number, width = 12) {
29
32
  const filled = Math.round((pct / 100) * width);
30
- return `${"█".repeat(filled)}${"░".repeat(width - filled)} ${pct}%`;
33
+ return `<code>${"█".repeat(filled)}${"░".repeat(width - filled)}</code> ${pct}%`;
31
34
  }
35
+
32
36
  // ── CI/CD Status Icons ─────────────────────────────────────────────
33
37
 
34
38
  function jobIcon(status: string): string {
@@ -51,7 +55,7 @@ export interface IssueCard {
51
55
  number: number;
52
56
  title: string;
53
57
  state: "open" | "closed";
54
- status?: string; // "in progress", "blocked", etc.
58
+ status?: string;
55
59
  assignee?: string;
56
60
  labels?: string[];
57
61
  milestone?: string;
@@ -86,12 +90,14 @@ export function issueCard(i: IssueCard): string {
86
90
  msg += labelValue("Repo", italic(`${i.repo.owner}/${i.repo.name}`)) + "\n";
87
91
  msg += `${BAR}\n`;
88
92
  if (i.branch) msg += labelValue("Branch", code(i.branch)) + "\n";
89
- if (i.pr) {
93
+ if (i.pr && i.url) {
90
94
  msg +=
91
- labelValue("PR", `[#${i.pr.number}](${i.url ?? ""}) — ${i.pr.status}`) +
95
+ labelValue("PR", `${link(i.url, `#${i.pr.number}`)} — ${i.pr.status}`) +
92
96
  "\n";
97
+ } else if (i.pr) {
98
+ msg += labelValue("PR", `#${i.pr.number} — ${i.pr.status}`) + "\n";
93
99
  }
94
- if (i.url) msg += `\n[View on GitHub](${i.url})`;
100
+ if (i.url) msg += `\n${link(i.url, "View on GitHub")}`;
95
101
  return msg;
96
102
  }
97
103
 
@@ -137,7 +143,6 @@ export function prCard(pr: PRCard): string {
137
143
  msg += labelValue("Author", code(pr.author)) + "\n";
138
144
  msg += labelValue("Branch", `${code(pr.base)} ← ${code(pr.head)}`) + "\n";
139
145
 
140
- // CI status
141
146
  if (pr.ci) {
142
147
  const icon = pr.ci.status
143
148
  ? jobIcon(pr.ci.status)
@@ -152,7 +157,6 @@ export function prCard(pr: PRCard): string {
152
157
  ) + "\n";
153
158
  }
154
159
 
155
- // Reviews
156
160
  if (pr.reviews) {
157
161
  const parts: string[] = [];
158
162
  if (pr.reviews.approved > 0)
@@ -174,7 +178,7 @@ export function prCard(pr: PRCard): string {
174
178
  if (pr.breaking) msg += labelValue("Breaking", "⚠️ Yes") + "\n";
175
179
 
176
180
  msg += `${BAR}\n`;
177
- if (pr.url) msg += `[View on GitHub](${pr.url})`;
181
+ if (pr.url) msg += link(pr.url, "View on GitHub");
178
182
  return msg;
179
183
  }
180
184
 
@@ -184,8 +188,8 @@ export interface TaskProgress {
184
188
  title: string;
185
189
  emoji?: string;
186
190
  status: "started" | "in-progress" | "paused" | "blocked" | "complete";
187
- progress?: number; // 0–100
188
- elapsed?: string; // "3m 22s"
191
+ progress?: number;
192
+ elapsed?: string;
189
193
  eta?: string;
190
194
  details?: string[];
191
195
  metrics?: Record<string, string>;
@@ -234,7 +238,7 @@ export interface CIPipeline {
234
238
  workflow: string;
235
239
  runNumber: number;
236
240
  branch: string;
237
- event: string; // "push", "pull_request"
241
+ event: string;
238
242
  jobs: Array<{ name: string; status: string; duration?: string }>;
239
243
  triggeredBy?: string;
240
244
  url?: string;
@@ -254,7 +258,7 @@ export function ciPipeline(p: CIPipeline): string {
254
258
  msg += `${jobIcon(j.status)} ${code(j.name)}${dur}\n`;
255
259
  }
256
260
 
257
- if (p.url) msg += `\n[View Pipeline](${p.url})`;
261
+ if (p.url) msg += `\n${link(p.url, "View Pipeline")}`;
258
262
  return msg;
259
263
  }
260
264
 
@@ -389,7 +393,7 @@ export function ack(text: string, emoji = "✅"): string {
389
393
  return `${emoji} ${italic(text)}`;
390
394
  }
391
395
 
392
- // ── Decision Card (for override/approval context) ───────────────────
396
+ // ── Decision Card ───────────────────────────────────────────────────
393
397
 
394
398
  export interface DecisionCard {
395
399
  question: string;
@@ -415,7 +419,7 @@ export function decisionCard(d: DecisionCard): string {
415
419
  if (d.command) msg += labelValue("Command", code(d.command)) + "\n";
416
420
  if (d.reason) msg += labelValue("Reason", d.reason) + "\n";
417
421
  msg += `\n${bold("❓ " + d.question)}\n`;
418
- msg += `\n${d.options.map((o, i) => `${i + 1}\\. ${o}`).join("\n")}`;
422
+ msg += `\n${d.options.map((o, i) => `${i + 1}. ${o}`).join("\n")}`;
419
423
  return msg;
420
424
  }
421
425
 
@@ -483,14 +487,14 @@ export function diffSummary(d: DiffSummary): string {
483
487
  msg += ` • ${h}\n`;
484
488
  }
485
489
  }
486
- if (d.url) msg += `\n[View Diff](${d.url})`;
490
+ if (d.url) msg += `\n${link(d.url, "View Diff")}`;
487
491
  return msg;
488
492
  }
489
493
 
490
494
  // ── Quick Pickers (for telegram_ask buttons) ────────────────────────
491
495
 
492
496
  export function quickActions(label: string, actions: string[]): string {
493
- return `${bold(label)}\n${actions.map((a, i) => `${i + 1}\\. ${code(a)}`).join(" \n")}`;
497
+ return `${bold(label)}\n${actions.map((a, i) => `${i + 1}. ${code(a)}`).join(" \n")}`;
494
498
  }
495
499
 
496
500
  export function yesNoExplain(prompt: string): string {
@@ -499,7 +503,6 @@ export function yesNoExplain(prompt: string): string {
499
503
 
500
504
  // ── Build Notification (main dispatch for telegram_notify tool) ────
501
505
 
502
- // Flattened input from the tool; only the relevant subset is used per kind.
503
506
  export interface NotifyInput {
504
507
  kind:
505
508
  | "issue"
@@ -513,7 +516,6 @@ export interface NotifyInput {
513
516
  | "diff"
514
517
  | "decision"
515
518
  | "ack";
516
- // issue
517
519
  issueNumber?: number;
518
520
  issueTitle?: string;
519
521
  issueState?: "open" | "closed";
@@ -527,7 +529,6 @@ export interface NotifyInput {
527
529
  issuePRNumber?: number;
528
530
  issuePRStatus?: string;
529
531
  issueUrl?: string;
530
- // pr
531
532
  prNumber?: number;
532
533
  prTitle?: string;
533
534
  prState?: "open" | "closed" | "merged" | "draft";
@@ -549,7 +550,6 @@ export interface NotifyInput {
549
550
  prBreaking?: boolean;
550
551
  prDraft?: boolean;
551
552
  prUrl?: string;
552
- // task
553
553
  taskTitle?: string;
554
554
  taskEmoji?: string;
555
555
  taskStatus?: "started" | "in-progress" | "paused" | "blocked" | "complete";
@@ -558,7 +558,6 @@ export interface NotifyInput {
558
558
  taskEta?: string;
559
559
  taskDetails?: string[];
560
560
  taskMetrics?: Record<string, string>;
561
- // pipeline
562
561
  pipelineWorkflow?: string;
563
562
  pipelineRunNumber?: number;
564
563
  pipelineBranch?: string;
@@ -566,7 +565,6 @@ export interface NotifyInput {
566
565
  pipelineJobs?: Array<{ name: string; status: string; duration?: string }>;
567
566
  pipelineTriggeredBy?: string;
568
567
  pipelineUrl?: string;
569
- // session
570
568
  sessionName?: string;
571
569
  sessionBranch?: string;
572
570
  sessionModel?: string;
@@ -575,14 +573,12 @@ export interface NotifyInput {
575
573
  sessionTokensTotal?: number;
576
574
  sessionDuration?: string;
577
575
  sessionStatus?: "idle" | "streaming" | "tool-exec" | "waiting";
578
- // alert
579
576
  alertLevel?: "info" | "warning" | "error" | "critical";
580
577
  alertTitle?: string;
581
578
  alertSource?: string;
582
579
  alertDetail?: string;
583
580
  alertAction?: string;
584
581
  alertStack?: string;
585
- // factory-job
586
582
  jobId?: string;
587
583
  jobMode?: "single" | "chain" | "parallel";
588
584
  jobStatus?: "pending" | "running" | "success" | "failure" | "cancelled";
@@ -591,28 +587,24 @@ export interface NotifyInput {
591
587
  jobEnvironment?: string;
592
588
  jobDuration?: string;
593
589
  jobWorker?: string;
594
- // standup
595
590
  standupDate?: string;
596
591
  standupAgent?: string;
597
592
  standupWorkedOn?: string[];
598
593
  standupNextUp?: string[];
599
594
  standupBlockers?: string[];
600
595
  standupMetrics?: Record<string, string>;
601
- // diff
602
596
  diffTitle?: string;
603
597
  diffAdded?: number;
604
598
  diffRemoved?: number;
605
599
  diffFiles?: number;
606
600
  diffHighlights?: string[];
607
601
  diffUrl?: string;
608
- // decision
609
602
  decisionQuestion?: string;
610
603
  decisionContext?: string;
611
604
  decisionOptions?: string[];
612
605
  decisionRisk?: "low" | "medium" | "high" | "critical";
613
606
  decisionCommand?: string;
614
607
  decisionReason?: string;
615
- // ack
616
608
  ackText?: string;
617
609
  ackEmoji?: string;
618
610
  }