paperclip-plugin-telegram 0.2.3 → 0.2.7

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Van Horn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,27 +1,66 @@
1
1
  # paperclip-plugin-telegram
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/paperclip-plugin-telegram)](https://www.npmjs.com/package/paperclip-plugin-telegram)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
5
 
5
- Bidirectional Telegram integration for [Paperclip](https://github.com/paperclipai/paperclip). Push agent notifications to Telegram, receive bot commands, approve requests with inline buttons, and route forum topics to projects.
6
+ Bidirectional Telegram integration for [Paperclip](https://github.com/paperclipai/paperclip). Push agent notifications to Telegram, receive bot commands, approve requests with inline buttons, gather community signals, run multi-agent sessions in threads, process media attachments, register custom commands, and deploy proactive agent suggestions.
6
7
 
7
8
  Built on the Paperclip plugin SDK and the domain event bridge ([PR #909](https://github.com/paperclipai/paperclip/pull/909)).
8
9
 
9
- ## Features
10
+ ## Why this exists
10
11
 
11
- ### Core
12
+ Multiple Paperclip users asked for notifications on the same day the plugin system shipped (2026-03-14):
12
13
 
13
- - **Push notifications** on issue created, issue done, approval requested, agent error, run lifecycle
14
- - **Bot commands**: `/status`, `/issues`, `/agents`, `/approve`, `/help`, `/connect`, `/connect-topic`, `/acp`, `/commands`
15
- - **Inline buttons** for approve/reject on approval notifications
16
- - **Inbound routing**: reply to a notification in Telegram and it becomes an issue comment in Paperclip
17
- - **Per-company chat routing** with `/connect` command
18
- - **Forum topic routing**: map Telegram topics to Paperclip projects
19
- - **Daily digest**: scheduled summary of agent activity
20
- - **MarkdownV2 formatting** with automatic plain text fallback
21
- - **ACP bridge**: `/acp spawn`, `/acp status`, `/acp cancel`, `/acp close` for managing agent sessions
14
+ > "is there a way to have codex/claude check paperclip to see when tasks are done without me prompting it?" - @Choose Liberty, Discord #dev
22
15
 
23
- ### Phase 1: HITL Escalation
16
+ > "basically to have it 'let me know when its done'" - @Choose Liberty, Discord #dev
17
+
18
+ > "can claude code check paperclip to see when tasks are done" - @Nascozz, Discord #dev
19
+
20
+ @dotta (maintainer) responded: "we're also adding issue-changed hooks for plugins so when that lands someone could [make notifications]." The event bridge ([PR #909](https://github.com/paperclipai/paperclip/pull/909)) shipped that same day. @Ryze said "Really excited by the plugins. I had developed a custom plugin bridge that I will now deprecate and migrate over to the new supported plugin system."
21
+
22
+ This is that plugin.
23
+
24
+ ## What it does
25
+
26
+ ### Notifications (MarkdownV2 with plain text fallback)
27
+
28
+ - **Issue created** - Title, description, status, priority, assignee, project fields, and a "View Issue" link
29
+ - **Issue done** - Completion confirmation with status fields
30
+ - **Approval requested** - Interactive **Approve** and **Reject** inline buttons. Click to act without leaving Telegram.
31
+ - **Agent error** - Error message with warning indicator
32
+ - **Agent run started/finished** - Lifecycle notifications
24
33
 
34
+ ### Interactive approvals
35
+ - Approve/reject inline buttons on every approval notification
36
+ - Clicking a button calls the Paperclip API and updates the Telegram message inline
37
+ - Callback query acknowledgment with result text
38
+
39
+ ### Per-type chat routing
40
+ - `approvalsChatId` - Dedicated chat for approval notifications
41
+ - `errorsChatId` - Dedicated chat for agent errors
42
+ - `escalationChatId` - Dedicated chat for agent escalations
43
+ - Falls back to `defaultChatId` when per-type chats aren't configured
44
+ - Per-company overrides via `/connect`
45
+
46
+ ### Bot commands
47
+ - `/status` - Show active agents and recent completions
48
+ - `/issues` - List open issues
49
+ - `/agents` - List agents with status indicators
50
+ - `/approve <id>` - Approve a pending approval
51
+ - `/help` - Display all available commands
52
+ - `/connect <company>` - Link this chat to a Paperclip company
53
+ - `/connect_topic <project-name> <topic-id>` - Map a forum topic to a Paperclip project
54
+ - `/acp spawn <agent>` - Start a new agent session in the current thread
55
+ - `/acp status` - Check ACP session status
56
+ - `/acp cancel` - Cancel a running ACP session
57
+ - `/acp close` - Close a completed ACP session
58
+ - `/commands import <json>` - Import a workflow command
59
+ - `/commands list` - List registered workflow commands
60
+ - `/commands run <name> [args]` - Execute a workflow command
61
+ - `/commands delete <name>` - Delete a workflow command
62
+
63
+ ### Phase 1: HITL Escalation
25
64
  - Agents call `escalate_to_human` tool when stuck (low confidence, user request, policy violation, unknown intent)
26
65
  - Escalation posted to dedicated channel with conversation context, suggested reply, and confidence score
27
66
  - Inline buttons: Send Suggested Reply, Reply, Override, Dismiss
@@ -30,8 +69,7 @@ Built on the Paperclip plugin SDK and the domain event bridge ([PR #909](https:/
30
69
  - Reply routing back to originating chat via native or ACP transport
31
70
 
32
71
  ### Phase 2: Multi-Agent Group Threads
33
-
34
- - Multiple agents per thread (up to 5 configurable via `MAX_AGENTS_PER_THREAD`)
72
+ - Multiple agents per thread (up to 5 configurable via `maxAgentsPerThread`)
35
73
  - `@mention` routing: address a specific agent by name in a multi-agent thread
36
74
  - Reply-to routing: reply to an agent's message to route to that agent
37
75
  - Fallback routing: most recently active agent receives unaddressed messages
@@ -44,14 +82,12 @@ Built on the Paperclip plugin SDK and the domain event bridge ([PR #909](https:/
44
82
  - Auto-spawn on handoff/discuss if target agent isn't already in the thread
45
83
 
46
84
  ### Phase 3: Media-to-Task Pipeline
47
-
48
85
  - Voice messages, audio, video notes, documents, and photos routed to agents
49
86
  - Voice/audio transcription via Whisper API with transcription preview posted back
50
87
  - **Brief Agent**: media sent to intake channels is forwarded to a configurable Brief Agent for triage
51
88
  - Media in active agent threads is routed to the active session (native or ACP)
52
89
 
53
90
  ### Phase 4: Custom Workflow Commands
54
-
55
91
  - `/commands import <json>` - import a multi-step workflow as a custom slash command
56
92
  - `/commands list` - show all registered custom commands
57
93
  - `/commands run <name> [args]` - execute a workflow
@@ -62,7 +98,6 @@ Built on the Paperclip plugin SDK and the domain event bridge ([PR #909](https:/
62
98
  - Per-company command registry
63
99
 
64
100
  ### Phase 5: Proactive Agent Suggestions
65
-
66
101
  - Agents call `register_watch` tool to set up condition-based monitors
67
102
  - Watch conditions: `gt`, `lt`, `eq`, `ne`, `contains`, `exists` operators on entity fields
68
103
  - Watches evaluate against issues, agents, or custom state-stored data
@@ -72,25 +107,24 @@ Built on the Paperclip plugin SDK and the domain event bridge ([PR #909](https:/
72
107
  - Deduplication: same watch+entity won't re-fire within a configurable window (default 24h)
73
108
  - Scheduled job checks all watches periodically
74
109
 
75
- ## Setup
76
-
77
- ### 1. Create a Telegram bot
78
-
79
- 1. Open Telegram and chat with [@BotFather](https://t.me/BotFather)
80
- 2. Run `/newbot` and follow the prompts
81
- 3. Save the bot token
82
-
83
- ### 2. Get your chat ID
84
-
85
- Send a message to your bot, then run:
110
+ ### Reply routing
111
+ - Reply to any bot notification to route your message back to Paperclip
112
+ - Replies to issue notifications create issue comments automatically
113
+ - Replies to escalation notifications resolve the escalation as a human reply
114
+ - Enable/disable with `enableInbound` config toggle (default: true)
86
115
 
87
- ```bash
88
- curl "https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates"
89
- ```
116
+ ### Daily digest
117
+ - Configurable digest summaries posted to your Telegram chats
118
+ - Modes: `daily` (once), `bidaily` (twice), `tridaily` (three times per day)
119
+ - Configure via the `digestMode` config setting
120
+ - Includes: tasks completed/created, active agents, in-progress/review/blocked issues
90
121
 
91
- Find the `chat.id` field in the response. For groups, the ID is a negative number.
122
+ ### Forum topic routing
123
+ - Map Telegram forum topics to Paperclip projects via `/connect_topic`
124
+ - Notifications for a project are routed to its mapped topic
125
+ - Requires a group with forum topics enabled
92
126
 
93
- ### 3. Install the plugin
127
+ ## Install
94
128
 
95
129
  ```bash
96
130
  npm install paperclip-plugin-telegram
@@ -104,69 +138,51 @@ curl -X POST http://127.0.0.1:3100/api/plugins/install \
104
138
  -d '{"packageName":"paperclip-plugin-telegram"}'
105
139
  ```
106
140
 
107
- ### 4. Store your bot token as a Paperclip secret
108
-
109
- The bot token field requires a Paperclip secret reference (a UUID), not the raw token.
110
-
111
- 1. In Paperclip, go to **Settings → Secrets → Create new secret**
112
- 2. Paste your Telegram bot token as the secret value and save
113
- 3. Copy the UUID of the created secret
114
- 4. Use that UUID in the `telegramBotTokenRef` field below
115
-
116
- Do the same for `transcriptionApiKeyRef` if you want Whisper transcription.
141
+ ## Setup
117
142
 
118
- ### 5. Configure
143
+ 1. Open Telegram and chat with [@BotFather](https://t.me/BotFather)
144
+ 2. Run `/newbot` and follow the prompts to create a bot
145
+ 3. Save the bot token
146
+ 4. Send a message to your bot, then run `curl "https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates"` and find the `chat.id` field
147
+ 5. In Paperclip, go to **Settings -> Secrets -> Create new secret**, paste your bot token as the secret value, and copy the resulting UUID
148
+ 6. Configure the plugin with the secret UUID in `telegramBotTokenRef` and your chat ID in `defaultChatId`
119
149
 
120
- In your Paperclip instance settings, configure:
150
+ ## Configuration
121
151
 
122
152
  | Setting | Required | Description |
123
153
  |---------|----------|-------------|
124
- | `telegramBotTokenRef` | Yes | Secret UUID for your bot token (see step 4) |
125
- | `defaultChatId` | Yes | Chat ID for notifications |
154
+ | `telegramBotTokenRef` | Yes | Secret UUID for your bot token |
155
+ | `defaultChatId` | No | Fallback chat ID for notifications |
126
156
  | `approvalsChatId` | No | Separate chat for approvals |
127
157
  | `errorsChatId` | No | Separate chat for errors |
158
+ | `escalationChatId` | No | Dedicated chat for agent escalations |
159
+ | `paperclipBaseUrl` | No | Internal Paperclip API URL (default: http://localhost:3100) |
160
+ | `paperclipPublicUrl` | No | Public URL for issue links in messages |
128
161
  | `enableCommands` | No | Enable bot commands (default: true) |
129
162
  | `enableInbound` | No | Route Telegram replies to issues (default: true) |
130
- | `dailyDigestEnabled` | No | Send daily activity summary |
131
- | `topicRouting` | No | Map forum topics to projects |
132
- | `escalationChatId` | No | Dedicated chat/topic for agent escalations |
163
+ | `topicRouting` | No | Map forum topics to projects (default: false) |
164
+ | `digestMode` | No | Digest frequency: off, daily, bidaily, tridaily (default: off) |
165
+ | `dailyDigestTime` | No | UTC time for digest, HH:MM (default: 09:00) |
166
+ | `bidailySecondTime` | No | Second digest time for bidaily mode (default: 17:00) |
167
+ | `tridailyTimes` | No | Comma-separated HH:MM times for tridaily (default: 07:00,13:00,19:00) |
133
168
  | `escalationTimeoutMs` | No | Timeout before default action fires (default: 900000 / 15 min) |
134
- | `escalationDefaultAction` | No | Action on timeout: `defer`, `close`, `retry`, `escalate_further` (default: `defer`) |
135
- | `escalationHoldMessage` | No | Message sent to customer while waiting (default: "Let me check on that - I'll get back to you shortly.") |
136
- | `briefAgentId` | No | Agent ID for the Brief Agent (Phase 3 media intake) |
137
- | `briefAgentChatIds` | No | Chat IDs that act as media intake channels for the Brief Agent |
138
- | `transcriptionApiKeyRef` | No | Secret reference to OpenAI API key for Whisper transcription |
169
+ | `escalationDefaultAction` | No | Action on timeout: `defer`, `auto_reply`, `close` (default: `defer`) |
170
+ | `escalationHoldMessage` | No | Message sent to customer while waiting |
171
+ | `maxAgentsPerThread` | No | Max concurrent agents per thread (default: 5) |
172
+ | `briefAgentId` | No | Agent ID for media intake Brief Agent |
173
+ | `briefAgentChatIds` | No | Chat IDs that act as media intake channels |
174
+ | `transcriptionApiKeyRef` | No | Secret reference to OpenAI API key for Whisper |
139
175
  | `maxSuggestionsPerHourPerCompany` | No | Rate limit for proactive suggestions (default: 10) |
140
- | `watchDeduplicationWindowMs` | No | Window before same watch+entity can re-fire (default: 86400000 / 24h) |
141
-
142
- ### 6. Add bot to group (optional)
143
-
144
- If using a group chat:
145
- 1. Add the bot to your Telegram group
146
- 2. Disable privacy mode via BotFather (`/setprivacy` -> Disable) if you want the bot to see all messages
147
- 3. Run `/connect <company-name>` in the group
148
-
149
- ## How it works
150
-
151
- ### Notifications (outbound)
152
-
153
- When Paperclip events fire (issue created, approval needed, agent error), the plugin formats them as Telegram messages with MarkdownV2 and sends to the configured chat.
154
-
155
- Approval notifications include inline Approve/Reject buttons. Clicking a button calls the Paperclip API directly and updates the message to show the resolution.
156
-
157
- ### Commands (inbound)
176
+ | `watchDeduplicationWindowMs` | No | Suppress duplicate watch suggestions within this window (default: 86400000 / 24h) |
158
177
 
159
- The bot registers commands with Telegram's command menu. Users can interact with Paperclip directly:
178
+ ## Agent tools
160
179
 
161
- - `/status` - overview of agents and tasks
162
- - `/issues` - list open issues
163
- - `/agents` - list agents with status
164
- - `/approve <id>` - approve a pending request
165
- - `/connect <company>` - link this chat to a Paperclip company
166
-
167
- ### Reply routing
168
-
169
- When a user replies to a bot notification, the plugin looks up which Paperclip entity that notification was about and posts the reply as an issue comment. This turns Telegram threads into a natural conversation interface for Paperclip issues.
180
+ | Tool | Phase | Description |
181
+ |------|-------|-------------|
182
+ | `escalate_to_human` | 1 | Escalate a conversation to a human when confidence is low |
183
+ | `handoff_to_agent` | 2 | Hand off work to another agent in this thread |
184
+ | `discuss_with_agent` | 2 | Start a back-and-forth conversation with another agent |
185
+ | `register_watch` | 5 | Register a proactive watch that monitors entities and sends suggestions |
170
186
 
171
187
  ## Comparison with PR #407
172
188
 
@@ -192,7 +208,7 @@ When a user replies to a bot notification, the plugin looks up which Paperclip e
192
208
 
193
209
  The `telegramBotTokenRef` and `transcriptionApiKeyRef` fields now require a Paperclip secret reference (a UUID), not the raw token value. If you previously entered your raw bot token in the field, follow these steps to migrate:
194
210
 
195
- 1. Go to **Settings Secrets Create new secret**
211
+ 1. Go to **Settings -> Secrets -> Create new secret**
196
212
  2. Paste your Telegram bot token as the secret value and save
197
213
  3. Copy the resulting UUID
198
214
  4. Open **Plugin Settings for Telegram Bot** and paste the UUID into "Telegram Bot Token"
@@ -204,13 +220,18 @@ The plugin will fail to activate if a raw token (non-UUID) is entered in the fie
204
220
 
205
221
  ```bash
206
222
  pnpm install
207
- pnpm build
208
223
  pnpm typecheck
224
+ pnpm test
225
+ pnpm build
209
226
  ```
210
227
 
211
- ## License
228
+ ~80 tests covering notifications, approvals, escalation, session registry, media pipeline, custom commands, proactive suggestions, MarkdownV2 formatting, and bot commands.
212
229
 
213
- MIT
230
+ ## Contributing
231
+
232
+ Issues and PRs welcome at [github.com/mvanhorn/paperclip-plugin-telegram](https://github.com/mvanhorn/paperclip-plugin-telegram).
233
+
234
+ Auto-publishes to npm on push to `main` via OIDC trusted publishing.
214
235
 
215
236
  ## Credits
216
237
 
@@ -219,3 +240,7 @@ MIT
219
240
  [@leeknowsai](https://github.com/leeknowsai) - Worker bootstrap patterns adapted from the Discord plugin.
220
241
 
221
242
  Inspired by [OpenClaw's Telegram integration](https://github.com/openclaw/openclaw) (grammY, bidirectional messaging, inline buttons) and adapted for the Paperclip plugin SDK.
243
+
244
+ ## License
245
+
246
+ MIT
@@ -35,6 +35,24 @@ export async function handleAcpCommand(ctx, token, chatId, args, messageThreadId
35
35
  ].join("\n"), { parseMode: "MarkdownV2", messageThreadId });
36
36
  }
37
37
  }
38
+ // --- Agent name resolution ---
39
+ /**
40
+ * Resolve an agent by name/urlKey (case-insensitive).
41
+ * The plugin SDK's `agents.get()` requires a UUID, so we list all agents
42
+ * and match by name or urlKey.
43
+ */
44
+ async function resolveAgentByName(ctx, name, companyId) {
45
+ try {
46
+ const allAgents = await ctx.agents.list({ companyId });
47
+ const lower = name.toLowerCase();
48
+ const match = allAgents.find((a) => a.name?.toLowerCase() === lower ||
49
+ a.urlKey?.toLowerCase() === lower);
50
+ return match ? { id: match.id, name: match.name } : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
38
56
  // --- Spawn (multi-agent aware, native-first) ---
39
57
  async function handleAcpSpawn(ctx, token, chatId, agentName, messageThreadId, companyId) {
40
58
  if (!agentName.trim()) {
@@ -58,15 +76,14 @@ async function handleAcpSpawn(ctx, token, chatId, agentName, messageThreadId, co
58
76
  const trimmedName = agentName.trim();
59
77
  const displayName = trimmedName.charAt(0).toUpperCase() + trimmedName.slice(1);
60
78
  const resolvedCompanyId = companyId ?? await resolveCompanyIdFromChat(ctx, chatId);
61
- // Try native session first: check if agent exists in Paperclip
79
+ // Try native session first: resolve agent by name, then create session
62
80
  let transport = "acp";
63
81
  let sessionId;
64
82
  let agentId = "";
65
- try {
66
- const agent = await ctx.agents.get(trimmedName, resolvedCompanyId);
67
- if (agent) {
68
- // Native Paperclip agent - create a session
69
- agentId = agent.id;
83
+ const resolved = await resolveAgentByName(ctx, trimmedName, resolvedCompanyId);
84
+ if (resolved) {
85
+ try {
86
+ agentId = resolved.id;
70
87
  const session = await ctx.agents.sessions.create(agentId, resolvedCompanyId, {
71
88
  reason: `Telegram thread ${chatId}/${messageThreadId}`,
72
89
  });
@@ -74,12 +91,11 @@ async function handleAcpSpawn(ctx, token, chatId, agentName, messageThreadId, co
74
91
  transport = "native";
75
92
  ctx.logger.info("Created native agent session", { agentId, sessionId });
76
93
  }
77
- else {
94
+ catch {
78
95
  sessionId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
79
96
  }
80
97
  }
81
- catch {
82
- // Agent not found in Paperclip - fall back to ACP
98
+ else {
83
99
  sessionId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
84
100
  }
85
101
  const now = new Date().toISOString();
@@ -297,26 +313,56 @@ export async function routeMessageToAgent(ctx, token, chatId, threadId, text, re
297
313
  await ctx.agents.sessions.sendMessage(targetSession.sessionId, resolvedCompanyId, {
298
314
  prompt: text,
299
315
  reason: "telegram_message",
300
- onEvent: (event) => {
301
- if (event.eventType === "chunk" && event.message) {
302
- handleAcpOutput(ctx, token, {
303
- sessionId: targetSession.sessionId,
304
- chatId,
305
- threadId,
306
- text: event.message,
307
- done: false,
308
- }).catch((err) => ctx.logger.error("Native output handler error", { error: String(err) }));
309
- }
310
- else if (event.eventType === "done") {
311
- handleAcpOutput(ctx, token, {
312
- sessionId: targetSession.sessionId,
313
- chatId,
314
- threadId,
315
- text: event.message ?? "",
316
- done: true,
317
- }).catch((err) => ctx.logger.error("Native output handler error", { error: String(err) }));
318
- }
319
- },
316
+ onEvent: (() => {
317
+ // Buffer assistant text and send only the final response
318
+ const assistantTextBuffer = [];
319
+ return (event) => {
320
+ if (event.eventType === "chunk" && event.message) {
321
+ const msg = event.message;
322
+ if (msg.startsWith("{")) {
323
+ try {
324
+ const parsed = JSON.parse(msg);
325
+ // Collect only assistant text content
326
+ if (parsed.type === "assistant" && parsed.message?.content) {
327
+ const textParts = parsed.message.content
328
+ .filter((c) => c.type === "text" && c.text)
329
+ .map((c) => c.text);
330
+ if (textParts.length > 0) {
331
+ assistantTextBuffer.push(textParts.join("\n"));
332
+ }
333
+ }
334
+ }
335
+ catch {
336
+ // Not JSON — ignore non-structured output
337
+ }
338
+ }
339
+ // Drop all non-JSON chunks (system messages like "run started", "adapter invocation", etc.)
340
+ }
341
+ else if (event.eventType === "done") {
342
+ const finalText = assistantTextBuffer.length > 0
343
+ ? assistantTextBuffer.join("\n\n")
344
+ : "";
345
+ if (finalText) {
346
+ handleAcpOutput(ctx, token, {
347
+ sessionId: targetSession.sessionId,
348
+ chatId,
349
+ threadId,
350
+ text: finalText,
351
+ done: true,
352
+ }).catch((err) => ctx.logger.error("Native output handler error", { error: String(err) }));
353
+ }
354
+ else {
355
+ handleAcpOutput(ctx, token, {
356
+ sessionId: targetSession.sessionId,
357
+ chatId,
358
+ threadId,
359
+ text: event.message ?? "Run completed",
360
+ done: true,
361
+ }).catch((err) => ctx.logger.error("Native output handler error", { error: String(err) }));
362
+ }
363
+ }
364
+ };
365
+ })(),
320
366
  });
321
367
  }
322
368
  catch (err) {
@@ -432,19 +478,62 @@ async function flushOutputQueue(ctx, token, chatId, threadId) {
432
478
  }
433
479
  }
434
480
  }
481
+ // --- Markdown to Telegram HTML ---
482
+ function escapeHtml(text) {
483
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
484
+ }
485
+ function markdownToTelegramHtml(text) {
486
+ let html = escapeHtml(text);
487
+ // Bold: **text** → <b>text</b>
488
+ html = html.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
489
+ // Italic: _text_ (but not in the middle of words)
490
+ html = html.replace(/(?<!\w)_(.+?)_(?!\w)/g, "<i>$1</i>");
491
+ // Inline code: `text` → <code>text</code>
492
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
493
+ // Code blocks: ```text``` → <pre>text</pre>
494
+ html = html.replace(/```(?:\w*\n)?([\s\S]*?)```/g, "<pre>$1</pre>");
495
+ return html;
496
+ }
435
497
  // --- Send labeled output ---
498
+ const TELEGRAM_MAX_LENGTH = 4000; // Leave room for prefix/label overhead
436
499
  async function sendLabeledOutput(ctx, token, chatId, threadId, sessionId, displayName, text, done) {
437
500
  const prefix = done
438
501
  ? escapeMarkdownV2("\u2705")
439
502
  : escapeMarkdownV2("\ud83e\udd16");
440
503
  const label = `*\\[${escapeMarkdownV2(displayName)}\\]*`;
441
- const formatted = `${prefix} ${label} ${escapeMarkdownV2(text)}`;
442
- const messageId = await sendMessage(ctx, token, chatId, formatted, {
443
- parseMode: "MarkdownV2",
444
- messageThreadId: threadId,
445
- });
446
- if (messageId) {
447
- await ctx.state.set({ scopeKind: "instance", stateKey: `agent_msg_${chatId}_${messageId}` }, { sessionId });
504
+ // Split long text into chunks to stay within Telegram's 4096 char limit
505
+ const chunks = [];
506
+ if (text.length <= TELEGRAM_MAX_LENGTH) {
507
+ chunks.push(text);
508
+ }
509
+ else {
510
+ let remaining = text;
511
+ while (remaining.length > 0) {
512
+ if (remaining.length <= TELEGRAM_MAX_LENGTH) {
513
+ chunks.push(remaining);
514
+ break;
515
+ }
516
+ // Try to split at a newline boundary
517
+ let splitAt = remaining.lastIndexOf("\n", TELEGRAM_MAX_LENGTH);
518
+ if (splitAt <= 0)
519
+ splitAt = TELEGRAM_MAX_LENGTH;
520
+ chunks.push(remaining.slice(0, splitAt));
521
+ remaining = remaining.slice(splitAt).replace(/^\n/, "");
522
+ }
523
+ }
524
+ for (let i = 0; i < chunks.length; i++) {
525
+ const isLast = i === chunks.length - 1;
526
+ // Convert agent Markdown to Telegram HTML for proper rendering
527
+ const doneEmoji = done ? "\u2705" : "\ud83e\udd16";
528
+ const chunkPrefix = `${doneEmoji} <b>[${escapeHtml(displayName)}]</b> `;
529
+ const formatted = `${chunkPrefix}${markdownToTelegramHtml(chunks[i])}`;
530
+ const messageId = await sendMessage(ctx, token, chatId, formatted, {
531
+ parseMode: "HTML",
532
+ messageThreadId: threadId,
533
+ });
534
+ if (messageId && isLast) {
535
+ await ctx.state.set({ scopeKind: "instance", stateKey: `agent_msg_${chatId}_${messageId}` }, { sessionId });
536
+ }
448
537
  }
449
538
  }
450
539
  // --- Handoff tool handler ---
@@ -532,21 +621,21 @@ async function executeHandoff(ctx, token, chatId, threadId, targetAgent, context
532
621
  let transport = "acp";
533
622
  let sessionId;
534
623
  let agentId = "";
535
- try {
536
- const agent = await ctx.agents.get(targetAgent, companyId);
537
- if (agent) {
538
- agentId = agent.id;
624
+ const resolved = await resolveAgentByName(ctx, targetAgent, companyId);
625
+ if (resolved) {
626
+ try {
627
+ agentId = resolved.id;
539
628
  const session = await ctx.agents.sessions.create(agentId, companyId, {
540
629
  reason: `Handoff from Telegram thread ${chatId}/${threadId}`,
541
630
  });
542
631
  sessionId = session.sessionId;
543
632
  transport = "native";
544
633
  }
545
- else {
634
+ catch {
546
635
  sessionId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
547
636
  }
548
637
  }
549
- catch {
638
+ else {
550
639
  sessionId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
551
640
  }
552
641
  const displayName = targetAgent.charAt(0).toUpperCase() + targetAgent.slice(1);
@@ -618,21 +707,21 @@ export async function handleDiscussToolCall(ctx, token, params, companyId, sourc
618
707
  let transport = "acp";
619
708
  let sessionId;
620
709
  let agentId = "";
621
- try {
622
- const agent = await ctx.agents.get(targetAgent, companyId);
623
- if (agent) {
624
- agentId = agent.id;
710
+ const resolved = await resolveAgentByName(ctx, targetAgent, companyId);
711
+ if (resolved) {
712
+ try {
713
+ agentId = resolved.id;
625
714
  const session = await ctx.agents.sessions.create(agentId, companyId, {
626
715
  reason: `Discussion from Telegram thread ${chatId}/${threadId}`,
627
716
  });
628
717
  sessionId = session.sessionId;
629
718
  transport = "native";
630
719
  }
631
- else {
720
+ catch {
632
721
  sessionId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
633
722
  }
634
723
  }
635
- catch {
724
+ else {
636
725
  sessionId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
637
726
  }
638
727
  const displayName = targetAgent.charAt(0).toUpperCase() + targetAgent.slice(1);
@@ -791,7 +880,7 @@ async function resolveCompanyIdFromChat(ctx, chatId) {
791
880
  scopeKind: "instance",
792
881
  stateKey: `chat_${chatId}`,
793
882
  });
794
- return mapping?.companyName ?? chatId;
883
+ return mapping?.companyId ?? mapping?.companyName ?? chatId;
795
884
  }
796
885
  function simpleHash(text) {
797
886
  let hash = 0;