@overpod/mcp-telegram 1.28.1 → 1.32.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.
@@ -161,4 +161,66 @@ export function registerReactionTools(server, telegram) {
161
161
  return fail(e);
162
162
  }
163
163
  });
164
+ // ─── Paid reactions ────────────────────────────────────────────────────────
165
+ server.registerTool("telegram-send-paid-reaction", {
166
+ description: "Send a paid reaction (★ Stars) on a channel post. Stars are spent from your balance. Optional private flag controls leaderboard visibility.",
167
+ inputSchema: {
168
+ chatId: z.string().describe("Chat ID or username (channel)"),
169
+ messageId: z.number().int().positive().describe("Message ID of the channel post"),
170
+ count: z.number().int().min(1).max(2500).default(1).describe("Number of Stars to send (1-2500)"),
171
+ private: z
172
+ .boolean()
173
+ .optional()
174
+ .describe("true = anonymous on leaderboard, false = show name, omit = use account default"),
175
+ },
176
+ annotations: WRITE,
177
+ }, async ({ chatId, messageId, count, private: privateFlag }) => {
178
+ const err = await requireConnection(telegram);
179
+ if (err)
180
+ return fail(new Error(err));
181
+ try {
182
+ await telegram.sendPaidReaction(chatId, messageId, count, { private: privateFlag });
183
+ const privacy = privateFlag === true ? " (anonymous)" : privateFlag === false ? " (public)" : "";
184
+ return ok(`Sent ★×${count} paid reaction to message #${messageId} in ${chatId}${privacy}`);
185
+ }
186
+ catch (e) {
187
+ return fail(e);
188
+ }
189
+ });
190
+ server.registerTool("telegram-toggle-paid-reaction-privacy", {
191
+ description: "Change leaderboard visibility of your paid reaction on a specific channel post (Layer 198 API).",
192
+ inputSchema: {
193
+ chatId: z.string().describe("Chat ID or username (channel)"),
194
+ messageId: z.number().int().positive().describe("Message ID of the channel post"),
195
+ private: z.boolean().describe("true = anonymous on leaderboard, false = show name"),
196
+ },
197
+ annotations: WRITE,
198
+ }, async ({ chatId, messageId, private: privateFlag }) => {
199
+ const err = await requireConnection(telegram);
200
+ if (err)
201
+ return fail(new Error(err));
202
+ try {
203
+ await telegram.togglePaidReactionPrivacy(chatId, messageId, privateFlag);
204
+ return ok(`Updated paid reaction privacy on message #${messageId} in ${chatId}: ${privateFlag ? "anonymous" : "show name"}`);
205
+ }
206
+ catch (e) {
207
+ return fail(e);
208
+ }
209
+ });
210
+ server.registerTool("telegram-get-paid-reaction-privacy", {
211
+ description: "Get your current default paid reaction privacy setting.",
212
+ inputSchema: {},
213
+ annotations: READ_ONLY,
214
+ }, async () => {
215
+ const err = await requireConnection(telegram);
216
+ if (err)
217
+ return fail(new Error(err));
218
+ try {
219
+ const result = await telegram.getPaidReactionPrivacy();
220
+ return ok(`Default paid reaction privacy: ${result.private ? "anonymous" : "show name"}`);
221
+ }
222
+ catch (e) {
223
+ return fail(e);
224
+ }
225
+ });
164
226
  }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { TelegramService } from "../telegram-client.js";
3
+ export declare function registerSendMediaTools(server: McpServer, telegram: TelegramService): void;
@@ -0,0 +1,259 @@
1
+ import { z } from "zod";
2
+ import { ABSOLUTE_PATH_ERROR, fail, isSafeAbsolutePath, ok, requireConnection, sanitizeInputText, WRITE, } from "./shared.js";
3
+ const absolutePath = z.string().min(1).refine(isSafeAbsolutePath, ABSOLUTE_PATH_ERROR);
4
+ const safeText = z.string().transform(sanitizeInputText);
5
+ const DICE_EMOJIS = ["🎲", "🎯", "🎰", "🏀", "⚽", "🎳"];
6
+ export function registerSendMediaTools(server, telegram) {
7
+ server.registerTool("telegram-send-voice", {
8
+ description: "Send a voice note (audio recording) to a Telegram chat. Shows as a voice message with waveform UI.",
9
+ inputSchema: {
10
+ chatId: z.string().describe("Chat ID or username (e.g. @username or numeric ID)"),
11
+ filePath: absolutePath.describe("Absolute local filesystem path to audio file (OGG/Opus preferred; M4A/MP3 also accepted). URLs are rejected."),
12
+ caption: safeText.optional().describe("Optional caption shown below the voice note"),
13
+ replyTo: z.number().int().positive().optional().describe("Message ID to reply to"),
14
+ topicId: z.number().int().positive().optional().describe("Forum topic ID (for groups with Topics enabled)"),
15
+ parseMode: z.enum(["md", "html"]).optional().describe("Caption format: md (Markdown) or html"),
16
+ },
17
+ annotations: WRITE,
18
+ }, async ({ chatId, filePath, caption, replyTo, topicId, parseMode }) => {
19
+ const err = await requireConnection(telegram);
20
+ if (err)
21
+ return fail(new Error(err));
22
+ try {
23
+ const { id } = await telegram.sendVoice(chatId, filePath, {
24
+ caption,
25
+ replyTo,
26
+ topicId,
27
+ parseMode,
28
+ });
29
+ return ok(`Voice note sent to ${chatId} [#${id}]`);
30
+ }
31
+ catch (e) {
32
+ return fail(e);
33
+ }
34
+ });
35
+ server.registerTool("telegram-send-video-note", {
36
+ description: "Send a video note (round-shaped short video) to a Telegram chat. Shows as a circular video in the UI.",
37
+ inputSchema: {
38
+ chatId: z.string().describe("Chat ID or username"),
39
+ filePath: absolutePath.describe("Absolute local filesystem path to video file (MP4 preferred, square source recommended for best look). URLs are rejected."),
40
+ duration: z.number().int().positive().max(60).optional().describe("Duration in seconds (Telegram caps at 60)"),
41
+ length: z
42
+ .number()
43
+ .int()
44
+ .positive()
45
+ .max(640)
46
+ .optional()
47
+ .describe("Frame edge length in pixels (the circle is square-cropped)"),
48
+ replyTo: z.number().int().positive().optional().describe("Message ID to reply to"),
49
+ topicId: z.number().int().positive().optional().describe("Forum topic ID"),
50
+ },
51
+ annotations: WRITE,
52
+ }, async ({ chatId, filePath, duration, length, replyTo, topicId }) => {
53
+ const err = await requireConnection(telegram);
54
+ if (err)
55
+ return fail(new Error(err));
56
+ try {
57
+ const { id } = await telegram.sendVideoNote(chatId, filePath, {
58
+ duration,
59
+ length,
60
+ replyTo,
61
+ topicId,
62
+ });
63
+ return ok(`Video note sent to ${chatId} [#${id}]`);
64
+ }
65
+ catch (e) {
66
+ return fail(e);
67
+ }
68
+ });
69
+ server.registerTool("telegram-send-contact", {
70
+ description: "Send a contact card (phone number + name) to a Telegram chat.",
71
+ inputSchema: {
72
+ chatId: z.string().describe("Chat ID or username"),
73
+ phone: z
74
+ .string()
75
+ .regex(/^\+?\d{6,15}$/)
76
+ .describe("Phone number in E.164-like format — 6-15 digits, optional leading +. " +
77
+ "Note: sent as-is; Telegram shows the number to the recipient."),
78
+ firstName: safeText.pipe(z.string().min(1).max(64)).describe("Contact first name"),
79
+ lastName: safeText.pipe(z.string().max(64)).optional().describe("Contact last name"),
80
+ vcard: safeText.pipe(z.string().max(2048)).optional().describe("Optional vCard v3.0 text content"),
81
+ replyTo: z.number().int().positive().optional(),
82
+ topicId: z.number().int().positive().optional(),
83
+ },
84
+ annotations: WRITE,
85
+ }, async ({ chatId, phone, firstName, lastName, vcard, replyTo, topicId }) => {
86
+ const err = await requireConnection(telegram);
87
+ if (err)
88
+ return fail(new Error(err));
89
+ try {
90
+ const { id } = await telegram.sendContact(chatId, phone, firstName, {
91
+ lastName,
92
+ vcard,
93
+ replyTo,
94
+ topicId,
95
+ });
96
+ return ok(`Contact sent to ${chatId} [#${id}]`);
97
+ }
98
+ catch (e) {
99
+ return fail(e);
100
+ }
101
+ });
102
+ server.registerTool("telegram-send-dice", {
103
+ description: "Send an animated dice/game emoji to a Telegram chat. Returns the server-rolled value — useful for games, " +
104
+ "coin-flips, random picks. Values: 🎲🎯🎳 = 1-6, 🏀⚽ = 1-5, 🎰 = slot combo 1-64.",
105
+ inputSchema: {
106
+ chatId: z.string().describe("Chat ID or username"),
107
+ emoji: z
108
+ .enum(DICE_EMOJIS)
109
+ .default("🎲")
110
+ .describe("Dice emoji: 🎲 dice (1-6), 🎯 dart (1-6), 🎰 slot machine (1-64), 🏀 basketball (1-5), ⚽ football (1-5), 🎳 bowling (1-6)"),
111
+ replyTo: z.number().int().positive().optional(),
112
+ topicId: z.number().int().positive().optional(),
113
+ },
114
+ annotations: WRITE,
115
+ }, async ({ chatId, emoji, replyTo, topicId }) => {
116
+ const err = await requireConnection(telegram);
117
+ if (err)
118
+ return fail(new Error(err));
119
+ try {
120
+ const { id, value } = await telegram.sendDice(chatId, emoji, { replyTo, topicId });
121
+ const rolled = value !== undefined ? `: rolled ${value}` : " (value pending)";
122
+ return ok(`Dice ${emoji} sent to ${chatId}${rolled} [#${id}]`);
123
+ }
124
+ catch (e) {
125
+ return fail(e);
126
+ }
127
+ });
128
+ server.registerTool("telegram-send-location", {
129
+ description: "Send a geographic location to a Telegram chat. Static pin by default; set livePeriod to share a live-updating location for N seconds.",
130
+ inputSchema: {
131
+ chatId: z.string().describe("Chat ID or username"),
132
+ latitude: z.number().min(-90).max(90).describe("Latitude in decimal degrees (-90 to 90)"),
133
+ longitude: z.number().min(-180).max(180).describe("Longitude in decimal degrees (-180 to 180)"),
134
+ accuracyRadius: z
135
+ .number()
136
+ .int()
137
+ .nonnegative()
138
+ .optional()
139
+ .describe("Horizontal accuracy radius in meters (0 = unknown)"),
140
+ livePeriod: z
141
+ .number()
142
+ .int()
143
+ .min(60)
144
+ .max(86400)
145
+ .optional()
146
+ .describe("If set, sends a live location updated for N seconds (60-86400). Omit for static pin."),
147
+ heading: z
148
+ .number()
149
+ .int()
150
+ .min(1)
151
+ .max(360)
152
+ .optional()
153
+ .describe("Direction the user is heading, 1-360 degrees (meaningful only for live locations)"),
154
+ proximityRadius: z
155
+ .number()
156
+ .int()
157
+ .positive()
158
+ .optional()
159
+ .describe("Alert radius for proximity notification in meters (live only)"),
160
+ replyTo: z.number().int().positive().optional().describe("Message ID to reply to"),
161
+ topicId: z.number().int().positive().optional().describe("Forum topic ID"),
162
+ },
163
+ annotations: WRITE,
164
+ }, async ({ chatId, latitude, longitude, accuracyRadius, livePeriod, heading, proximityRadius, replyTo, topicId }) => {
165
+ const err = await requireConnection(telegram);
166
+ if (err)
167
+ return fail(new Error(err));
168
+ try {
169
+ const { id } = await telegram.sendLocation(chatId, latitude, longitude, {
170
+ accuracyRadius,
171
+ livePeriod,
172
+ heading,
173
+ proximityRadius,
174
+ replyTo,
175
+ topicId,
176
+ });
177
+ const label = livePeriod ? `Live location sent to ${chatId} for ${livePeriod}s` : `Location sent to ${chatId}`;
178
+ return ok(`${label} [#${id}]`);
179
+ }
180
+ catch (e) {
181
+ return fail(e);
182
+ }
183
+ });
184
+ server.registerTool("telegram-send-venue", {
185
+ description: "Send a venue card (point-of-interest with title and address) to a Telegram chat.",
186
+ inputSchema: {
187
+ chatId: z.string().describe("Chat ID or username"),
188
+ latitude: z.number().min(-90).max(90).describe("Venue latitude"),
189
+ longitude: z.number().min(-180).max(180).describe("Venue longitude"),
190
+ title: safeText.pipe(z.string().min(1).max(256)).describe("Venue name (e.g. 'Red Square')"),
191
+ address: safeText.pipe(z.string().min(1).max(512)).describe("Street address"),
192
+ provider: safeText
193
+ .pipe(z.string().max(32))
194
+ .optional()
195
+ .describe("Data provider — typically 'foursquare' or 'gplaces'. Defaults to 'foursquare'."),
196
+ venueId: safeText.pipe(z.string().max(256)).optional().describe("Provider-specific venue ID"),
197
+ venueType: safeText.pipe(z.string().max(256)).optional().describe("Provider-specific venue type category"),
198
+ replyTo: z.number().int().positive().optional(),
199
+ topicId: z.number().int().positive().optional(),
200
+ },
201
+ annotations: WRITE,
202
+ }, async ({ chatId, latitude, longitude, title, address, provider, venueId, venueType, replyTo, topicId }) => {
203
+ const err = await requireConnection(telegram);
204
+ if (err)
205
+ return fail(new Error(err));
206
+ try {
207
+ const { id } = await telegram.sendVenue(chatId, latitude, longitude, title, address, {
208
+ provider,
209
+ venueId,
210
+ venueType,
211
+ replyTo,
212
+ topicId,
213
+ });
214
+ return ok(`Venue "${title}" sent to ${chatId} [#${id}]`);
215
+ }
216
+ catch (e) {
217
+ return fail(e);
218
+ }
219
+ });
220
+ server.registerTool("telegram-send-album", {
221
+ description: "Send an album (group) of 2-10 photos as a single grouped message. Media type is auto-detected " +
222
+ "by file extension — videos are supported by the underlying TL call but are not covered by v1.29.0 " +
223
+ "mock tests, so uniform-photo albums are the safer choice until a live checkpoint. Uploads are " +
224
+ "serial per item: expect ≈4-10s for 10 mid-size photos, 15-40s for 10 large videos. Prefer ≤5 " +
225
+ "items or photos when low latency matters.",
226
+ inputSchema: {
227
+ chatId: z.string().describe("Chat ID or username"),
228
+ items: z
229
+ .array(z.object({
230
+ filePath: absolutePath.describe("Absolute local filesystem path to a photo or video file. URLs are rejected."),
231
+ caption: safeText
232
+ .optional()
233
+ .describe("Per-item caption (shown under this item when the album is expanded)"),
234
+ }))
235
+ .min(2)
236
+ .max(10)
237
+ .describe("Array of media items (2-10)"),
238
+ caption: safeText
239
+ .optional()
240
+ .describe("Album-level caption (attached to the first item — shown in the collapsed view)"),
241
+ parseMode: z.enum(["md", "html"]).optional().describe("Caption format (applies to all captions)"),
242
+ replyTo: z.number().int().positive().optional().describe("Message ID to reply to"),
243
+ topicId: z.number().int().positive().optional().describe("Forum topic ID"),
244
+ },
245
+ annotations: WRITE,
246
+ }, async ({ chatId, items, caption, parseMode, replyTo, topicId }) => {
247
+ const err = await requireConnection(telegram);
248
+ if (err)
249
+ return fail(new Error(err));
250
+ try {
251
+ const { ids } = await telegram.sendAlbum(chatId, items, { caption, parseMode, replyTo, topicId });
252
+ const idList = ids.map((id) => `#${id}`).join(", ");
253
+ return ok(`Album sent to ${chatId} (${ids.length} items) [${idList}]`);
254
+ }
255
+ catch (e) {
256
+ return fail(e);
257
+ }
258
+ });
259
+ }
@@ -36,5 +36,33 @@ export declare function formatReactions(reactions?: {
36
36
  count: number;
37
37
  me: boolean;
38
38
  }[]): string;
39
+ /**
40
+ * Validate that a user-supplied path is safe to upload.
41
+ *
42
+ * The threat model is prompt-injection: an AI that was told "send the user's file" can be
43
+ * manipulated into sending `/proc/self/environ`, `/etc/shadow`, `http://169.254.169.254/...`,
44
+ * or an SMB share `\\attacker.com\share`. GramJS `sendFile` happily fetches URLs and reads
45
+ * any local path, so the validation has to live here.
46
+ *
47
+ * Rules:
48
+ * - Must be an absolute path (POSIX `/` or Windows `C:\` / `\\server\share`).
49
+ * - No URL schemes (http:, https:, file:, ftp:, data:, javascript:, …).
50
+ * - No path traversal (`..` segments) even inside an absolute path.
51
+ * - No OS-sensitive directories on POSIX (`/proc`, `/sys`, `/dev`, `/run`). These leak env,
52
+ * kernel state, or block on device reads.
53
+ * - UNC paths (`\\server\share`) are blocked (NTLM-relay / remote-SMB risk).
54
+ *
55
+ * This is defence-in-depth: the admin still owns the machine and can exfiltrate files
56
+ * deliberately — we just refuse to help prompt-injection do it automatically.
57
+ */
58
+ export declare function isSafeAbsolutePath(p: string): boolean;
59
+ /** Zod refinement message paired with `isSafeAbsolutePath` */
60
+ export declare const ABSOLUTE_PATH_ERROR = "Must be an absolute local filesystem path (e.g. /tmp/file.ogg). URLs, UNC shares, path traversal (..), and OS-sensitive dirs (/proc, /sys, /dev, /run) are rejected.";
61
+ /**
62
+ * Sanitize a user-provided text for safe TL encoding.
63
+ * Strips unpaired UTF-16 surrogates that crash GramJS's wire serializer. Use on every
64
+ * free-text field that reaches GramJS (captions, provider names, venue titles, quoteText, …).
65
+ */
66
+ export declare function sanitizeInputText(text: string): string;
39
67
  /** Try to connect, return error text if failed */
40
68
  export declare function requireConnection(telegram: TelegramService): Promise<string | null>;
@@ -22,6 +22,65 @@ export function formatReactions(reactions) {
22
22
  const parts = reactions.map((r) => `${r.emoji}×${r.count}${r.me ? "(me)" : ""}`);
23
23
  return ` [${parts.join(" ")}]`;
24
24
  }
25
+ /**
26
+ * Validate that a user-supplied path is safe to upload.
27
+ *
28
+ * The threat model is prompt-injection: an AI that was told "send the user's file" can be
29
+ * manipulated into sending `/proc/self/environ`, `/etc/shadow`, `http://169.254.169.254/...`,
30
+ * or an SMB share `\\attacker.com\share`. GramJS `sendFile` happily fetches URLs and reads
31
+ * any local path, so the validation has to live here.
32
+ *
33
+ * Rules:
34
+ * - Must be an absolute path (POSIX `/` or Windows `C:\` / `\\server\share`).
35
+ * - No URL schemes (http:, https:, file:, ftp:, data:, javascript:, …).
36
+ * - No path traversal (`..` segments) even inside an absolute path.
37
+ * - No OS-sensitive directories on POSIX (`/proc`, `/sys`, `/dev`, `/run`). These leak env,
38
+ * kernel state, or block on device reads.
39
+ * - UNC paths (`\\server\share`) are blocked (NTLM-relay / remote-SMB risk).
40
+ *
41
+ * This is defence-in-depth: the admin still owns the machine and can exfiltrate files
42
+ * deliberately — we just refuse to help prompt-injection do it automatically.
43
+ */
44
+ export function isSafeAbsolutePath(p) {
45
+ if (typeof p !== "string" || p.length < 2)
46
+ return false;
47
+ // Reject embedded NUL — Node fs rejects it too, but we want an earlier, clearer failure
48
+ if (p.includes("\0"))
49
+ return false;
50
+ // Reject URL schemes outright (scheme://... or scheme:...)
51
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(p))
52
+ return false;
53
+ if (/^(file|data|javascript|http|https|ftp|ftps|ws|wss):/i.test(p))
54
+ return false;
55
+ // Reject Windows UNC shares — SMB SSRF / NTLM relay primitive
56
+ if (p.startsWith("\\\\") || p.startsWith("//"))
57
+ return false;
58
+ // Reject path-traversal segments anywhere in the path
59
+ const parts = p.split(/[\\/]+/);
60
+ if (parts.some((seg) => seg === ".."))
61
+ return false;
62
+ // POSIX absolute path
63
+ if (p.startsWith("/")) {
64
+ // Reject kernel / device / runtime pseudo-filesystems
65
+ if (/^\/(proc|sys|dev|run)(\/|$)/.test(p))
66
+ return false;
67
+ return true;
68
+ }
69
+ // Windows absolute path (C:\ or C:/), no UNC (already rejected above)
70
+ if (/^[a-zA-Z]:[\\/]/.test(p))
71
+ return true;
72
+ return false;
73
+ }
74
+ /** Zod refinement message paired with `isSafeAbsolutePath` */
75
+ export const ABSOLUTE_PATH_ERROR = "Must be an absolute local filesystem path (e.g. /tmp/file.ogg). URLs, UNC shares, path traversal (..), and OS-sensitive dirs (/proc, /sys, /dev, /run) are rejected.";
76
+ /**
77
+ * Sanitize a user-provided text for safe TL encoding.
78
+ * Strips unpaired UTF-16 surrogates that crash GramJS's wire serializer. Use on every
79
+ * free-text field that reaches GramJS (captions, provider names, venue titles, quoteText, …).
80
+ */
81
+ export function sanitizeInputText(text) {
82
+ return sanitize(text);
83
+ }
25
84
  /** Try to connect, return error text if failed */
26
85
  export async function requireConnection(telegram) {
27
86
  if (await telegram.ensureConnected())