Repository navigation
Expand file tree
/
Copy pathbot.ts
More file actions
1911 lines (1830 loc) · 81.2 KB
/
Copy pathbot.ts
File metadata and controls
1911 lines (1830 loc) · 81.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { Database } from 'bun:sqlite'
import { type Api, Bot, type Context, GrammyError, InputFile, type Transformer } from 'grammy'
import type { ApiResponse, InputMediaVideo, Message } from 'grammy/types'
import { existsSync, rmSync, writeFileSync } from 'node:fs'
import { chmod, copyFile, mkdir, mkdtemp, readdir, rename, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { egressProxy, proxyFreeEnv } from './egress'
import { DRAIN, pollWithHandover } from './handover'
import { type Key, t } from './i18n'
import { type Promo, promos } from './promo'
import { scratch } from './scratch'
const env = process.env
const API = env.BOT_API_URL || 'https://api.telegram.org'
// A local Bot API server takes uploads up to 2000 MB, the cloud one 50 MB
const LIMIT = (API.includes('api.telegram.org') ? 50 : 2000) * 1024 * 1024
const COOKIES = env.COOKIES || '/cookies.txt'
const DATA = env.DATA_DIR || './data'
// file:// paths only work with a local Bot API server
const SCRATCH = scratch(API.includes('api.telegram.org') ? '' : undefined)
const TMP = SCRATCH.dir
// Videos downloaded and sent at once (slots), long ones in up to 7 of them by
// default. With 4 (3 for long ones), the 3 long slots were all busy for
// 88-96% of a day's busiest 6 hours, and long videos waited up to about 3
// hours for one (#395). The sites that fill them, adult CDNs at 0.1-1.3 MB/s,
// are slow per connection (Eporner kept its speed in 5 downloads at once,
// #298), so more at once get more done.
const MAX_DOWNLOADS = Number(env.MAX_DOWNLOADS || 8)
// A minute, in ms, of the download time limits, of how old saved info may
// get, of how often a download's files are weighed and of the waits between
// self-update tries (bot.e2e.test.ts shortens it)
const MINUTE = Number(env.MINUTE_MS) || 60_000
// how long a stopped yt-dlp run gets before it's over (bot.e2e.test.ts halves it)
const GRACE = Number(env.GRACE_MS) || 5_000
// How long after a guest message was sent the bot may wait for yt-dlp to find
// a video before it answers (guest()). Telegram expires a guest query that
// has no answer ("query is too old") but doesn't say when; a guest-mode bot
// in production answers its long runs at 45 s
// (github.com/aspalagin/openclaw-telegram-guest-mode). In 48 h of job lines,
// 4 of 1,732 pages without a video took over 30 s to get and run their -J.
const GUEST_WAIT = Number(env.GUEST_WAIT_MS) || 30_000
// bot.e2e.test.ts sets GUEST_GATES to a folder: then that wait ends when the
// test lets it, as $GUEST_GATES/<the message's guest_query_id> appears
// (<guest_query_id>.held says it's waiting), instead of after GUEST_WAIT,
// so no test races that timer (deadline())
const GUEST_GATES = env.GUEST_GATES
// Only formats whose every request goes through the proxy: yt-dlp's own HTTP
// (HLS, DASH, websockets) and ffmpeg's http(s), which yt-dlp hands the proxy
// to. rtmp, rtsp and mms would connect directly.
const PROTOCOLS = `[protocol~='^(https?|m3u8(_native)?|http_dash_segments(_generator)?|f4m|ism|websocket_frag)$']`
// ffmpeg's http input lets a response turn into rtp/udp/rtsp (an SDP or a
// playlist pointing there), which would skip the proxy: keep it to these.
// Local files (merger, thumbnail) already default to file,crypto,data.
const FFMPEG_PROTOCOLS = 'http,https,tls,tcp,httpproxy,crypto,data'
// tcp and tls stay listed for http(s), but some demuxers (IMF, from an XML
// response) open such URLs on their own, direct: only HLS and its segments
const FFMPEG_FORMATS = 'hls,mpegts,mov,matroska,aac,mp3,flv,ac3,eac3,webvtt'
export const YTDLP = [
'--no-playlist',
'--playlist-items', '1',
'--no-warnings',
'--js-runtimes', 'bun',
'-f', `bv*${PROTOCOLS}+ba${PROTOCOLS}/b${PROTOCOLS}`,
'--downloader-args',
`ffmpeg_i:-protocol_whitelist ${FFMPEG_PROTOCOLS} -format_whitelist ${FFMPEG_FORMATS}`,
// h264/aac plays inline on every Telegram client
'-S', 'vcodec:h264,res:1080,acodec:m4a',
'--merge-output-format', 'mp4',
]
type Kind = 'video' | 'animation' | 'audio' | 'document'
type File = { kind: Kind; file_id: string; title: string; url: string }
type Opts = Parameters<Context['api']['sendVideo']>[2] &
Parameters<Context['api']['sendAudio']>[2]
// Where a file goes: a chat, replying to a message there, with a promo line
// or button if the user's experiment arm has one (promo.ts); sent = the id
// of the message that carried it; stale = a cached video that went out
// without a size, being downloaded again to replace it: that message and
// its file_id; who = the user who asked, whose jobs take turns with everyone
// else's (limit); uploading = its own job has begun the upload
type Dest = {
chat: number | string
reply?: number
sent?: number
promo?: Promo
stale?: { message: number; file_id: string }
who?: number
uploading?: boolean
}
// Guest replies can't upload, so their files go through this chat first
// (the message is deleted right after, the file_id stays valid)
const STORAGE = env.STORAGE_CHAT || env.ADMIN_ID
// Thrown with the message to show; null = not a video, stay quiet in groups
export class Fail extends Error {
constructor(public key: Key | null, why = key ?? 'not a video') {
super(why)
}
}
// News sites' extractors, which claim plain article URLs (not their video,
// live and embed pages)
const NEWS = /^(?!.*(video|live|embed)).*(article|news)|^(bbc|cnn|spiegel|voxmedia|lenta)$/i
// Whether a yt-dlp error means the page has no video rather than a video
// that failed: the generic extractor on any page (no such site, 403, nothing
// embedded) or a site saying so. Not getting or not parsing the page only
// counts from a news extractor (NYT, Spiegel, Vox on an article without
// media); on a video site it's a real failure. Crashes carry no [extractor]
// tag, so ies = the extractors that claim the link.
export function notVideo(why: string, ies: string[] = []) {
if (/unsupported url|no video|no media|media blocks|\[generic\]/i.test(why))
return true
if (!/unable to (download webpage|extract|find)|extractor error has occurred/i.test(why))
return false
const tag = why.match(/^(?:ERROR: )?\[([^\]]+)\]/)?.[1]
const by = tag ? [tag] : ies
return by.length > 0 && by.every((ie) => NEWS.test(ie))
}
// A stream that hasn't started makes yt-dlp -J fail: YouTube's own reasons
// ("This live event will begin in 5 hours.", "Premieres in 3 hours") and
// other sites' extractor messages ("This livestream has not yet started").
// An offline channel ("The channel is not currently live") stays a failure:
// nothing says a stream is coming.
const UPCOMING = /live event will begin|: premieres?\b|(stream|event|broadcast|space)\b.* not (yet )?started|not started yet|event will start at|scheduled for/i
// 'upcoming' for a yt-dlp error saying the stream hasn't started, else null
export const unstarted = (why: string): Key | null =>
UPCOMING.test(why) ? 'upcoming' : null
// What to say about a stream yt-dlp can't download yet, or null: live now,
// or announced and not started. A stream that just ended (post_live) is
// tried like a video.
export const unready = (v: any): Key | null =>
v.is_live || v.live_status === 'is_live'
? 'live'
: v.live_status === 'is_upcoming'
? 'upcoming'
: null
// The video in what yt-dlp -J printed, or Fail(null) when there is none
export function picked(info: any) {
// yt-dlp prints null when the page had nothing (a t.me text post)
if (!info) throw new Fail(null, 'nothing extracted')
// YouTube playlists, channels and feeds (WL, LL, history) would hand out
// what the logged-in cookies can see; --no-playlist already reduces
// watch?v=…&list=… to the video. Multi-video posts elsewhere stay.
if (info._type === 'playlist' && /^youtube/i.test(info.extractor_key))
throw new Fail(null, 'youtube playlist')
// an article page without media is an empty playlist (BBC, CNN)
if (info._type === 'playlist' && !info.entries?.[0])
throw new Fail(null, 'empty playlist')
return info.entries?.[0] ?? info
}
// A tweet with only text or pictures: shared everywhere, never worth an answer
const TWEET_NO_VIDEO = /\[twitter\].*no video could be found/i
// What to tell the chat about a failed link, null = nothing. "Not a video"
// only gets an answer from whoever asked the bot itself.
export const answer = (key: Key | null, chat: string, called: boolean, why = '') =>
TWEET_NO_VIDEO.test(why) ? null : key ?? (chat === 'private' || called ? 'failed' : null)
// What a guest message that doesn't mention the bot hears about a failed link
// before it has had an answer, null = nothing: what a group message would
// without a status up (guest()). That's not even the restart that dropped the
// link, whose page may have no video.
export const unasked = (key: Key | null, why = '') => {
const told = answer(key, 'guest', false, why)
return told === 'restarting' ? null : told
}
// Calls f in ms, when the answer to guest message id can't wait any longer
// (guest()), or with GUEST_GATES once the test lets it: never in the turn
// that sets it, so what that turn's own promises note comes first. Returns
// what calls it off.
export function deadline(id: string, ms: number, f: () => void) {
if (!GUEST_GATES) {
const timer = setTimeout(f, Math.max(0, ms))
return () => clearTimeout(timer)
}
const gate = join(GUEST_GATES, id)
let on = true
const off = () => {
on = false
rmSync(`${gate}.held`, { force: true })
}
writeFileSync(`${gate}.held`, '')
void (async () => {
do await Bun.sleep(20)
while (on && !existsSync(gate))
if (!on) return
off()
f()
})()
return off
}
// Whether a chat hears that its link waits for a slot, or that a restart
// dropped it. Groups only do when they asked the bot itself: a burst of links
// there would become a burst of replies on top of the videos, and Telegram
// allows a group ~20 messages a minute (a 429 isn't retried).
export const tellQueued = (chat: string, called: boolean) => chat === 'private' || called
// t.me/s/<channel>/<post> is the channel's web preview, which yt-dlp reads
// as a list of the latest posts: the post itself is t.me/<channel>/<post>
const tme = (url: URL) => {
if (/^(t|telegram)\.me$/i.test(url.hostname))
url.pathname = url.pathname.replace(/^\/s\/([^/]+\/\d+)/, '/$1')
return url
}
export function urls(msg: Message) {
const text = msg.text ?? msg.caption ?? ''
const found = (msg.entities ?? msg.caption_entities ?? []).flatMap((e) =>
e.type === 'url'
? [text.slice(e.offset, e.offset + e.length)]
: e.type === 'text_link'
? [e.url]
: [],
)
return [
...new Set(
found.flatMap((u) => {
try {
const url = tme(new URL(/^\w+:\/\//.test(u) ? u : `https://${u}`))
return /^https?:$/.test(url.protocol) && isPublic(url.hostname)
? [url.href]
: []
} catch {
return []
}
}),
),
]
}
// Called on by an @mention
export const summoned = (msg: Message, username: string) =>
new RegExp(`@${username}\\b`, 'i').test(msg.text ?? msg.caption ?? '')
// The bot, as getMe has it, and whether a file (its file_unique_id) is the
// bot's: one it has sent
type Me = { id: number; username: string; mine: (file: string) => boolean }
// The bot's own message: a forward of one, one sent through it (inline), or
// one with a file the bot has sent. A forward that hides the sender's name,
// or a copy a client or another bot makes, comes as the sender's own message
// with nothing of the bot's left but the file, whose file_unique_id is the
// same in every chat. Its videos' captions have the link and the @mention
// that ask it for the video, so its video forwarded into another chat it's
// in would go out there twice.
export const own = (msg: Message, me: Me) => {
const file = msg.video ?? msg.animation ?? msg.audio ?? msg.document
return (
msg.via_bot?.id === me.id ||
(msg.forward_origin?.type === 'user' && msg.forward_origin.sender_user.id === me.id) ||
(!!file && me.mine(file.file_unique_id))
)
}
// Also the links in the message replied to when the bot is summoned. The
// bot's own messages ask for nothing, as the message or the one replied to.
export function links(msg: Message, me: Me) {
if (own(msg, me)) return []
const reply = summoned(msg, me.username) ? msg.reply_to_message : undefined
const theirs = reply && reply.from?.id !== me.id && !own(reply, me) ? urls(reply) : []
return [...new Set([...urls(msg), ...theirs])]
}
// Extractors that claim links without being a site's video extractor: the
// generic one takes any page, the others only refuse (Spotify, Netflix, …)
const NOT_SITES = new Set(['generic', 'DRM', 'Piracy', 'Liability'])
// Each link's own-site yt-dlp extractors. Decided from the URLs alone,
// nothing is fetched: --list-extractors prints each extractor with the URLs
// it matches below it.
export async function claims(urls: string[]) {
const by = new Map<string, string[]>()
if (!urls.length) return by
const { out } = await check.run(() => ytdlp(['--list-extractors', '--', ...urls]))
let ie = ''
for (const line of out.split('\n'))
if (!line.startsWith(' ')) ie = line.replace(/ \(CURRENTLY BROKEN\)$/, '')
else if (!NOT_SITES.has(ie)) by.set(line.trim(), [...(by.get(line.trim()) ?? []), ie])
return by
}
// The links a site's own extractor claims
export async function videos(urls: string[]) {
const by = await claims(urls)
return urls.filter((u) => by.has(u))
}
// What a message asks to download. In groups a link mentioned in passing
// (a bare domain, an article) isn't a request: unless the bot is summoned,
// only links to a site yt-dlp knows count. The same goes for a guest message
// (chat 'guest') in any chat, a private one too: Telegram calls the bot on a
// plain reply to one of its messages as well, mention or not.
export async function requested(
msg: Message,
me: Me,
chat: string,
video = videos,
) {
const found = links(msg, me)
return chat === 'private' || summoned(msg, me.username) || !found.length
? found
: video(found)
}
// First line against links into the docker network: no IP literals (the URL
// parser normalizes 2130706433 and 0x7f.1 to 127.0.0.1), no dotless service
// names, no local-only TLDs. Redirects and public names that resolve inside
// are stopped by the egress proxy yt-dlp goes through (egress.ts).
function isPublic(host: string) {
host = host.replace(/\.$/, '') // "name." is the same docker service
return (
host.includes('.') &&
!host.startsWith('[') &&
!/^[\d.]+$/.test(host) &&
!/\.(localhost|local|internal|lan|home\.arpa)$/i.test(host)
)
}
let proxy: Promise<string> | undefined
// YouTube blocks the server's own address (#415), so its hosts go out through
// Cloudflare WARP: WARP_PROXY is the videodownloadbot-warp service's HTTP proxy
// (host:port, wireproxy from the Dockerfile's warp stage)
export const YOUTUBE = /(^|\.)(youtube\.com|youtu\.be|youtube-nocookie\.com|googlevideo\.com|ytimg\.com)\.?$/i
const warp = (host: string) => (env.WARP_PROXY && YOUTUBE.test(host) ? env.WARP_PROXY : undefined)
// ponytail: in-process daily self-update; pin a version if nightly ever breaks
// The update swaps the binary out, so a run started meanwhile waits for it
// (else spawn fails with "Executable not found"); it takes seconds, and a
// hung one can't hold every download back for long. A run already going may
// still fail on the swap, as the one-file yt-dlp reads its modules from the
// binary's path while it imports them: the daily update swaps it while jobs
// run, and so does a retry after a failed first try (selfUpdate).
let updating: Promise<unknown> = Promise.resolve()
// One try at it: yt-dlp's exit code, or null when the time limit's SIGTERM
// stopped it (PyInstaller's bootloader hands that on to yt-dlp and then dies
// by it too). Another signal (an OOM kill) gives an exit code like any
// failure. cmd and limit differ only in tests.
export const updateOnce = (cmd = ['yt-dlp', '--update-to', 'nightly'], limit = 5 * 60_000) => {
const p = Bun.spawn(cmd, {
env: { ...process.env, ...SCRATCH.env },
stdout: 'inherit',
timeout: limit,
killSignal: 'SIGTERM',
})
const tried = p.exited.then((code) => (p.signalCode === 'SIGTERM' ? null : code))
updating = tried
return tried
}
// The minutes from a failed self-update to its next try. GitHub refuses
// yt-dlp's version check (HTTP 403 "rate limit exceeded") once the address
// the bot shares has used up its hourly API limit, which renews within the
// hour, so the last retry comes just over an hour after the first try (#416).
const UPDATE_RETRIES = [1, 2, 4, 8, 16, 32]
// Updates yt-dlp to the newest nightly: run() is one try, resolving to
// yt-dlp's exit code (null when the time limit stopped it). A failed try goes
// again after each of waits (ms) in turn, until one works, and every failure
// leaves a log line. After one that ran out of time, only the last (longest)
// wait's try is left: a try that's running holds yt-dlp runs back (updating;
// at the start, polling), so that one held them for 5 minutes, and one right
// after might too. yt-dlp gives up on a server that doesn't answer within
// 20 s, so it takes a transfer that trickles or a stuck run. first = the
// first try's result; retries = the whole update, over once a try has worked
// or none is left. pause is Bun.sleep and log console.error but in tests.
export function selfUpdate(
run: () => Promise<number | null>,
waits = UPDATE_RETRIES.map((m) => m * MINUTE),
pause: (ms: number) => Promise<unknown> = Bun.sleep,
log: (line: string) => void = console.error,
) {
const first = run()
const retries = (async () => {
let code = await first
for (let i = 0; code !== 0; i++) {
if (code === null) i = Math.max(i, waits.length - 1)
const ms = waits[i]
const next = ms === undefined ? 'next try tomorrow' : `retry ${i + 1}/${waits.length} in ${+(ms / 60_000).toFixed(1)} min`
log(`yt-dlp update ${code === null ? 'timed out' : `failed (${code})`}, ${next}`)
if (ms === undefined) return
await pause(ms)
code = await run()
}
})().catch(console.error)
return { first, retries }
}
// yt-dlp ran out of time and was stopped
export class Timeout extends Error {}
// The yt-dlp runs going now, each with a way to end its whole group at once:
// an e2e bot whose test process is gone ends them as it exits (below)
const runs = new Set<() => void>()
// Runs yt-dlp, stopped after timeout ms or once signal aborts: then a Timeout
// or the abort's reason is thrown, unless yt-dlp still exits 0 and its output
// closes within 5 s of the stop
async function ytdlp(args: string[], timeout = 30 * 60_000, signal?: AbortSignal) {
// a failed listen isn't kept: the next download tries again
proxy ??= egressProxy(undefined, undefined, warp).catch((e) => {
proxy = undefined
throw e
})
await updating
const via = await proxy
// an abort while it waited (a self-update) won't come again
signal?.throwIfAborted()
// In a process group of its own: yt-dlp hands m3u8 and some HLS downloads
// to ffmpeg, which inherits its pipes and goes on when yt-dlp alone is
// stopped, so a stop stops the whole group
const p = Bun.spawn(['yt-dlp', '--proxy', via, ...args], {
env: { ...proxyFreeEnv(), ...SCRATCH.env },
stdout: 'pipe',
stderr: 'pipe',
detached: true,
})
// the group, or yt-dlp alone if there's no group; none once the run is
// over, whatever timer or abort comes late (its number could lead another
// group by then)
let over = false
const kill = (sig: NodeJS.Signals) => {
if (over) return
try {
process.kill(-p.pid, sig)
} catch {
p.kill(sig)
}
}
const end = () => kill('SIGKILL')
runs.add(end)
// why the run was stopped, the first time: SIGTERM, and GRACE (5 s) later
// the run is over, even if something still holds the pipes
let stopped: Error | undefined
const cutoff = Promise.withResolvers<void>()
const timers: ReturnType<typeof setTimeout>[] = []
const stop = (why: Error) => {
if (stopped) return
stopped = why
kill('SIGTERM')
timers.push(setTimeout(cutoff.resolve, GRACE))
}
timers.push(
setTimeout(
() => stop(new Timeout(`yt-dlp timed out after ${+(timeout / 60_000).toFixed(1)} min`)),
timeout,
),
)
const abort = () => stop(signal!.reason)
signal?.addEventListener('abort', abort)
try {
const run = await Promise.race([
Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]),
cutoff.promise,
])
if (!run || (run[2] && stopped)) throw stopped
const [out, err, code] = run
if (code) throw new Error(err.trim().split('\n').pop() || `yt-dlp ${code}`)
return { out, err }
} finally {
// Then whatever is left of a stopped run's group gets SIGKILL, whether
// it holds the pipes or not (a merge's ffmpeg doesn't). While anything is
// left no other group can have the number, and since pids are handed out
// in turn, neither can one just after the last of it is gone
if (stopped) kill('SIGKILL')
over = true
runs.delete(end)
signal?.removeEventListener('abort', abort)
for (const t of timers) clearTimeout(t)
}
}
type Size = { width: number; height: number }
// The size a player shows a video stream at (ffprobe's stream entry): the
// coded size stretched by the sample aspect ratio, turned by the rotation
export function shown(s: any): Size | undefined {
if (!(s?.width > 0 && s?.height > 0)) return
// "0:1" or "N/A" = unknown, square pixels
const [n, d] = String(s.sample_aspect_ratio).split(':').map(Number)
const width = n > 0 && d > 0 ? Math.round((s.width * n) / d) : s.width
const rotation = Number(
s.side_data_list?.find((x: any) => x.rotation !== undefined)?.rotation ??
s.tags?.rotate ??
0,
)
return Math.abs(rotation) % 180 === 90
? { width: s.height, height: width }
: { width, height: s.height }
}
// Telegram draws the player box from sendVideo's width/height and ignores the
// file's pixel aspect: they come from the file itself, since yt-dlp often has
// none (Instagram's progressive mp4s) or another format's
export async function probe(path: string) {
const p = Bun.spawn(
[
'ffprobe', '-v', 'error', '-select_streams', 'V:0',
'-show_entries', 'stream=width,height,sample_aspect_ratio:stream_side_data=rotation:stream_tags=rotate',
'-of', 'json', path,
],
{ stdout: 'pipe', stderr: 'ignore' },
)
const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited])
if (code) return
try {
return shown(JSON.parse(out).streams?.[0])
} catch {
return
}
}
// The thumbnail's filter: square pixels first (ffmpeg has already applied
// the rotation), then within 320x320
export const THUMB = "scale='iw*sar':ih,scale=320:320:force_original_aspect_ratio=decrease"
// A new file rather than a cached file_id (a local file:// path is new too)
export const fresh = (f: string | InputFile) =>
typeof f !== 'string' || f.startsWith('file:')
// Runs at most max fns at once, long ones at most maxLong of them, the rest
// wait their turn. who = whose fn (a user): one user's fns get at most
// max - 1 turns, and long ones maxLong - 1 (all of them when there's only
// one), so a burst of one user's links always leaves someone else a turn; a
// free turn goes to the first in line among those with the fewest running
// whose fn fits. Fns without a who share no such limit. A fn in line holds
// nothing.
export function limit(max: number, maxLong = max) {
let running = 0
let long = 0
// each user's running fns: all of them and the long ones
const per = new Map<unknown, { all: number; long: number }>()
const mine = (who: unknown) => per.get(who) ?? { all: 0, long: 0 }
const add = (who: unknown, isLong: boolean, n: number) => {
running += n
if (isLong) long += n
const m = mine(who)
const now = { all: m.all + n, long: m.long + (isLong ? n : 0) }
if (now.all) per.set(who, now)
else per.delete(who)
}
// one user's part of n turns
const share = (n: number) => Math.max(1, n - 1)
const fits = (who: unknown, isLong: boolean) => {
const m = mine(who)
return (
running < max &&
!(isLong && long >= maxLong) &&
(who === undefined || (m.all < share(max) && !(isLong && m.long >= share(maxLong))))
)
}
// Nothing in line fits: ended turns go to the ones that do (one ending can
// let in two: one its user's part held back, and one more), so a new fn
// that fits jumps nobody who could run
const queue: { who: unknown; long: boolean; go: () => void; stop: (e: Error) => void }[] = []
let closed: Error | undefined
// Waits for a turn; the returned fn ends it
const take = async (who?: unknown, isLong = false) => {
if (closed && (isLong || !fits(who, isLong))) throw closed
if (fits(who, isLong)) add(who, isLong, 1)
else await new Promise<void>((go, stop) => queue.push({ who, long: isLong, go, stop })) // counted in by whoever hands over
let done = false
return () => {
if (done) return
done = true
add(who, isLong, -1)
for (;;) {
let next = -1
queue.forEach((w, i) => {
if (fits(w.who, w.long) && (next < 0 || mine(w.who).all < mine(queue[next]!.who).all))
next = i
})
if (next < 0) return
const w = queue.splice(next, 1)[0]!
add(w.who, w.long, 1)
w.go()
}
}
}
return {
take,
async run<T>(fn: () => Promise<T>, who?: unknown) {
const done = await take(who)
try {
return await fn()
} finally {
done()
}
},
// whether a new fn of who's would have to wait (once closed, it fails or
// starts at once)
busy: (isLong = false, who?: unknown) => !closed && !fits(who, isLong),
// the fns in line when a new one asks, other users' that wait only for
// their own part included: long ones only count for a short one while
// they could have a turn
waiting: (isLong = false) =>
queue.filter((w) => isLong || !w.long || long < maxLong).length,
// How many of who's fns, long or short, have a turn or wait in line for
// one: with none, a new fn of theirs that has to wait waits for other
// users' fns alone. Fns without a who share no part and count none.
own: (who: unknown) =>
who === undefined ? 0 : mine(who).all + queue.filter((w) => w.who === who).length,
// Fails the fns still waiting with e, and later ones that would have to
// wait or are long (a restart has time for a short one); running ones go on
close(e: Error) {
closed = e
for (const w of queue.splice(0)) w.stop(e)
},
}
}
// What yt-dlp says the formats it picked weigh (video and audio, or the one),
// undefined for one it doesn't say
type Weighed = { filesize?: number | null; filesize_approx?: number | null }
export const sizes = (v: Weighed & { requested_formats?: Weighed[] }) =>
(v.requested_formats ?? [v]).map((f) => f.filesize || f.filesize_approx || undefined)
// Long videos: over 10 minutes. One that doesn't say how long it is counts as
// long too (an HLS/DASH stream or a file of unknown length can take 20-40
// minutes), unless it's from Instagram or an X post, whose reels and GIFs
// usually don't say and come in seconds, or yt-dlp says what all of it weighs
// and that's 100 MB at most (typically under 2 minutes). X is the Twitter key
// alone: TwitterBroadcast and TwitterSpaces are long HLS replays.
export const LONG = 10 * 60
export const SMALL = 100 * 2 ** 20
const QUICK = /^(Instagram\w*|Twitter)$/
export const isLong = (
v: Parameters<typeof sizes>[0] & { duration?: number | null; extractor_key?: string },
) => {
if (v.duration && v.duration > 0) return v.duration > LONG
if (QUICK.test(v.extractor_key ?? '')) return false
const s = sizes(v)
return s.includes(undefined) || s.reduce<number>((all, n) => all + n!, 0) > SMALL
}
// The minutes a job may spend downloading: 45 for a long video, else 30. Some
// sites (adult CDNs) serve one connection at about 1-1.3 MB/s, where the
// biggest file a local Bot API takes (2000 MB) can need about 35 minutes, and
// a flat 30 cut off about 8 downloads a day, mostly from such sites (#298).
// With two or more slots long videos leave short ones a slot, so the longer
// limit mostly holds up other long ones.
export const downloadMinutes = (long: boolean) => (long ? 45 : 30)
// yt-dlp's saved info over a minute old: its media links may have expired
// while the job waited its turn
export const stale = (age: number) => age > MINUTE
// Whether a download from stale saved info that failed gets another try that
// reads the page again, in the time the job has left: when over a minute is
// (none is, after a timeout)
export const reread = (age: number, left: number) => stale(age) && left > MINUTE
// Whether the page read again picks the file(s) the failed try got part of.
// yt-dlp's --continue goes on from whatever file has the name it's about to
// write, a .part by its bytes (an HTTP Range request) and a .ytdl by its
// fragment index, without asking which format that came from, and one
// format's name is the extension alone (v.mp4.part): another format's bytes
// or fragments would be appended to the first one's. The same pick is of the
// same video (a multi-video post's first one may change) and has formats with
// the same ids and encodings: yt-dlp numbers the formats a site gives no id
// of their own (0, 1, … in its order, hls-N), so an id alone could name
// another file. Each format also has the same exact size, or where yt-dlp
// doesn't know it, the same link. On a site yt-dlp has an extractor for,
// that's up to the query, where signatures that change with every read
// usually go: the video's id is the site's, and its format ids and encodings
// tell its files apart. A page it reads with its generic extractor (GENERIC)
// is another matter: the id comes from the page's address, and a <video>
// there has a format with nothing but its index, ext and protocol, so its
// whole link is all that tells another clip apart, the query too (a page may
// pick its clip by it: /stream?clip=…). A link whose host or path changes
// between the reads (a CDN that answers from another server, a signature in
// the path), or on a generic page its query, starts over: safe, but slower.
// A link that stays the same while the file behind it changes can't be told.
const ENCODING = ['format_id', 'ext', 'protocol', 'width', 'height', 'tbr', 'vcodec', 'acodec']
// yt-dlp's generic extractor and the embeds it finds on pages, whose video id
// isn't a site's own: it's the page's (the HTML5 and quoted-HTML ones, by
// _generic_id), the file's name without its extension and query (WordPress
// players, Camtasia) or a page widget's (Elementor), and their formats may
// have nothing but a link (yt-dlp 2026.08.19)
const GENERIC =
/^(Generic|HTML5MediaEmbed|QuotedHTML|ElementorEmbed|WordpressPlaylistEmbed|WordpressMiniAudioPlayerEmbed|CamtasiaEmbed)$/
// a link without its #fragment, and its query unless whole
const link = (url: unknown, whole: boolean) =>
typeof url === 'string' ? url.split(whole ? '#' : /[?#]/)[0] : null
const files = (v: any) => {
const whole = GENERIC.test(v.extractor_key)
return [
v.extractor_key,
v.id,
(v.requested_formats ?? [v]).map((f: any) => [
...ENCODING.map((k) => f[k] ?? null),
f.filesize || link(f.url, whole),
]),
]
}
// Whether yt-dlp downloads a pick from its saved info: a video, not a
// playlist (one a multi-video post's -J may have as its first entry)
const isVideo = (v: any) => (v._type ?? 'video') === 'video'
// What a download from saved info gets: the video picked, alone. yt-dlp loads
// saved info without its entries (--clean-info-json), so from a multi-video
// post's -J (a playlist of its first video, --playlist-items 1) it would have
// nothing to download but the page, which it reads again itself and goes on
// in, --continue, from whatever that picks. A pick that's a playlist goes as
// -J printed it (json): its page is read again either way.
export const saved = (v: any, json: string) => (isVideo(v) ? JSON.stringify(v) : json)
// so the same pick is a video, judged as it's downloaded
export const samePick = (a: any, b: any) =>
isVideo(a) && isVideo(b) && JSON.stringify(files(a)) === JSON.stringify(files(b))
// The key a video's file is cached under, whatever link led to it
const cacheKey = (v: any) => `${v.extractor_key}:${v.id}`
// The slots for downloading and sending videos. Long ones (20-40 min each on
// some sites) take at most MAX_DOWNLOADS - 1 of them (with one slot, that
// one), so long downloads never hold every slot; one user's get one fewer
// when there are more (limit), so another user's long video doesn't wait for
// theirs either. The whole download and upload holds a slot:
// a link flood can't fork unbounded yt-dlp runs or pile finished files up in
// TMP while uploads crawl
const slots = limit(MAX_DOWNLOADS, Math.max(1, MAX_DOWNLOADS - 1))
// yt-dlp -J, which tells a job's lane, runs before it takes a slot
const meta = limit(MAX_DOWNLOADS)
// The links of a group or guest message that doesn't mention the bot are
// checked one yt-dlp run per message (requested()), and a job whose -J fails
// without an extractor's tag checks its link (claims()): a flood of them
// queues instead of forking a process each. A guest message's wait here
// counts against the time its answer may wait (GUEST_WAIT).
const check = limit(2)
// The signal of a progress message, which isn't worth a flood wait. grammY's
// Node typings have an AbortSignal of their own, which a real one fits.
type Signal = NonNullable<Parameters<Api['sendMessage']>[3]>
export const PROGRESS = new AbortController().signal as unknown as Signal
// Telegram's flood control: a 429 says how many seconds until the chat takes
// messages again (parameters.retry_after, 3-40 s in busy groups). The request
// waits that long and goes again, up to retries times and max seconds in all,
// so a burst of links in a group delays the videos instead of losing them.
// Every 429 starts or extends the chat's flood, the one that ends a request's
// retries too. While the chat is in a flood, its requests line up and go one
// at a time: let through together, all but one would hit the limit again and
// use up their retries. Each has the turn through its own retries and passes
// it on when it's done, or once it's been gap seconds in flight: an upload
// can take minutes, and the chat's other requests (and their download slots)
// mustn't wait for it. A PROGRESS request's 429 comes back at once, and the
// chat's line still waits the flood out. getUpdates has grammY's own wait,
// and a chat action would be stale by then. flooded(chat) = whether the chat
// is in a flood or has a line.
export function floodWait(retries = 5, max = 120, gap = 1) {
// chats in a flood: until when, the end of its line, and the requests in it
type Flood = { until: number; last: Promise<void>; size: number }
const floods = new Map<string | symbol, Flood>()
const over = (f: Flood) => f.until <= Date.now() && !f.size
const forget = (chat: string | symbol, f: Flood) => {
if (floods.get(chat) === f && over(f)) floods.delete(chat)
}
// the chat's flood lasts s seconds more at least
const hold = (chat: string | symbol, s: number) => {
const f = floods.get(chat) ?? { until: 0, last: Promise.resolve(), size: 0 }
f.until = Math.max(f.until, Date.now() + s * 1000)
floods.set(chat, f)
void Bun.sleep(f.until - Date.now()).then(() => forget(chat, f))
}
// waits for a turn in the chat's line; returns what passes it on
const line = async (chat: string | symbol, f: Flood) => {
const ahead = f.last
let next = () => {}
f.last = new Promise<void>((go) => (next = go))
f.size++
await ahead
return () => {
f.size--
next()
forget(chat, f)
}
}
const wait: Transformer = async (prev, method, payload, signal) => {
if (method === 'getUpdates' || method === 'sendChatAction')
return prev(method, payload, signal)
const id = (payload as { chat_id?: number | string } | undefined)?.chat_id
// a request without a chat (to an inline message) waits on its own
const chat = id === undefined ? Symbol() : String(id)
// the seconds a 429 asks to wait, unless it's longer than max
const after = (res: ApiResponse<unknown>) => {
const s = res.ok || res.error_code !== 429 ? undefined : res.parameters?.retry_after
return s === undefined || s > max ? undefined : s
}
if (signal === PROGRESS) {
const res = await prev(method, payload, signal)
const s = after(res)
if (s !== undefined) hold(chat, s)
return res
}
let tries = 0
let waited = 0
// passes the chat's turn on, while this request has it
let pass: (() => void) | undefined
try {
for (;;) {
const f = floods.get(chat)
if (f && !over(f)) {
pass ??= await line(chat, f)
// another 429 may push the wait out while this one sleeps
while (f.until > Date.now()) await Bun.sleep(f.until - Date.now())
}
const mine = pass
const slow = mine
? setTimeout(() => {
if (pass !== mine) return
pass = undefined
mine()
}, gap * 1000)
: undefined
const res = await prev(method, payload, signal).finally(() => clearTimeout(slow))
const s = after(res)
if (s !== undefined) hold(chat, s)
if (s === undefined || (waited += s) > max || ++tries > retries) return res
console.error('flood', method, `retry after ${s}`, `${tries}/${retries}`)
}
} finally {
pass?.()
}
}
return Object.assign(wait, {
flooded: (chat: number | string) => {
const f = floods.get(String(chat))
return !!f && !over(f)
},
})
}
// A progress status isn't worth a flood wait: waiting it out would hold the
// job's download slot and use up the chat's messages ahead of the video. It's
// skipped while the chat is in a flood, and a 429 on it only starts one.
// show(signal) shows it.
export async function progress(flooded: boolean, show: (signal: Signal) => Promise<unknown>) {
if (flooded) return
await show(PROGRESS).catch((e) => {
if (!(e instanceof GrammyError && e.error_code === 429)) throw e
})
}
// Deletes a message the bot sent to STORAGE (a guest upload sits in the
// admin's DM until then): through a flood wait floodWait gives up on (over
// its max) and network or server errors, tries times in all. What it still
// can't delete is logged, not thrown. pause is Bun.sleep but in tests.
export async function unsend(
del: () => Promise<unknown>,
tries = 6,
pause: (ms: number) => Promise<unknown> = Bun.sleep,
) {
for (let i = 1; ; i++) {
try {
return void (await del())
} catch (e) {
const g = e instanceof GrammyError ? e : undefined
if ((g && g.error_code !== 429 && g.error_code < 500) || i >= tries)
return console.error('delete failed', (e as Error).message)
console.error('delete retry', i, (e as Error).message)
await pause((g?.parameters.retry_after ?? 5 * 2 ** i) * 1000)
}
}
}
// Sends a file to dest.chat as kind; returns the kind it went as, its file_id
// and the message. kept hears the file_unique_id of every file that goes out.
export const sender = (api: Api, kept: (file: string) => void = () => {}) =>
async function send(
dest: Dest,
kind: Kind,
file: () => string | InputFile,
opts: Opts,
): Promise<[Kind, string, Message]> {
const { chat } = dest
let m: Message
try {
m =
kind === 'video'
? await api.sendVideo(chat, file(), opts)
: kind === 'animation'
? await api.sendAnimation(chat, file(), opts)
: kind === 'audio'
? await api.sendAudio(chat, file(), opts)
: await api.sendDocument(chat, file(), opts)
} catch (e) {
// a fresh file Telegram won't take as media still goes as a file; a
// cached file_id doesn't (a local file:// path is fresh). A 429 is no
// reason to: it's about the chat, not the file, and floodWait has
// already waited it out as long as it should.
if (
kind === 'document' ||
!fresh(file()) ||
(e instanceof GrammyError && e.error_code === 429)
)
throw e
m = await api.sendDocument(chat, file(), opts)
}
const media = m.video ?? m.animation ?? m.audio ?? m.document
dest.sent = m.message_id
if (!media) throw new Error('no file in sent message')
kept(media.file_unique_id)
const sent = m.video
? 'video'
: m.animation
? 'animation'
: m.audio
? 'audio'
: 'document'
return [sent, media.file_id, m]
}
// The live cookie jar sits on the data volume, so the session cookies Google
// rotates survive restarts. COOKIES only seeds it, again whenever it changes.
const JAR = join(DATA, 'cookies.txt')
const SEED = join(DATA, 'cookies.seed.txt')
async function seedJar() {
if (!existsSync(COOKIES)) return
const seed = await Bun.file(COOKIES).text()
if (existsSync(SEED) && (await Bun.file(SEED).text()) === seed) return
await Bun.write(JAR, seed)
await Bun.write(SEED, seed)
}
// yt-dlp gets a private copy (parallel runs would clobber one file)...
const taken = new Map<string, number>()
async function jar(dir: string) {
if (!existsSync(JAR)) return []
taken.set(dir, (await stat(JAR)).mtimeMs)
await copyFile(JAR, join(dir, 'cookies.txt'))
// the job folder may be readable by the Bot API server (scratch.ts), the session isn't
await chmod(join(dir, 'cookies.txt'), 0o600)
return ['--cookies', join(dir, 'cookies.txt')]
}
// ...and its refreshed cookies go back, unless YouTube dropped the login or
// another run saved newer ones meanwhile
async function saveJar(dir: string) {
const since = taken.get(dir)
taken.delete(dir)
const copy = join(dir, 'cookies.txt')
if (!existsSync(copy) || !(await Bun.file(copy).text()).includes('\tLOGIN_INFO\t'))
return
if (since !== undefined && (await stat(JAR)).mtimeMs !== since) return
await copyFile(copy, `${JAR}.new`)
await rename(`${JAR}.new`, JAR)
}
// First-seen chat and user ids, for stats.borodutch.com (served on STATS_PORT
// inside the Docker network only, no domain): totals and new ones per UTC day,
// plus the audience: chats by type and by days since their last update, and
// private chats that blocked the bot (counts only, never ids)
export const WINDOWS = [7, 30, 90, 180] as const
export function census(db: Database) {
const tables = ['chats', 'users'] as const
for (const t of tables)
db.run(`CREATE TABLE IF NOT EXISTS ${t} (id INTEGER PRIMARY KEY, created INTEGER NOT NULL)`)
for (const col of ['seen INTEGER', 'type TEXT', 'gone INTEGER NOT NULL DEFAULT 0'])
try {
db.run(`ALTER TABLE chats ADD COLUMN ${col}`)
} catch {} // already there
const chat = db.query(
`INSERT INTO chats (id, created, seen, type) VALUES (?1, unixepoch(), unixepoch(), ?2)
ON CONFLICT (id) DO UPDATE SET seen = excluded.seen, type = coalesce(excluded.type, type), gone = 0`,
)
const user = db.query('INSERT OR IGNORE INTO users VALUES (?, unixepoch())')
const member = db.query(
`INSERT INTO chats (id, created, type, gone) VALUES (?1, unixepoch(), 'private', ?2)
ON CONFLICT (id) DO UPDATE SET gone = excluded.gone`,
)
const daily = (t: string) =>
db
.query(`SELECT date(created, 'unixepoch') AS date, count(*) AS count FROM ${t} GROUP BY 1 ORDER BY 1`)
.all() as { date: string; count: number }[]
const total = (t: string) =>
(db.query(`SELECT count(*) AS n FROM ${t}`).get() as { n: number }).n
const recent = WINDOWS.map(
(d) => `sum(gone = 0 AND unixepoch() - coalesce(seen, created) < ${d * 86400}) AS d${d}`,
).join(', ')
const audience = () =>
Object.fromEntries(
(
db
.query(
`SELECT coalesce(type, CASE WHEN id > 0 THEN 'private' WHEN id < -1000000000000
THEN 'supergroup_or_channel' ELSE 'group' END) AS kind,
count(*) AS ever, sum(gone) AS unreachable, ${recent}
FROM chats GROUP BY 1`,
)
.all() as ({ kind: string } & Record<string, number>)[]
).map(({ kind, ...n }) => [kind, n]),
)
return {
// a chat's last activity: any update from it except the bot's own membership changes