paperclip-plugin-telegram 0.2.0 → 0.2.2
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 +68 -4
- package/dist/acp-bridge.d.ts +34 -0
- package/dist/acp-bridge.js +805 -0
- package/dist/acp-bridge.js.map +1 -0
- package/dist/adapter.d.ts +35 -0
- package/dist/adapter.js +75 -0
- package/dist/adapter.js.map +1 -0
- package/dist/command-registry.d.ts +3 -0
- package/dist/command-registry.js +273 -0
- package/dist/command-registry.js.map +1 -0
- package/dist/commands.d.ts +10 -0
- package/dist/commands.js +213 -0
- package/dist/commands.js.map +1 -0
- package/dist/constants.d.ts +44 -0
- package/dist/constants.js +48 -0
- package/dist/constants.js.map +1 -0
- package/dist/escalation.d.ts +41 -0
- package/dist/escalation.js +254 -0
- package/dist/escalation.js.map +1 -0
- package/dist/formatters.d.ts +13 -0
- package/dist/formatters.js +130 -0
- package/dist/formatters.js.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +3 -0
- package/dist/manifest.js +230 -0
- package/dist/manifest.js.map +1 -0
- package/dist/media-pipeline.d.ts +46 -0
- package/dist/media-pipeline.js +161 -0
- package/dist/media-pipeline.js.map +1 -0
- package/dist/telegram-api.d.ts +28 -0
- package/dist/telegram-api.js +147 -0
- package/dist/telegram-api.js.map +1 -0
- package/dist/watch-registry.d.ts +9 -0
- package/dist/watch-registry.js +272 -0
- package/dist/watch-registry.js.map +1 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +548 -0
- package/dist/worker.js.map +1 -0
- package/package.json +7 -3
- package/src/acp-bridge.ts +0 -1273
- package/src/adapter.ts +0 -129
- package/src/command-registry.ts +0 -482
- package/src/commands.ts +0 -346
- package/src/constants.ts +0 -51
- package/src/escalation.ts +0 -421
- package/src/formatters.ts +0 -148
- package/src/manifest.ts +0 -246
- package/src/media-pipeline.ts +0 -234
- package/src/telegram-api.ts +0 -202
- package/src/watch-registry.ts +0 -369
- package/src/worker.ts +0 -783
- package/tests/acp-bridge.test.ts +0 -314
- package/tests/command-registry.test.ts +0 -283
- package/tests/commands.test.ts +0 -213
- package/tests/escalation.test.ts +0 -550
- package/tests/formatters.test.ts +0 -185
- package/tests/media-pipeline.test.ts +0 -324
- package/tests/telegram-api.test.ts +0 -108
- package/tests/watch-registry.test.ts +0 -404
- package/tsconfig.json +0 -16
- /package/{src/index.ts → dist/index.d.ts} +0 -0
package/src/manifest.ts
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
|
2
|
-
import { DEFAULT_CONFIG, PLUGIN_ID, PLUGIN_VERSION, MAX_AGENTS_PER_THREAD } from "./constants.js";
|
|
3
|
-
|
|
4
|
-
const manifest: PaperclipPluginManifestV1 = {
|
|
5
|
-
id: PLUGIN_ID,
|
|
6
|
-
apiVersion: 1,
|
|
7
|
-
version: PLUGIN_VERSION,
|
|
8
|
-
displayName: "Telegram Bot",
|
|
9
|
-
description:
|
|
10
|
-
"Bidirectional Telegram integration: push notifications, bot commands, escalation to humans, multi-agent sessions (native + ACP), media pipeline with transcription, custom workflow commands, and proactive suggestion watches.",
|
|
11
|
-
author: "mvanhorn",
|
|
12
|
-
categories: ["connector", "automation"],
|
|
13
|
-
capabilities: [
|
|
14
|
-
"companies.read",
|
|
15
|
-
"issues.read",
|
|
16
|
-
"issues.create",
|
|
17
|
-
"agents.read",
|
|
18
|
-
"agents.invoke",
|
|
19
|
-
"agent.sessions.create",
|
|
20
|
-
"agent.sessions.list",
|
|
21
|
-
"agent.sessions.send",
|
|
22
|
-
"agent.sessions.close",
|
|
23
|
-
"agent.tools.register",
|
|
24
|
-
"events.subscribe",
|
|
25
|
-
"events.emit",
|
|
26
|
-
"plugin.state.read",
|
|
27
|
-
"plugin.state.write",
|
|
28
|
-
"http.outbound",
|
|
29
|
-
"secrets.read-ref",
|
|
30
|
-
"activity.log.write",
|
|
31
|
-
"metrics.write",
|
|
32
|
-
"jobs.schedule",
|
|
33
|
-
],
|
|
34
|
-
entrypoints: {
|
|
35
|
-
worker: "./dist/worker.js",
|
|
36
|
-
},
|
|
37
|
-
instanceConfigSchema: {
|
|
38
|
-
type: "object",
|
|
39
|
-
properties: {
|
|
40
|
-
telegramBotTokenRef: {
|
|
41
|
-
type: "string",
|
|
42
|
-
title: "Telegram Bot Token (secret reference)",
|
|
43
|
-
description:
|
|
44
|
-
"Reference to the Telegram Bot token stored in your secret provider. Get one from @BotFather.",
|
|
45
|
-
default: DEFAULT_CONFIG.telegramBotTokenRef,
|
|
46
|
-
},
|
|
47
|
-
defaultChatId: {
|
|
48
|
-
type: "string",
|
|
49
|
-
title: "Default Chat ID",
|
|
50
|
-
description:
|
|
51
|
-
"Telegram chat ID to send notifications to. Use a group chat ID (negative number) or a user chat ID.",
|
|
52
|
-
default: DEFAULT_CONFIG.defaultChatId,
|
|
53
|
-
},
|
|
54
|
-
approvalsChatId: {
|
|
55
|
-
type: "string",
|
|
56
|
-
title: "Approvals Chat ID",
|
|
57
|
-
description:
|
|
58
|
-
"Chat ID for approval requests. Falls back to default chat.",
|
|
59
|
-
default: DEFAULT_CONFIG.approvalsChatId,
|
|
60
|
-
},
|
|
61
|
-
errorsChatId: {
|
|
62
|
-
type: "string",
|
|
63
|
-
title: "Errors Chat ID",
|
|
64
|
-
description:
|
|
65
|
-
"Chat ID for agent error notifications. Falls back to default chat.",
|
|
66
|
-
default: DEFAULT_CONFIG.errorsChatId,
|
|
67
|
-
},
|
|
68
|
-
notifyOnIssueCreated: {
|
|
69
|
-
type: "boolean",
|
|
70
|
-
title: "Notify on issue created",
|
|
71
|
-
default: DEFAULT_CONFIG.notifyOnIssueCreated,
|
|
72
|
-
},
|
|
73
|
-
notifyOnIssueDone: {
|
|
74
|
-
type: "boolean",
|
|
75
|
-
title: "Notify on issue completed",
|
|
76
|
-
default: DEFAULT_CONFIG.notifyOnIssueDone,
|
|
77
|
-
},
|
|
78
|
-
notifyOnApprovalCreated: {
|
|
79
|
-
type: "boolean",
|
|
80
|
-
title: "Notify on approval requested",
|
|
81
|
-
default: DEFAULT_CONFIG.notifyOnApprovalCreated,
|
|
82
|
-
},
|
|
83
|
-
notifyOnAgentError: {
|
|
84
|
-
type: "boolean",
|
|
85
|
-
title: "Notify on agent error",
|
|
86
|
-
default: DEFAULT_CONFIG.notifyOnAgentError,
|
|
87
|
-
},
|
|
88
|
-
enableCommands: {
|
|
89
|
-
type: "boolean",
|
|
90
|
-
title: "Enable bot commands",
|
|
91
|
-
description:
|
|
92
|
-
"Allow users to interact with Paperclip via Telegram bot commands (/status, /issues, /agents).",
|
|
93
|
-
default: DEFAULT_CONFIG.enableCommands,
|
|
94
|
-
},
|
|
95
|
-
enableInbound: {
|
|
96
|
-
type: "boolean",
|
|
97
|
-
title: "Enable inbound message routing",
|
|
98
|
-
description:
|
|
99
|
-
"Route Telegram messages to Paperclip issue comments. Messages sent in reply to a notification get attached to that issue.",
|
|
100
|
-
default: DEFAULT_CONFIG.enableInbound,
|
|
101
|
-
},
|
|
102
|
-
dailyDigestEnabled: {
|
|
103
|
-
type: "boolean",
|
|
104
|
-
title: "Daily digest",
|
|
105
|
-
description: "Send a daily summary of agent activity.",
|
|
106
|
-
default: DEFAULT_CONFIG.dailyDigestEnabled,
|
|
107
|
-
},
|
|
108
|
-
dailyDigestTime: {
|
|
109
|
-
type: "string",
|
|
110
|
-
title: "Digest time (HH:MM UTC)",
|
|
111
|
-
description: "Time to send the daily digest in UTC.",
|
|
112
|
-
default: DEFAULT_CONFIG.dailyDigestTime,
|
|
113
|
-
},
|
|
114
|
-
topicRouting: {
|
|
115
|
-
type: "boolean",
|
|
116
|
-
title: "Forum topic routing",
|
|
117
|
-
description:
|
|
118
|
-
"Map Telegram forum topics to Paperclip projects. Requires the bot to be in a group with forum topics enabled.",
|
|
119
|
-
default: DEFAULT_CONFIG.topicRouting,
|
|
120
|
-
},
|
|
121
|
-
paperclipBaseUrl: {
|
|
122
|
-
type: "string",
|
|
123
|
-
title: "Paperclip Base URL",
|
|
124
|
-
description:
|
|
125
|
-
"Base URL of the Paperclip API server.",
|
|
126
|
-
default: DEFAULT_CONFIG.paperclipBaseUrl,
|
|
127
|
-
},
|
|
128
|
-
escalationChatId: {
|
|
129
|
-
type: "string",
|
|
130
|
-
title: "Escalation Chat ID",
|
|
131
|
-
description:
|
|
132
|
-
"Telegram chat ID where escalations are sent for human review. If empty, escalations are logged but not forwarded.",
|
|
133
|
-
default: DEFAULT_CONFIG.escalationChatId,
|
|
134
|
-
},
|
|
135
|
-
escalationTimeoutMs: {
|
|
136
|
-
type: "number",
|
|
137
|
-
title: "Escalation Timeout (ms)",
|
|
138
|
-
description:
|
|
139
|
-
"How long to wait for a human response before taking the default action. Default: 900000 (15 minutes).",
|
|
140
|
-
default: DEFAULT_CONFIG.escalationTimeoutMs,
|
|
141
|
-
},
|
|
142
|
-
escalationDefaultAction: {
|
|
143
|
-
type: "string",
|
|
144
|
-
title: "Escalation Default Action",
|
|
145
|
-
description:
|
|
146
|
-
"What to do when an escalation times out: defer (do nothing), auto_reply (send suggested reply), or close.",
|
|
147
|
-
enum: ["defer", "auto_reply", "close"],
|
|
148
|
-
default: DEFAULT_CONFIG.escalationDefaultAction,
|
|
149
|
-
},
|
|
150
|
-
escalationHoldMessage: {
|
|
151
|
-
type: "string",
|
|
152
|
-
title: "Escalation Hold Message",
|
|
153
|
-
description:
|
|
154
|
-
"Message sent to the user when their conversation is escalated to a human.",
|
|
155
|
-
default: DEFAULT_CONFIG.escalationHoldMessage,
|
|
156
|
-
},
|
|
157
|
-
maxAgentsPerThread: {
|
|
158
|
-
type: "number",
|
|
159
|
-
title: "Max Agents Per Thread",
|
|
160
|
-
description:
|
|
161
|
-
"Maximum number of concurrent agent sessions allowed in a single thread.",
|
|
162
|
-
default: MAX_AGENTS_PER_THREAD,
|
|
163
|
-
},
|
|
164
|
-
briefAgentId: {
|
|
165
|
-
type: "string",
|
|
166
|
-
title: "Brief Agent ID",
|
|
167
|
-
description: "Agent ID for processing media intake briefs. Leave empty to disable media pipeline.",
|
|
168
|
-
default: DEFAULT_CONFIG.briefAgentId,
|
|
169
|
-
},
|
|
170
|
-
briefAgentChatIds: {
|
|
171
|
-
type: "array",
|
|
172
|
-
items: { type: "string" },
|
|
173
|
-
title: "Brief Agent Intake Chat IDs",
|
|
174
|
-
description: "Telegram chat IDs where media is routed to the Brief Agent. Media in other chats goes to active agent sessions.",
|
|
175
|
-
default: DEFAULT_CONFIG.briefAgentChatIds,
|
|
176
|
-
},
|
|
177
|
-
transcriptionApiKeyRef: {
|
|
178
|
-
type: "string",
|
|
179
|
-
title: "Transcription API Key (secret reference)",
|
|
180
|
-
description: "Reference to OpenAI API key for Whisper transcription of voice/audio messages.",
|
|
181
|
-
default: DEFAULT_CONFIG.transcriptionApiKeyRef,
|
|
182
|
-
},
|
|
183
|
-
maxSuggestionsPerHourPerCompany: {
|
|
184
|
-
type: "number",
|
|
185
|
-
title: "Max Suggestions per Hour per Company",
|
|
186
|
-
description: "Rate limit for proactive watch suggestions.",
|
|
187
|
-
default: DEFAULT_CONFIG.maxSuggestionsPerHourPerCompany,
|
|
188
|
-
},
|
|
189
|
-
watchDeduplicationWindowMs: {
|
|
190
|
-
type: "number",
|
|
191
|
-
title: "Watch Deduplication Window (ms)",
|
|
192
|
-
description: "Suppress duplicate watch suggestions for the same entity within this window. Default: 86400000 (24 hours).",
|
|
193
|
-
default: DEFAULT_CONFIG.watchDeduplicationWindowMs,
|
|
194
|
-
},
|
|
195
|
-
},
|
|
196
|
-
required: ["telegramBotTokenRef", "defaultChatId"],
|
|
197
|
-
},
|
|
198
|
-
jobs: [
|
|
199
|
-
{
|
|
200
|
-
jobKey: "telegram-daily-digest",
|
|
201
|
-
displayName: "Telegram Daily Digest",
|
|
202
|
-
description: "Send a daily summary of agent activity to Telegram.",
|
|
203
|
-
schedule: "0 9 * * *",
|
|
204
|
-
},
|
|
205
|
-
{
|
|
206
|
-
jobKey: "check-escalation-timeouts",
|
|
207
|
-
displayName: "Check Escalation Timeouts",
|
|
208
|
-
description: "Check for timed-out escalations and apply default actions.",
|
|
209
|
-
schedule: "* * * * *",
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
jobKey: "check-watches",
|
|
213
|
-
displayName: "Check Proactive Watches",
|
|
214
|
-
description: "Evaluate registered watches and send suggestions when conditions are met.",
|
|
215
|
-
schedule: "*/15 * * * *",
|
|
216
|
-
},
|
|
217
|
-
],
|
|
218
|
-
tools: [
|
|
219
|
-
{
|
|
220
|
-
name: "escalate_to_human",
|
|
221
|
-
displayName: "Escalate to Human",
|
|
222
|
-
description: "Escalate a conversation to a human when you cannot handle it confidently",
|
|
223
|
-
parametersSchema: { type: "object" },
|
|
224
|
-
},
|
|
225
|
-
{
|
|
226
|
-
name: "handoff_to_agent",
|
|
227
|
-
displayName: "Handoff to Agent",
|
|
228
|
-
description: "Hand off work to another agent in this thread",
|
|
229
|
-
parametersSchema: { type: "object" },
|
|
230
|
-
},
|
|
231
|
-
{
|
|
232
|
-
name: "discuss_with_agent",
|
|
233
|
-
displayName: "Discuss with Agent",
|
|
234
|
-
description: "Start a back-and-forth conversation with another agent",
|
|
235
|
-
parametersSchema: { type: "object" },
|
|
236
|
-
},
|
|
237
|
-
{
|
|
238
|
-
name: "register_watch",
|
|
239
|
-
displayName: "Register Watch",
|
|
240
|
-
description: "Register a proactive watch that monitors entities and sends suggestions",
|
|
241
|
-
parametersSchema: { type: "object" },
|
|
242
|
-
},
|
|
243
|
-
],
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
export default manifest;
|
package/src/media-pipeline.ts
DELETED
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import type { PluginContext } from "@paperclipai/plugin-sdk";
|
|
2
|
-
import { sendMessage, escapeMarkdownV2, sendChatAction } from "./telegram-api.js";
|
|
3
|
-
import { METRIC_NAMES } from "./constants.js";
|
|
4
|
-
import { getSessions } from "./acp-bridge.js";
|
|
5
|
-
|
|
6
|
-
const TELEGRAM_API = "https://api.telegram.org";
|
|
7
|
-
|
|
8
|
-
type MediaConfig = {
|
|
9
|
-
briefAgentId: string;
|
|
10
|
-
briefAgentChatIds: string[];
|
|
11
|
-
transcriptionApiKeyRef: string;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
type TelegramMediaMessage = {
|
|
15
|
-
message_id: number;
|
|
16
|
-
chat: { id: number };
|
|
17
|
-
message_thread_id?: number;
|
|
18
|
-
from?: { id: number; username?: string; first_name?: string };
|
|
19
|
-
voice?: { file_id: string; duration: number; mime_type?: string };
|
|
20
|
-
audio?: { file_id: string; duration: number; title?: string; mime_type?: string };
|
|
21
|
-
video_note?: { file_id: string; duration: number };
|
|
22
|
-
document?: { file_id: string; file_name?: string; mime_type?: string };
|
|
23
|
-
photo?: Array<{ file_id: string; width: number; height: number }>;
|
|
24
|
-
caption?: string;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export async function handleMediaMessage(
|
|
28
|
-
ctx: PluginContext,
|
|
29
|
-
token: string,
|
|
30
|
-
msg: TelegramMediaMessage,
|
|
31
|
-
config: MediaConfig,
|
|
32
|
-
companyId: string,
|
|
33
|
-
): Promise<boolean> {
|
|
34
|
-
const chatId = String(msg.chat.id);
|
|
35
|
-
const threadId = msg.message_thread_id;
|
|
36
|
-
|
|
37
|
-
// Determine if this is an intake channel (brief agent) or an agent thread
|
|
38
|
-
const isIntakeChannel = config.briefAgentChatIds.includes(chatId);
|
|
39
|
-
const hasActiveSession = threadId
|
|
40
|
-
? (await getSessions(ctx, chatId, threadId)).some((s) => s.status === "active")
|
|
41
|
-
: false;
|
|
42
|
-
|
|
43
|
-
if (!isIntakeChannel && !hasActiveSession) {
|
|
44
|
-
return false; // Not a media-relevant context
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
await sendChatAction(ctx, token, chatId);
|
|
48
|
-
|
|
49
|
-
// Extract media file info
|
|
50
|
-
const fileId = extractFileId(msg);
|
|
51
|
-
if (!fileId) return false;
|
|
52
|
-
|
|
53
|
-
const isAudio = !!(msg.voice || msg.audio || msg.video_note);
|
|
54
|
-
|
|
55
|
-
let textContent = msg.caption ?? "";
|
|
56
|
-
|
|
57
|
-
// Transcribe audio/voice if applicable
|
|
58
|
-
if (isAudio && config.transcriptionApiKeyRef) {
|
|
59
|
-
try {
|
|
60
|
-
const transcription = await transcribeAudio(ctx, token, fileId, config.transcriptionApiKeyRef);
|
|
61
|
-
if (transcription) {
|
|
62
|
-
textContent = transcription;
|
|
63
|
-
|
|
64
|
-
// Send transcription preview
|
|
65
|
-
await sendMessage(
|
|
66
|
-
ctx,
|
|
67
|
-
token,
|
|
68
|
-
chatId,
|
|
69
|
-
`${escapeMarkdownV2("\ud83c\udfa4")} *Transcription:*\n${escapeMarkdownV2(textContent.slice(0, 500))}${textContent.length > 500 ? escapeMarkdownV2("...") : ""}`,
|
|
70
|
-
{
|
|
71
|
-
parseMode: "MarkdownV2",
|
|
72
|
-
messageThreadId: threadId,
|
|
73
|
-
replyToMessageId: msg.message_id,
|
|
74
|
-
},
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
} catch (err) {
|
|
78
|
-
ctx.logger.error("Transcription failed", { error: String(err) });
|
|
79
|
-
textContent = msg.caption ?? "[Audio - transcription failed]";
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (isIntakeChannel && config.briefAgentId) {
|
|
84
|
-
// Route to Brief Agent via one-shot invocation
|
|
85
|
-
try {
|
|
86
|
-
const prompt = buildBriefPrompt(msg, textContent);
|
|
87
|
-
const { runId } = await ctx.agents.invoke(config.briefAgentId, companyId, {
|
|
88
|
-
prompt,
|
|
89
|
-
reason: "media_intake",
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
await sendMessage(
|
|
93
|
-
ctx,
|
|
94
|
-
token,
|
|
95
|
-
chatId,
|
|
96
|
-
`${escapeMarkdownV2("\ud83d\udcdd")} Media sent to Brief Agent \\(run: \`${escapeMarkdownV2(runId)}\`\\)`,
|
|
97
|
-
{
|
|
98
|
-
parseMode: "MarkdownV2",
|
|
99
|
-
messageThreadId: threadId,
|
|
100
|
-
replyToMessageId: msg.message_id,
|
|
101
|
-
},
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
ctx.logger.info("Media routed to brief agent", { runId, briefAgentId: config.briefAgentId });
|
|
105
|
-
} catch (err) {
|
|
106
|
-
ctx.logger.error("Failed to invoke brief agent", { error: String(err) });
|
|
107
|
-
await sendMessage(
|
|
108
|
-
ctx,
|
|
109
|
-
token,
|
|
110
|
-
chatId,
|
|
111
|
-
`Failed to route media to brief agent: ${String(err)}`,
|
|
112
|
-
{ messageThreadId: threadId },
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
} else if (hasActiveSession && threadId) {
|
|
116
|
-
// Route to the active agent in the thread
|
|
117
|
-
const sessions = await getSessions(ctx, chatId, threadId);
|
|
118
|
-
const activeSessions = sessions.filter((s) => s.status === "active");
|
|
119
|
-
const target = activeSessions.sort(
|
|
120
|
-
(a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(),
|
|
121
|
-
)[0];
|
|
122
|
-
|
|
123
|
-
if (target) {
|
|
124
|
-
const mediaLabel = isAudio ? "Audio message" : msg.photo ? "Photo" : "Document";
|
|
125
|
-
const prompt = `[${mediaLabel}] ${textContent || "(no caption)"}`;
|
|
126
|
-
|
|
127
|
-
if (target.transport === "native") {
|
|
128
|
-
try {
|
|
129
|
-
await ctx.agents.sessions.sendMessage(target.sessionId, companyId, {
|
|
130
|
-
prompt,
|
|
131
|
-
reason: "media_message",
|
|
132
|
-
});
|
|
133
|
-
} catch (err) {
|
|
134
|
-
ctx.logger.error("Failed to send media to native session", { error: String(err) });
|
|
135
|
-
}
|
|
136
|
-
} else {
|
|
137
|
-
ctx.events.emit("acp-spawn", companyId, {
|
|
138
|
-
type: "message",
|
|
139
|
-
sessionId: target.sessionId,
|
|
140
|
-
chatId,
|
|
141
|
-
threadId,
|
|
142
|
-
text: prompt,
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
await ctx.metrics.write(METRIC_NAMES.mediaProcessed, 1);
|
|
149
|
-
return true;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function extractFileId(msg: TelegramMediaMessage): string | null {
|
|
153
|
-
if (msg.voice) return msg.voice.file_id;
|
|
154
|
-
if (msg.audio) return msg.audio.file_id;
|
|
155
|
-
if (msg.video_note) return msg.video_note.file_id;
|
|
156
|
-
if (msg.document) return msg.document.file_id;
|
|
157
|
-
if (msg.photo && msg.photo.length > 0) {
|
|
158
|
-
// Use the largest photo
|
|
159
|
-
return msg.photo.sort((a, b) => b.width * b.height - a.width * a.height)[0]!.file_id;
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
async function transcribeAudio(
|
|
165
|
-
ctx: PluginContext,
|
|
166
|
-
botToken: string,
|
|
167
|
-
fileId: string,
|
|
168
|
-
transcriptionApiKeyRef: string,
|
|
169
|
-
): Promise<string | null> {
|
|
170
|
-
// 1. Get file path from Telegram
|
|
171
|
-
const fileRes = await ctx.http.fetch(
|
|
172
|
-
`${TELEGRAM_API}/bot${botToken}/getFile?file_id=${fileId}`,
|
|
173
|
-
{ method: "GET" },
|
|
174
|
-
);
|
|
175
|
-
const fileData = (await fileRes.json()) as {
|
|
176
|
-
ok: boolean;
|
|
177
|
-
result?: { file_path: string };
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
if (!fileData.ok || !fileData.result?.file_path) {
|
|
181
|
-
ctx.logger.error("Failed to get file path from Telegram", { fileId });
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// 2. Download the file
|
|
186
|
-
const downloadUrl = `${TELEGRAM_API}/file/bot${botToken}/${fileData.result.file_path}`;
|
|
187
|
-
const audioRes = await ctx.http.fetch(downloadUrl, { method: "GET" });
|
|
188
|
-
const audioBlob = await audioRes.blob();
|
|
189
|
-
|
|
190
|
-
// 3. Send to Whisper API
|
|
191
|
-
const apiKey = await ctx.secrets.resolve(transcriptionApiKeyRef);
|
|
192
|
-
const formData = new FormData();
|
|
193
|
-
formData.append("file", audioBlob, "audio.ogg");
|
|
194
|
-
formData.append("model", "whisper-1");
|
|
195
|
-
|
|
196
|
-
const whisperRes = await ctx.http.fetch(
|
|
197
|
-
"https://api.openai.com/v1/audio/transcriptions",
|
|
198
|
-
{
|
|
199
|
-
method: "POST",
|
|
200
|
-
headers: {
|
|
201
|
-
Authorization: `Bearer ${apiKey}`,
|
|
202
|
-
},
|
|
203
|
-
body: formData,
|
|
204
|
-
},
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
const whisperData = (await whisperRes.json()) as { text?: string };
|
|
208
|
-
return whisperData.text ?? null;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function buildBriefPrompt(msg: TelegramMediaMessage, textContent: string): string {
|
|
212
|
-
const parts: string[] = [];
|
|
213
|
-
|
|
214
|
-
if (msg.voice) {
|
|
215
|
-
parts.push(`[Voice message, ${msg.voice.duration}s]`);
|
|
216
|
-
} else if (msg.audio) {
|
|
217
|
-
parts.push(`[Audio: ${msg.audio.title ?? "untitled"}, ${msg.audio.duration}s]`);
|
|
218
|
-
} else if (msg.video_note) {
|
|
219
|
-
parts.push(`[Video note, ${msg.video_note.duration}s]`);
|
|
220
|
-
} else if (msg.document) {
|
|
221
|
-
parts.push(`[Document: ${msg.document.file_name ?? "unknown"}]`);
|
|
222
|
-
} else if (msg.photo) {
|
|
223
|
-
parts.push("[Photo]");
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (textContent) {
|
|
227
|
-
parts.push(textContent);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const sender = msg.from?.username ?? msg.from?.first_name ?? "unknown";
|
|
231
|
-
parts.push(`\nFrom: ${sender}`);
|
|
232
|
-
|
|
233
|
-
return parts.join("\n");
|
|
234
|
-
}
|
package/src/telegram-api.ts
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import type { PluginContext } from "@paperclipai/plugin-sdk";
|
|
2
|
-
import { METRIC_NAMES } from "./constants.js";
|
|
3
|
-
|
|
4
|
-
const TELEGRAM_API = "https://api.telegram.org";
|
|
5
|
-
|
|
6
|
-
type InlineButton = {
|
|
7
|
-
text: string;
|
|
8
|
-
callback_data?: string;
|
|
9
|
-
url?: string;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
type InlineKeyboard = InlineButton[][];
|
|
13
|
-
|
|
14
|
-
export type SendMessageOptions = {
|
|
15
|
-
parseMode?: "MarkdownV2" | "HTML";
|
|
16
|
-
replyToMessageId?: number;
|
|
17
|
-
messageThreadId?: number;
|
|
18
|
-
inlineKeyboard?: InlineKeyboard;
|
|
19
|
-
disableNotification?: boolean;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export async function sendMessage(
|
|
23
|
-
ctx: PluginContext,
|
|
24
|
-
token: string,
|
|
25
|
-
chatId: string,
|
|
26
|
-
text: string,
|
|
27
|
-
options: SendMessageOptions = {},
|
|
28
|
-
): Promise<number | null> {
|
|
29
|
-
const body: Record<string, unknown> = {
|
|
30
|
-
chat_id: chatId,
|
|
31
|
-
text,
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
if (options.parseMode) body.parse_mode = options.parseMode;
|
|
35
|
-
if (options.replyToMessageId) body.reply_to_message_id = options.replyToMessageId;
|
|
36
|
-
if (options.messageThreadId) body.message_thread_id = options.messageThreadId;
|
|
37
|
-
if (options.disableNotification) body.disable_notification = true;
|
|
38
|
-
|
|
39
|
-
if (options.inlineKeyboard) {
|
|
40
|
-
body.reply_markup = {
|
|
41
|
-
inline_keyboard: options.inlineKeyboard,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
46
|
-
try {
|
|
47
|
-
const res = await ctx.http.fetch(`${TELEGRAM_API}/bot${token}/sendMessage`, {
|
|
48
|
-
method: "POST",
|
|
49
|
-
headers: { "Content-Type": "application/json" },
|
|
50
|
-
body: JSON.stringify(body),
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
const data = (await res.json()) as {
|
|
54
|
-
ok: boolean;
|
|
55
|
-
result?: { message_id: number };
|
|
56
|
-
description?: string;
|
|
57
|
-
parameters?: { retry_after?: number };
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
if (!data.ok) {
|
|
61
|
-
if (data.parameters?.retry_after && attempt < 2) {
|
|
62
|
-
const wait = data.parameters.retry_after * 1000;
|
|
63
|
-
ctx.logger.warn("Telegram rate limited, retrying", { retryAfter: data.parameters.retry_after, attempt });
|
|
64
|
-
await new Promise((r) => setTimeout(r, wait));
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (options.parseMode === "MarkdownV2") {
|
|
69
|
-
ctx.logger.warn("MarkdownV2 send failed, retrying as plain text", {
|
|
70
|
-
error: data.description,
|
|
71
|
-
});
|
|
72
|
-
return sendMessage(ctx, token, chatId, stripMarkdown(text), {
|
|
73
|
-
...options,
|
|
74
|
-
parseMode: undefined,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
ctx.logger.error("Telegram sendMessage failed", { error: data.description });
|
|
78
|
-
await ctx.metrics.write(METRIC_NAMES.failed, 1);
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
await ctx.metrics.write(METRIC_NAMES.sent, 1);
|
|
83
|
-
return data.result?.message_id ?? null;
|
|
84
|
-
} catch (err) {
|
|
85
|
-
ctx.logger.error("Telegram API error", { error: String(err) });
|
|
86
|
-
await ctx.metrics.write(METRIC_NAMES.failed, 1);
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export async function editMessage(
|
|
94
|
-
ctx: PluginContext,
|
|
95
|
-
token: string,
|
|
96
|
-
chatId: string,
|
|
97
|
-
messageId: number,
|
|
98
|
-
text: string,
|
|
99
|
-
options: { parseMode?: "MarkdownV2" | "HTML"; inlineKeyboard?: InlineKeyboard } = {},
|
|
100
|
-
): Promise<boolean> {
|
|
101
|
-
const body: Record<string, unknown> = {
|
|
102
|
-
chat_id: chatId,
|
|
103
|
-
message_id: messageId,
|
|
104
|
-
text,
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
if (options.parseMode) body.parse_mode = options.parseMode;
|
|
108
|
-
if (options.inlineKeyboard) {
|
|
109
|
-
body.reply_markup = { inline_keyboard: options.inlineKeyboard };
|
|
110
|
-
} else {
|
|
111
|
-
body.reply_markup = { inline_keyboard: [] };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
const res = await ctx.http.fetch(`${TELEGRAM_API}/bot${token}/editMessageText`, {
|
|
116
|
-
method: "POST",
|
|
117
|
-
headers: { "Content-Type": "application/json" },
|
|
118
|
-
body: JSON.stringify(body),
|
|
119
|
-
});
|
|
120
|
-
const data = (await res.json()) as { ok: boolean };
|
|
121
|
-
return data.ok;
|
|
122
|
-
} catch (err) {
|
|
123
|
-
ctx.logger.error("Telegram editMessage failed", { error: String(err) });
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export async function answerCallbackQuery(
|
|
129
|
-
ctx: PluginContext,
|
|
130
|
-
token: string,
|
|
131
|
-
callbackQueryId: string,
|
|
132
|
-
text: string,
|
|
133
|
-
): Promise<void> {
|
|
134
|
-
try {
|
|
135
|
-
await ctx.http.fetch(`${TELEGRAM_API}/bot${token}/answerCallbackQuery`, {
|
|
136
|
-
method: "POST",
|
|
137
|
-
headers: { "Content-Type": "application/json" },
|
|
138
|
-
body: JSON.stringify({
|
|
139
|
-
callback_query_id: callbackQueryId,
|
|
140
|
-
text,
|
|
141
|
-
}),
|
|
142
|
-
});
|
|
143
|
-
} catch (err) {
|
|
144
|
-
ctx.logger.error("Telegram answerCallbackQuery failed", { error: String(err) });
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export async function setMyCommands(
|
|
149
|
-
ctx: PluginContext,
|
|
150
|
-
token: string,
|
|
151
|
-
commands: Array<{ command: string; description: string }>,
|
|
152
|
-
): Promise<boolean> {
|
|
153
|
-
try {
|
|
154
|
-
const res = await ctx.http.fetch(`${TELEGRAM_API}/bot${token}/setMyCommands`, {
|
|
155
|
-
method: "POST",
|
|
156
|
-
headers: { "Content-Type": "application/json" },
|
|
157
|
-
body: JSON.stringify({ commands }),
|
|
158
|
-
});
|
|
159
|
-
const data = (await res.json()) as { ok: boolean };
|
|
160
|
-
return data.ok;
|
|
161
|
-
} catch (err) {
|
|
162
|
-
ctx.logger.error("Telegram setMyCommands failed", { error: String(err) });
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export async function sendChatAction(
|
|
168
|
-
ctx: PluginContext,
|
|
169
|
-
token: string,
|
|
170
|
-
chatId: string,
|
|
171
|
-
action: "typing" = "typing",
|
|
172
|
-
): Promise<void> {
|
|
173
|
-
try {
|
|
174
|
-
await ctx.http.fetch(`${TELEGRAM_API}/bot${token}/sendChatAction`, {
|
|
175
|
-
method: "POST",
|
|
176
|
-
headers: { "Content-Type": "application/json" },
|
|
177
|
-
body: JSON.stringify({ chat_id: chatId, action }),
|
|
178
|
-
});
|
|
179
|
-
} catch {
|
|
180
|
-
// Typing indicator failures are non-critical
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// MarkdownV2 requires escaping these characters
|
|
185
|
-
const MD_ESCAPE_CHARS = /([_*\[\]()~`>#+\-=|{}.!\\])/g;
|
|
186
|
-
|
|
187
|
-
export function escapeMarkdownV2(text: string): string {
|
|
188
|
-
return text.replace(MD_ESCAPE_CHARS, "\\$1");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function truncateAtWord(text: string, maxLen: number): string {
|
|
192
|
-
if (text.length <= maxLen) return text;
|
|
193
|
-
const truncated = text.slice(0, maxLen);
|
|
194
|
-
const lastSpace = truncated.lastIndexOf(" ");
|
|
195
|
-
return (lastSpace > maxLen * 0.7 ? truncated.slice(0, lastSpace) : truncated) + "...";
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function stripMarkdown(text: string): string {
|
|
199
|
-
return text
|
|
200
|
-
.replace(/\\([_*\[\]()~`>#+\-=|{}.!\\])/g, "$1")
|
|
201
|
-
.replace(/[*_`~]/g, "");
|
|
202
|
-
}
|