Skip to content

Commit ac61c7f

Browse files
committed
fix(telegram): accept URL/file_id media inputs
Telegram media block params were normalized as file references, which rejected plain strings (URLs/file_id) and caused send photo/video/audio/animation to fail validation before reaching the Telegram API. Fixes #3220
1 parent 0d86ea0 commit ac61c7f

File tree

9 files changed

+297
-13
lines changed

9 files changed

+297
-13
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { TelegramBlock } from '@/blocks/blocks/telegram'
3+
4+
describe('TelegramBlock', () => {
5+
const paramsFn = TelegramBlock.tools.config?.params
6+
7+
if (!paramsFn) {
8+
throw new Error('TelegramBlock.tools.config.params function is missing')
9+
}
10+
11+
it.concurrent('accepts a public URL string for telegram_send_photo', () => {
12+
const result = paramsFn({
13+
operation: 'telegram_send_photo',
14+
botToken: 'token',
15+
chatId: ' 123 ',
16+
photo: ' https://example.com/a.jpg ',
17+
caption: 'hello',
18+
})
19+
20+
expect(result).toEqual({
21+
botToken: 'token',
22+
chatId: '123',
23+
photo: 'https://example.com/a.jpg',
24+
caption: 'hello',
25+
})
26+
})
27+
28+
it.concurrent('accepts a file-like object for telegram_send_photo (uses url)', () => {
29+
const result = paramsFn({
30+
operation: 'telegram_send_photo',
31+
botToken: 'token',
32+
chatId: '123',
33+
photo: { id: 'f1', url: 'https://example.com/file.png' },
34+
})
35+
36+
expect(result).toMatchObject({
37+
photo: 'https://example.com/file.png',
38+
})
39+
})
40+
41+
it.concurrent('accepts JSON-stringified file objects in advanced mode', () => {
42+
const result = paramsFn({
43+
operation: 'telegram_send_photo',
44+
botToken: 'token',
45+
chatId: '123',
46+
photo: JSON.stringify({ id: 'f1', url: 'https://example.com/file.png' }),
47+
})
48+
49+
expect(result).toMatchObject({
50+
photo: 'https://example.com/file.png',
51+
})
52+
})
53+
54+
it.concurrent('throws a user-facing error when photo is missing/blank', () => {
55+
expect(() =>
56+
paramsFn({
57+
operation: 'telegram_send_photo',
58+
botToken: 'token',
59+
chatId: '123',
60+
photo: ' ',
61+
})
62+
).toThrow('Photo is required.')
63+
})
64+
65+
it.concurrent('accepts a public URL string for telegram_send_video', () => {
66+
const result = paramsFn({
67+
operation: 'telegram_send_video',
68+
botToken: 'token',
69+
chatId: '123',
70+
video: 'https://example.com/v.mp4',
71+
})
72+
73+
expect(result).toMatchObject({
74+
video: 'https://example.com/v.mp4',
75+
})
76+
})
77+
})

apps/sim/blocks/blocks/telegram.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { TelegramIcon } from '@/components/icons'
22
import type { BlockConfig } from '@/blocks/types'
33
import { AuthMode } from '@/blocks/types'
4-
import { normalizeFileInput } from '@/blocks/utils'
4+
import { normalizeFileInput, normalizeFileOrUrlInput } from '@/blocks/utils'
55
import type { TelegramResponse } from '@/tools/telegram/types'
66
import { getTrigger } from '@/triggers'
77

@@ -270,7 +270,7 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
270270
}
271271
case 'telegram_send_photo': {
272272
// photo is the canonical param for both basic (photoFile) and advanced modes
273-
const photoSource = normalizeFileInput(params.photo, {
273+
const photoSource = normalizeFileOrUrlInput(params.photo, {
274274
single: true,
275275
})
276276
if (!photoSource) {
@@ -284,7 +284,7 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
284284
}
285285
case 'telegram_send_video': {
286286
// video is the canonical param for both basic (videoFile) and advanced modes
287-
const videoSource = normalizeFileInput(params.video, {
287+
const videoSource = normalizeFileOrUrlInput(params.video, {
288288
single: true,
289289
})
290290
if (!videoSource) {
@@ -298,7 +298,7 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
298298
}
299299
case 'telegram_send_audio': {
300300
// audio is the canonical param for both basic (audioFile) and advanced modes
301-
const audioSource = normalizeFileInput(params.audio, {
301+
const audioSource = normalizeFileOrUrlInput(params.audio, {
302302
single: true,
303303
})
304304
if (!audioSource) {
@@ -312,7 +312,7 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
312312
}
313313
case 'telegram_send_animation': {
314314
// animation is the canonical param for both basic (animationFile) and advanced modes
315-
const animationSource = normalizeFileInput(params.animation, {
315+
const animationSource = normalizeFileOrUrlInput(params.animation, {
316316
single: true,
317317
})
318318
if (!animationSource) {

apps/sim/blocks/utils.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { normalizeFileOrUrlInput } from '@/blocks/utils'
3+
4+
describe('normalizeFileOrUrlInput', () => {
5+
it.concurrent('returns undefined for nullish and empty values', () => {
6+
expect(normalizeFileOrUrlInput(undefined, { single: true })).toBeUndefined()
7+
expect(normalizeFileOrUrlInput(null, { single: true })).toBeUndefined()
8+
expect(normalizeFileOrUrlInput('', { single: true })).toBeUndefined()
9+
expect(normalizeFileOrUrlInput(' ', { single: true })).toBeUndefined()
10+
})
11+
12+
it.concurrent('passes through trimmed URL/file_id strings', () => {
13+
expect(normalizeFileOrUrlInput(' https://example.com/a.jpg ', { single: true })).toBe(
14+
'https://example.com/a.jpg'
15+
)
16+
expect(normalizeFileOrUrlInput('AgACAgUAAxkBAAIB...', { single: true })).toBe(
17+
'AgACAgUAAxkBAAIB...'
18+
)
19+
expect(normalizeFileOrUrlInput('1234567890', { single: true })).toBe('1234567890')
20+
})
21+
22+
it.concurrent('extracts url from a file-like object', () => {
23+
expect(normalizeFileOrUrlInput({ url: 'https://example.com/file.png' }, { single: true })).toBe(
24+
'https://example.com/file.png'
25+
)
26+
})
27+
28+
it.concurrent('extracts url from JSON-stringified file objects/arrays', () => {
29+
expect(
30+
normalizeFileOrUrlInput(JSON.stringify({ url: 'https://example.com/a.png' }), {
31+
single: true,
32+
})
33+
).toBe('https://example.com/a.png')
34+
35+
expect(
36+
normalizeFileOrUrlInput(JSON.stringify([{ url: 'https://example.com/a.png' }]), {
37+
single: true,
38+
})
39+
).toBe('https://example.com/a.png')
40+
})
41+
42+
it.concurrent('throws when single=true and multiple values resolve', () => {
43+
expect(() =>
44+
normalizeFileOrUrlInput(
45+
JSON.stringify([{ url: 'https://a.com' }, { url: 'https://b.com' }]),
46+
{
47+
single: true,
48+
}
49+
)
50+
).toThrow('File reference must be a single file')
51+
})
52+
53+
it.concurrent('treats invalid JSON strings as raw identifiers', () => {
54+
expect(normalizeFileOrUrlInput('{not json', { single: true })).toBe('{not json')
55+
})
56+
})

apps/sim/blocks/utils.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,72 @@ export function normalizeFileInput(
364364

365365
return files
366366
}
367+
368+
function extractUrlFromFileLike(value: unknown): string | undefined {
369+
if (!value || typeof value !== 'object') return undefined
370+
const url = (value as { url?: unknown }).url
371+
if (typeof url !== 'string') return undefined
372+
const trimmed = url.trim()
373+
return trimmed.length > 0 ? trimmed : undefined
374+
}
375+
376+
/**
377+
* Normalizes an input that can be either:
378+
* - a plain string (URL, file_id, etc.)
379+
* - a UserFile-like object with a `url` property
380+
* - an array of the above
381+
* - a JSON stringified object/array (advanced mode template resolution)
382+
*/
383+
export function normalizeFileOrUrlInput(
384+
value: unknown,
385+
options: { single: true; errorMessage?: string }
386+
): string | undefined
387+
export function normalizeFileOrUrlInput(
388+
value: unknown,
389+
options?: { single?: false }
390+
): string[] | undefined
391+
export function normalizeFileOrUrlInput(
392+
value: unknown,
393+
options?: { single?: boolean; errorMessage?: string }
394+
): string | string[] | undefined {
395+
if (value === null || value === undefined) return undefined
396+
397+
if (typeof value === 'string') {
398+
const trimmed = value.trim()
399+
if (!trimmed) return undefined
400+
401+
// Only attempt JSON parsing for object/array payloads (advanced mode may JSON.stringify file objects).
402+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
403+
try {
404+
value = JSON.parse(trimmed)
405+
} catch {
406+
// Not valid JSON; treat as a raw media identifier (URL/file_id).
407+
return options?.single ? trimmed : [trimmed]
408+
}
409+
} else {
410+
return options?.single ? trimmed : [trimmed]
411+
}
412+
}
413+
414+
const values: unknown[] = Array.isArray(value) ? value : [value]
415+
const normalized = values
416+
.map((v) => {
417+
if (typeof v === 'string') {
418+
const trimmed = v.trim()
419+
return trimmed.length > 0 ? trimmed : undefined
420+
}
421+
return extractUrlFromFileLike(v)
422+
})
423+
.filter((v): v is string => typeof v === 'string' && v.length > 0)
424+
425+
if (normalized.length === 0) return undefined
426+
427+
if (options?.single) {
428+
if (normalized.length > 1) {
429+
throw new Error(options.errorMessage ?? DEFAULT_MULTIPLE_FILES_ERROR)
430+
}
431+
return normalized[0]
432+
}
433+
434+
return normalized
435+
}

apps/sim/tools/telegram/send_animation.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
TelegramSendAnimationParams,
55
TelegramSendMediaResponse,
66
} from '@/tools/telegram/types'
7-
import { convertMarkdownToHTML } from '@/tools/telegram/utils'
7+
import { convertMarkdownToHTML, normalizeTelegramMediaParam } from '@/tools/telegram/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
export const telegramSendAnimationTool: ToolConfig<
@@ -52,9 +52,14 @@ export const telegramSendAnimationTool: ToolConfig<
5252
'Content-Type': 'application/json',
5353
}),
5454
body: (params: TelegramSendAnimationParams) => {
55+
const animation = normalizeTelegramMediaParam(params.animation, { single: true })
56+
if (typeof animation !== 'string' || !animation) {
57+
throw new Error('Animation is required.')
58+
}
59+
5560
const body: Record<string, any> = {
5661
chat_id: params.chatId,
57-
animation: params.animation,
62+
animation,
5863
}
5964

6065
if (params.caption) {

apps/sim/tools/telegram/send_audio.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
TelegramSendAudioParams,
55
TelegramSendAudioResponse,
66
} from '@/tools/telegram/types'
7-
import { convertMarkdownToHTML } from '@/tools/telegram/utils'
7+
import { convertMarkdownToHTML, normalizeTelegramMediaParam } from '@/tools/telegram/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
export const telegramSendAudioTool: ToolConfig<TelegramSendAudioParams, TelegramSendAudioResponse> =
@@ -50,9 +50,14 @@ export const telegramSendAudioTool: ToolConfig<TelegramSendAudioParams, Telegram
5050
'Content-Type': 'application/json',
5151
}),
5252
body: (params: TelegramSendAudioParams) => {
53+
const audio = normalizeTelegramMediaParam(params.audio, { single: true })
54+
if (typeof audio !== 'string' || !audio) {
55+
throw new Error('Audio is required.')
56+
}
57+
5358
const body: Record<string, any> = {
5459
chat_id: params.chatId,
55-
audio: params.audio,
60+
audio,
5661
}
5762

5863
if (params.caption) {

apps/sim/tools/telegram/send_photo.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
TelegramSendPhotoParams,
55
TelegramSendPhotoResponse,
66
} from '@/tools/telegram/types'
7-
import { convertMarkdownToHTML } from '@/tools/telegram/utils'
7+
import { convertMarkdownToHTML, normalizeTelegramMediaParam } from '@/tools/telegram/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
export const telegramSendPhotoTool: ToolConfig<TelegramSendPhotoParams, TelegramSendPhotoResponse> =
@@ -50,9 +50,14 @@ export const telegramSendPhotoTool: ToolConfig<TelegramSendPhotoParams, Telegram
5050
'Content-Type': 'application/json',
5151
}),
5252
body: (params: TelegramSendPhotoParams) => {
53+
const photo = normalizeTelegramMediaParam(params.photo, { single: true })
54+
if (typeof photo !== 'string' || !photo) {
55+
throw new Error('Photo is required.')
56+
}
57+
5358
const body: Record<string, any> = {
5459
chat_id: params.chatId,
55-
photo: params.photo,
60+
photo,
5661
}
5762

5863
if (params.caption) {

apps/sim/tools/telegram/send_video.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
TelegramSendMediaResponse,
55
TelegramSendVideoParams,
66
} from '@/tools/telegram/types'
7-
import { convertMarkdownToHTML } from '@/tools/telegram/utils'
7+
import { convertMarkdownToHTML, normalizeTelegramMediaParam } from '@/tools/telegram/utils'
88
import type { ToolConfig } from '@/tools/types'
99

1010
export const telegramSendVideoTool: ToolConfig<TelegramSendVideoParams, TelegramSendMediaResponse> =
@@ -50,9 +50,14 @@ export const telegramSendVideoTool: ToolConfig<TelegramSendVideoParams, Telegram
5050
'Content-Type': 'application/json',
5151
}),
5252
body: (params: TelegramSendVideoParams) => {
53+
const video = normalizeTelegramMediaParam(params.video, { single: true })
54+
if (typeof video !== 'string' || !video) {
55+
throw new Error('Video is required.')
56+
}
57+
5358
const body: Record<string, any> = {
5459
chat_id: params.chatId,
55-
video: params.video,
60+
video,
5661
}
5762

5863
if (params.caption) {

0 commit comments

Comments
 (0)