Commite9d420cbRecorded18 Apr 2026Repositorysigil-telegram

Add tg-send-video and tg-upload-video

Message

Mirrors the photo/document patterns: tg-send-video takes a Telegram file_id or HTTP(S) URL and calls sendVideo; tg-upload-video reads a local file, MIME-detects it, and posts a multipart sendVideo. Both expose video-specific optional params (duration, width, height, supports-streaming) on top of the shared caption/parse-mode/reply-markup keys.

The multipart upload path forwards the file bytes verbatim, so any audio track in the container is preserved (no transcoding).

Tests cover the video form-field encoding and verbatim raw-byte preservation in build-multipart-body.

Changed
 src/sigil/telegram.sgl        |  2 ++
 src/sigil/telegram/client.sgl | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/test-telegram.sgl        | 34 +++++++++++++++++++++++++++++++++-
 3 files changed, 123 insertions(+), 1 deletion(-)
Diff
src/sigil/telegram.sglmodified
@@ -60,10 +60,12 @@
60
;; Media (URL/file_id)
61
tg-send-photo
62
tg-send-document
+63
tg-send-video
64
65
;; Media (file upload)
66
tg-upload-photo
67
tg-upload-document
+68
tg-upload-video
69
70
;; Callbacks
71
tg-answer-callback
src/sigil/telegram/client.sglmodified
@@ -34,10 +34,12 @@
34
;; Media (URL/file_id)
35
tg-send-photo
36
tg-send-document
+37
tg-send-video
38
39
;; Media (file upload)
40
tg-upload-photo
41
tg-upload-document
+42
tg-upload-video
43
44
;; Callbacks
45
tg-answer-callback
@@ -215,6 +217,41 @@
217
(params (add-param params reply_markup: reply-markup)))
218
(dict->tg-message (tg-api-call client "sendDocument" params: params))))
219
+220
;;; Send a video by URL or file_id.
+221
;;;
+222
;;; The `video` parameter must be a Telegram file_id string or an
+223
;;; HTTP/HTTPS URL. For uploading a local file, use `tg-upload-video`.
+224
;;;
+225
;;; Optional parameters mirror Telegram's `sendVideo` extras: duration
+226
;;; (seconds), width/height (pixels), and supports-streaming (whether
+227
;;; the file is suitable for streaming).
+228
;;;
+229
;;; ```scheme
+230
;;; (tg-send-video client 12345 "https://example.com/clip.mp4"
+231
;;; caption: "Build demo" supports-streaming: #t)
+232
;;; ```
+233
(define (tg-send-video client chat-id video
+234
(keys: (caption #f) (parse-mode #f) (reply-markup #f)
+235
(duration #f) (width #f) (height #f)
+236
(supports-streaming #f)))
+237
(: tg-client? (any-of integer? string?) string?
+238
(caption: (maybe string?)) (parse-mode: (maybe string?))
+239
(reply-markup: (maybe dict?))
+240
(duration: (maybe integer?)) (width: (maybe integer?))
+241
(height: (maybe integer?)) (supports-streaming: (maybe boolean?))
+242
-> tg-message?)
+243
(let* ((params (dict chat_id: chat-id video: video))
+244
(params (add-param params caption: caption))
+245
(params (add-param params parse_mode: parse-mode))
+246
(params (add-param params reply_markup: reply-markup))
+247
(params (add-param params duration: duration))
+248
(params (add-param params width: width))
+249
(params (add-param params height: height))
+250
(params (if supports-streaming
+251
(dict-set params supports_streaming: #t)
+252
params)))
+253
(dict->tg-message (tg-api-call client "sendVideo" params: params))))
+254
255
;; ============================================================
256
;; Multipart File Upload
257
;; ============================================================
@@ -387,6 +424,57 @@
424
(files (list (list "document" filename mime data))))
425
(dict->tg-message (tg-api-call/upload client "sendDocument" fields files))))
426
+427
;;; Upload a local video file.
+428
;;;
+429
;;; Reads the file at `path`, auto-detects its MIME type, and sends
+430
;;; it to the Telegram API via multipart/form-data upload. The bytes
+431
;;; are forwarded verbatim — any audio track in the container is
+432
;;; preserved (no transcoding).
+433
;;;
+434
;;; Optional parameters mirror `tg-send-video`: duration (seconds),
+435
;;; width/height (pixels), supports-streaming.
+436
;;;
+437
;;; ```scheme
+438
;;; (tg-upload-video client 12345 "/tmp/screencast.mp4"
+439
;;; caption: "Cinder Cantata progress" supports-streaming: #t)
+440
;;; ```
+441
(define (tg-upload-video client chat-id path
+442
(keys: (caption #f) (parse-mode #f) (reply-markup #f)
+443
(duration #f) (width #f) (height #f)
+444
(supports-streaming #f)))
+445
(: tg-client? (any-of integer? string?) string?
+446
(caption: (maybe string?)) (parse-mode: (maybe string?))
+447
(reply-markup: (maybe dict?))
+448
(duration: (maybe integer?)) (width: (maybe integer?))
+449
(height: (maybe integer?)) (supports-streaming: (maybe boolean?))
+450
-> tg-message?)
+451
(let* ((data (read-file-bytes path))
+452
(mime (mime-type-for-file path))
+453
(filename (path-basename path))
+454
(fields (let* ((f (list (cons "chat_id" (if (integer? chat-id)
+455
(number->string chat-id)
+456
chat-id))))
+457
(f (if caption (cons (cons "caption" caption) f) f))
+458
(f (if parse-mode (cons (cons "parse_mode" parse-mode) f) f))
+459
(f (if reply-markup
+460
(cons (cons "reply_markup" (json-encode reply-markup)) f)
+461
f))
+462
(f (if duration
+463
(cons (cons "duration" (number->string duration)) f)
+464
f))
+465
(f (if width
+466
(cons (cons "width" (number->string width)) f)
+467
f))
+468
(f (if height
+469
(cons (cons "height" (number->string height)) f)
+470
f))
+471
(f (if supports-streaming
+472
(cons (cons "supports_streaming" "true") f)
+473
f)))
+474
f))
+475
(files (list (list "video" filename mime data))))
+476
(dict->tg-message (tg-api-call/upload client "sendVideo" fields files))))
+477
478
;; ============================================================
479
;; Callback Queries
480
;; ============================================================
test/test-telegram.sglmodified
@@ -277,6 +277,38 @@
277
(assert-true (bytevector? body))
278
;; Body should end with closing boundary
279
(let ((str (utf8->string body)))
280
(assert-true (string-contains? str (string-append "--" boundary "--")))))))
+280
(assert-true (string-contains? str (string-append "--" boundary "--"))))))
+281
+282
(test "video file part uses video field name"
+283
(let* ((data (string->utf8 "fake video bytes"))
+284
(bv (encode-file-part "BOUNDARY" "video" "clip.mp4" "video/mp4" data))
+285
(str (utf8->string bv)))
+286
(assert-true (string-contains? str "name=\"video\"; filename=\"clip.mp4\""))
+287
(assert-true (string-contains? str "Content-Type: video/mp4"))
+288
(assert-true (string-contains? str "fake video bytes"))))
+289
+290
(test "multipart body preserves arbitrary file bytes verbatim"
+291
;; Audio tracks and binary container bytes must pass through untouched.
+292
(let* ((raw (bytevector 0 1 2 255 254 253 0 13 10))
+293
(result (build-multipart-body
+294
(list (cons "chat_id" "1"))
+295
(list (list "video" "a.mp4" "video/mp4" raw))))
+296
(body (cdr result)))
+297
(assert-true (bytevector? body))
+298
;; Locate the raw payload inside the body bytevector
+299
(let loop ((i 0) (found #f))
+300
(cond
+301
(found (assert-true #t))
+302
((> (+ i (bytevector-length raw)) (bytevector-length body))
+303
(assert-true #f))
+304
((let match ((j 0))
+305
(cond
+306
((= j (bytevector-length raw)) #t)
+307
((= (bytevector-u8-ref body (+ i j))
+308
(bytevector-u8-ref raw j))
+309
(match (+ j 1)))
+310
(else #f)))
+311
(loop i #t))
+312
(else (loop (+ i 1) #f)))))))
313
314
(run-tests)