hardik1731 Claude Sonnet 5 commited on
Commit
bcbbd18
·
1 Parent(s): 26511c2

Add /gifer: convert a replied-to GIF into a Telegram video sticker

Browse files

Reply to any GIF with /gifer and it's converted to a WEBM/VP9 video
sticker (Telegram's only animated-sticker path a raster GIF can
actually reach — TGS is vector-only) and added to the user's pack,
creating one first if needed.

Encoding runs through ffmpeg (new Dockerfile dependency), retrying
down a CRF/fps ladder until the output fits Telegram's 256KB video
sticker cap, and reports a real failure rather than delivering
something Telegram would reject if nothing fits. Live-tested against
real GIFs before writing any app code: a moderately complex 400x400
animated GIF encoded to ~120KB at good quality, comfortably inside
budget.

Two real bugs caught by live end-to-end testing (not just mocked unit
tests) against the actual running app with real ffmpeg:
- Telegram's docs state video/animated stickers can't be uploaded via
HTTP URL (unlike static ones) — the relay Worker now fetches the
converted bytes itself and re-uploads via multipart/form-data instead
of just handing Telegram a URL.
- An input-side `-t 3` trimmed how much of the source ffmpeg READ, but
the fps filter could then round that up to one extra frame in the
output, overshooting the 3s cap (3.067s observed). Moved -t to an
output-side option, which cuts the encoded stream at exactly 3.0s.

Converted bytes are cached in a new TelegramConvertedSticker table
(claimed and deleted on first fetch, like every outbox table) since —
unlike every other sticker this app delivers — they're a one-off, not
regenerable from a short URL parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Dockerfile CHANGED
@@ -2,6 +2,13 @@ FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
 
 
 
 
 
 
 
5
  COPY backend/requirements.txt backend/requirements.txt
6
  RUN pip install --no-cache-dir -r backend/requirements.txt
7
 
 
2
 
3
  WORKDIR /app
4
 
5
+ # ffmpeg — GIF-to-video-sticker conversion (notify/gif_sticker.py, the
6
+ # /gifer command). Only the libvpx-vp9 encode path is actually used; the
7
+ # Debian package pulls in a full build rather than a minimal one, which is
8
+ # an acceptable image-size tradeoff for not having to compile it ourselves.
9
+ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
  COPY backend/requirements.txt backend/requirements.txt
13
  RUN pip install --no-cache-dir -r backend/requirements.txt
14
 
backend/db/models.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
  import datetime
4
  import uuid
5
 
6
- from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
7
  from sqlalchemy.orm import Mapped, mapped_column, relationship
8
 
9
  from db.session import Base
@@ -252,6 +252,26 @@ class TelegramOutboxStickerAdd(Base):
252
  created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), default=_now)
253
 
254
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  class TelegramAiCooldown(Base):
256
  """Per-DM cooldown gate for the natural-language Q&A path (see
257
  notify/telegram_ai.py) — the only bot feature that costs a real
 
3
  import datetime
4
  import uuid
5
 
6
+ from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, JSON, LargeBinary, String, Text, UniqueConstraint
7
  from sqlalchemy.orm import Mapped, mapped_column, relationship
8
 
9
  from db.session import Base
 
252
  created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), default=_now)
253
 
254
 
255
+ class TelegramConvertedSticker(Base):
256
+ """One-shot storage for a GIF converted to a video sticker (the
257
+ /gifer command, notify/gif_sticker.py) — unlike every other sticker
258
+ delivery in this app (generated: deterministic from a prompt+seed;
259
+ copied: an existing Telegram file_id), a converted GIF's output
260
+ bytes aren't re-derivable from a short URL param, so they have to
261
+ sit somewhere until Telegram's relay Worker fetches them. Claimed
262
+ exactly like the outbox tables: GET /telegram/sticker.webm (main.py)
263
+ deletes the row after serving it once. Telegram also further
264
+ requires video stickers to be uploaded via multipart/form-data
265
+ rather than passed as a URL directly (unlike static ones) — see the
266
+ Worker's uploadStickerBytes — so this row is fetched by the Worker
267
+ itself and re-uploaded, not handed to Telegram as a URL."""
268
+ __tablename__ = "telegram_converted_stickers"
269
+
270
+ token: Mapped[str] = mapped_column(String(64), primary_key=True)
271
+ webm_bytes: Mapped[bytes] = mapped_column(LargeBinary)
272
+ created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), default=_now)
273
+
274
+
275
  class TelegramAiCooldown(Base):
276
  """Per-DM cooldown gate for the natural-language Q&A path (see
277
  notify/telegram_ai.py) — the only bot feature that costs a real
backend/main.py CHANGED
@@ -71,6 +71,7 @@ from db.models import (
71
  ScannerSymbol,
72
  Signal,
73
  SignalSubscription,
 
74
  TelegramOutboxAudio,
75
  TelegramOutboxButtonMessage,
76
  TelegramOutboxMessage,
@@ -83,7 +84,7 @@ from db.models import (
83
  from db.session import get_session, init_db
84
  from indicators.technical import compute_indicators
85
  from news.sentiment import get_news_sentiment, fetch_article_text, is_served_article_url
86
- from notify import charts, image_gen, md, telegram_commands, voice, witty
87
  from notify.email import send_email
88
  from notify.telegram import (
89
  TELEGRAM_BOT_TOKEN,
@@ -1069,6 +1070,10 @@ class TelegramWebhookBody(BaseModel):
1069
  # The text/caption of whatever this message replies to, from ANY
1070
  # sender — not just the bot or a sticker (separate fields above).
1071
  replied_message_text: Optional[str] = None
 
 
 
 
1072
 
1073
 
1074
  @app.post("/telegram/webhook")
@@ -1108,7 +1113,7 @@ async def telegram_webhook(
1108
  body.voice_file_b64, body.document_file_b64, body.document_mime_type,
1109
  body.photo_file_b64, body.caption,
1110
  body.replied_sticker_file_id, body.replied_sticker_is_animated, body.replied_sticker_is_video,
1111
- body.message_id, body.replied_message_text,
1112
  )
1113
  await session.commit()
1114
  if not deliver_now:
@@ -1381,6 +1386,37 @@ async def telegram_sticker_photo(source_url: str = Query(...), exp: int = Query(
1381
  return Response(content=sticker, media_type="image/webp")
1382
 
1383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1384
  def _signal_dict(s: Signal) -> dict:
1385
  return {
1386
  "id": s.id, "symbol": s.symbol, "direction": s.direction, "timeframe": s.timeframe,
 
71
  ScannerSymbol,
72
  Signal,
73
  SignalSubscription,
74
+ TelegramConvertedSticker,
75
  TelegramOutboxAudio,
76
  TelegramOutboxButtonMessage,
77
  TelegramOutboxMessage,
 
84
  from db.session import get_session, init_db
85
  from indicators.technical import compute_indicators
86
  from news.sentiment import get_news_sentiment, fetch_article_text, is_served_article_url
87
+ from notify import charts, gif_sticker, image_gen, md, telegram_commands, voice, witty
88
  from notify.email import send_email
89
  from notify.telegram import (
90
  TELEGRAM_BOT_TOKEN,
 
1070
  # The text/caption of whatever this message replies to, from ANY
1071
  # sender — not just the bot or a sticker (separate fields above).
1072
  replied_message_text: Optional[str] = None
1073
+ # Present only when this message replies to a GIF (Telegram's
1074
+ # `animation` type) — the /gifer command's input, Worker-downloaded
1075
+ # and base64-encoded the same way voice/document/photo bytes are.
1076
+ replied_animation_file_b64: Optional[str] = None
1077
 
1078
 
1079
  @app.post("/telegram/webhook")
 
1113
  body.voice_file_b64, body.document_file_b64, body.document_mime_type,
1114
  body.photo_file_b64, body.caption,
1115
  body.replied_sticker_file_id, body.replied_sticker_is_animated, body.replied_sticker_is_video,
1116
+ body.message_id, body.replied_message_text, body.replied_animation_file_b64,
1117
  )
1118
  await session.commit()
1119
  if not deliver_now:
 
1386
  return Response(content=sticker, media_type="image/webp")
1387
 
1388
 
1389
+ @app.get("/telegram/sticker.webm")
1390
+ async def telegram_sticker_webm(
1391
+ token: str = Query(...), exp: int = Query(...), sig: str = Query(...),
1392
+ session: AsyncSession = Depends(get_session),
1393
+ ):
1394
+ """Fetched by the relay Worker itself (NOT Telegram directly — see
1395
+ the Worker's uploadStickerBytes: video stickers can't be handed to
1396
+ Telegram as a URL, only via multipart upload, unlike every other
1397
+ sticker this app delivers). Unlike sticker.webp/sticker-photo.webp
1398
+ above, this can't regenerate its content on demand — a converted
1399
+ GIF's bytes are a one-off, not derivable from a short URL param — so
1400
+ they're looked up from TelegramConvertedSticker (written by
1401
+ /gifer, notify/telegram_commands.py::cmd_gifer) and the row is
1402
+ deleted once served, the same claim-by-delete contract as every
1403
+ outbox table in this app."""
1404
+ if not _TELEGRAM_RELAY_SECRET:
1405
+ raise HTTPException(status_code=403, detail="Not configured")
1406
+ if int(time.time()) > exp:
1407
+ raise HTTPException(status_code=403, detail="Expired sticker link")
1408
+ if not hmac.compare_digest(sig, gif_sticker.sign_gif_sticker_token(token, exp)):
1409
+ raise HTTPException(status_code=403, detail="Invalid signature")
1410
+
1411
+ row = await session.get(TelegramConvertedSticker, token)
1412
+ if not row:
1413
+ raise HTTPException(status_code=404, detail="Sticker not found or already claimed")
1414
+ webm_bytes = row.webm_bytes
1415
+ await session.delete(row)
1416
+ await session.commit()
1417
+ return Response(content=webm_bytes, media_type="video/webm")
1418
+
1419
+
1420
  def _signal_dict(s: Signal) -> dict:
1421
  return {
1422
  "id": s.id, "symbol": s.symbol, "direction": s.direction, "timeframe": s.timeframe,
backend/notify/gif_sticker.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GIF -> Telegram video sticker conversion, powering the /gifer command
2
+ (notify/telegram_commands.py). Telegram's "video sticker" format is the
3
+ only way to turn a raster GIF into an animated sticker at all — the other
4
+ animated format, "animated" (.TGS), is a vector Lottie animation with no
5
+ meaningful GIF-to-TGS conversion path. Video stickers must be WEBM/VP9,
6
+ silent, at most 3 seconds, with one side exactly 512px, and — the binding
7
+ constraint — at most 256KB.
8
+
9
+ Live-tested against real GIFs before building this (not just trusting
10
+ Telegram's docs): a moderately complex 400x400 animated GIF (continuous
11
+ color, no flat regions) encoded to 117KB at good quality (CRF 30) once
12
+ scaled to 512px/15fps/3s — comfortably inside budget. Only adversarial
13
+ content (literal random noise, far outside what any real GIF's own
14
+ 256-color palette can produce) fails to fit even at the lowest quality
15
+ tried here — hence the honest "couldn't convert" failure path below
16
+ rather than ever silently delivering something over Telegram's limit
17
+ (which it would just reject).
18
+
19
+ ffmpeg is shelled out to directly (see the Dockerfile) rather than using
20
+ a Python video library — there's no meaningfully-maintained pure-Python
21
+ GIF->VP9/WEBM encoder, and ffmpeg is the standard tool for this exact job.
22
+
23
+ Unlike every other sticker delivery in this app (generated: deterministic
24
+ from a prompt+seed; copied: an existing Telegram file_id), a converted
25
+ GIF's output bytes are a one-off — not re-derivable from a short URL
26
+ param — so they're cached in TelegramConvertedSticker (db/models.py)
27
+ until the relay Worker fetches and claims them via GET
28
+ /telegram/sticker.webm. Telegram also requires video stickers to be
29
+ uploaded via multipart/form-data rather than passed as a URL (unlike
30
+ static ones) — see the Worker's uploadStickerBytes — so this row is
31
+ fetched by the Worker itself and re-uploaded, not handed to Telegram
32
+ directly."""
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import hashlib
37
+ import hmac
38
+ import logging
39
+ import os
40
+ import tempfile
41
+ from typing import Optional
42
+
43
+ logger = logging.getLogger("pulsar.notify.gif_sticker")
44
+
45
+ GIF_STICKER_TOKEN_TTL_SECONDS = 300 # same reasoning as image_gen.py's STICKER_TOKEN_TTL_SECONDS
46
+ _TELEGRAM_RELAY_SECRET = os.getenv("TELEGRAM_RELAY_SECRET", "")
47
+
48
+ _MAX_OUTPUT_BYTES = 256 * 1024 # Telegram's hard cap for video stickers
49
+ _MAX_DURATION_SECONDS = 3 # Telegram's hard cap for video stickers
50
+ _FFMPEG_BIN = os.getenv("FFMPEG_BIN", "ffmpeg")
51
+ _ENCODE_TIMEOUT_SECONDS = 20 # guards against a malformed/corrupt GIF hanging the encoder
52
+
53
+ # Tried in order at 15fps; the first one to land under _MAX_OUTPUT_BYTES
54
+ # wins. Real GIFs (already palette-limited to 256 colors, unlike true
55
+ # video) fit comfortably well before the end of this ladder — confirmed
56
+ # live, see this module's docstring. The final fps=10 pass is a last-ditch
57
+ # attempt for genuinely high-entropy content the 15fps ladder couldn't fit.
58
+ _CRF_LADDER_15FPS = [28, 32, 36, 40, 45, 50, 55, 60]
59
+ _CRF_LADDER_10FPS_FALLBACK = [55, 63]
60
+
61
+ # Telegram requires one side to be exactly 512px; the other scales down
62
+ # proportionally. -2 keeps the scaled dimension even (required by yuv420p
63
+ # chroma subsampling) — ffmpeg's scale filter rounds down to the nearest
64
+ # even number when given -2 as a dimension.
65
+ _SCALE_FILTER = "scale='if(gte(iw,ih),512,-2)':'if(gte(iw,ih),-2,512)'"
66
+
67
+
68
+ def sign_gif_sticker_token(token: str, exp: int) -> str:
69
+ msg = f"{token}|{exp}".encode()
70
+ return hmac.new(_TELEGRAM_RELAY_SECRET.encode(), msg, hashlib.sha256).hexdigest()
71
+
72
+
73
+ async def _run_ffmpeg(input_path: str, output_path: str, fps: int, crf: int) -> bool:
74
+ """Runs one encode attempt; returns whether ffmpeg exited 0. Doesn't
75
+ itself check the output size — the caller does, since a successful
76
+ encode over budget just means "try a lower-quality pass next", not a
77
+ real failure."""
78
+ # -t as an OUTPUT option (after -i), not an input one — an input-side
79
+ # -t trims how much of the source is READ, but the fps filter can then
80
+ # round that up to an extra whole frame in the output, overshooting
81
+ # the 3s cap (confirmed live: input-side -t produced a 3.067s file for
82
+ # a fps=15 encode). Output-side -t cuts the encoded stream itself at
83
+ # exactly 3.0s regardless of frame boundaries.
84
+ args = [
85
+ _FFMPEG_BIN, "-y", "-i", input_path,
86
+ "-vf", f"{_SCALE_FILTER},fps={fps}",
87
+ "-pix_fmt", "yuv420p",
88
+ "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", str(crf),
89
+ "-an", "-t", str(_MAX_DURATION_SECONDS), "-f", "webm", output_path,
90
+ ]
91
+ try:
92
+ proc = await asyncio.create_subprocess_exec(
93
+ *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
94
+ )
95
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=_ENCODE_TIMEOUT_SECONDS)
96
+ except (asyncio.TimeoutError, FileNotFoundError, OSError):
97
+ logger.warning("ffmpeg encode failed to run (crf=%d fps=%d)", crf, fps, exc_info=True)
98
+ return False
99
+ if proc.returncode != 0:
100
+ logger.warning("ffmpeg exited %s (crf=%d fps=%d): %s", proc.returncode, crf, fps, stderr.decode(errors="replace")[-500:])
101
+ return False
102
+ return True
103
+
104
+
105
+ async def convert_gif_to_webm_sticker(gif_bytes: bytes) -> Optional[bytes]:
106
+ """Returns WEBM bytes fitting Telegram's video-sticker limits (3s,
107
+ 512px one side, <=256KB), or None if no quality level tried gets
108
+ under budget (or ffmpeg itself isn't available/fails outright) —
109
+ callers should show a real "couldn't convert this one" reply rather
110
+ than attempting delivery, since Telegram would just reject an
111
+ oversized upload anyway."""
112
+ if not gif_bytes:
113
+ return None
114
+
115
+ with tempfile.TemporaryDirectory() as tmpdir:
116
+ input_path = os.path.join(tmpdir, "input.gif")
117
+ output_path = os.path.join(tmpdir, "output.webm")
118
+ with open(input_path, "wb") as f:
119
+ f.write(gif_bytes)
120
+
121
+ for fps, ladder in ((15, _CRF_LADDER_15FPS), (10, _CRF_LADDER_10FPS_FALLBACK)):
122
+ for crf in ladder:
123
+ if not await _run_ffmpeg(input_path, output_path, fps, crf):
124
+ return None # a real ffmpeg failure, not just "too big" — no point retrying
125
+ size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
126
+ if 0 < size <= _MAX_OUTPUT_BYTES:
127
+ with open(output_path, "rb") as f:
128
+ return f.read()
129
+
130
+ logger.warning("gif->webm conversion never fit under %d bytes even at lowest quality", _MAX_OUTPUT_BYTES)
131
+ return None
backend/notify/telegram.py CHANGED
@@ -104,6 +104,7 @@ async def handle_incoming_update(
104
  replied_sticker_is_video: bool = False,
105
  message_id: int | None = None,
106
  replied_message_text: str | None = None,
 
107
  ) -> bool:
108
  """Processes one Telegram message relayed by the Worker. /start <code>
109
  (the linking handshake) and bare /start (welcome message) stay here
@@ -173,6 +174,14 @@ async def handle_incoming_update(
173
  are separate fields above) — lets "@PulsarBot what does this mean?"
174
  as a reply to someone else's message actually see what "this" is.
175
 
 
 
 
 
 
 
 
 
176
  Returns True if whatever this queued should be claimed and returned
177
  synchronously in the webhook's own HTTP response (see main.py's
178
  /telegram/webhook) rather than left for the async outbox poll — see
@@ -236,5 +245,5 @@ async def handle_incoming_update(
236
  from notify import telegram_commands
237
  return await telegram_commands.dispatch(
238
  session, chat_id, text, from_id, chat_type, reply_to_bot_username, is_voice_input, image_data_url,
239
- replied_sticker, message_id, replied_message_text,
240
  )
 
104
  replied_sticker_is_video: bool = False,
105
  message_id: int | None = None,
106
  replied_message_text: str | None = None,
107
+ replied_animation_file_b64: str | None = None,
108
  ) -> bool:
109
  """Processes one Telegram message relayed by the Worker. /start <code>
110
  (the linking handshake) and bare /start (welcome message) stay here
 
174
  are separate fields above) — lets "@PulsarBot what does this mean?"
175
  as a reply to someone else's message actually see what "this" is.
176
 
177
+ replied_animation_file_b64 is set only when this message replies to
178
+ a GIF (Telegram's `animation` message type) — the /gifer command's
179
+ input, already downloaded and base64-encoded by the Worker the same
180
+ way voice/document/photo bytes are (same reason: this backend can't
181
+ fetch it itself). Unlike those three, `text` stays as-is here (it's
182
+ the literal "/gifer" command text) rather than being replaced by
183
+ anything derived from the bytes.
184
+
185
  Returns True if whatever this queued should be claimed and returned
186
  synchronously in the webhook's own HTTP response (see main.py's
187
  /telegram/webhook) rather than left for the async outbox poll — see
 
245
  from notify import telegram_commands
246
  return await telegram_commands.dispatch(
247
  session, chat_id, text, from_id, chat_type, reply_to_bot_username, is_voice_input, image_data_url,
248
+ replied_sticker, message_id, replied_message_text, replied_animation_file_b64,
249
  )
backend/notify/telegram_commands.py CHANGED
@@ -791,6 +791,74 @@ async def cmd_kang(session: AsyncSession, chat_id: str, from_id: Optional[str],
791
  return True
792
 
793
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794
  async def cmd_space(session: AsyncSession, chat_id: str, args: str, from_id: Optional[str], chat_type: str) -> bool:
795
  from notify import space
796
  apod = await space.get_apod()
@@ -1097,6 +1165,7 @@ async def dispatch(
1097
  replied_sticker: Optional[dict] = None,
1098
  message_id: Optional[int] = None,
1099
  replied_message_text: Optional[str] = None,
 
1100
  ) -> bool:
1101
  """Returns True if the caller (main.py's webhook route) should
1102
  synchronously claim and return whatever this command just queued,
@@ -1130,6 +1199,8 @@ async def dispatch(
1130
  # reason: different inputs than the general case).
1131
  if cmd == "/kang":
1132
  return await cmd_kang(session, chat_id, from_id, replied_sticker)
 
 
1133
 
1134
  handler = _COMMANDS.get(cmd)
1135
  if handler is not None:
 
791
  return True
792
 
793
 
794
+ async def cmd_gifer(
795
+ session: AsyncSession, chat_id: str, from_id: Optional[str], replied_animation_file_b64: Optional[str],
796
+ ) -> bool:
797
+ """Direct GIF-to-video-sticker command — reply to a GIF with /gifer
798
+ and it's converted and added to your pack. Special-cased in
799
+ dispatch() below rather than living in _COMMANDS, same reason as
800
+ /kang: needs replied_animation_file_b64, which no other command
801
+ handler takes.
802
+
803
+ Unlike /kang (which just reuses an existing sticker's file_id, no
804
+ conversion needed), this one does real work — ffmpeg encoding — so
805
+ it's noticeably slower than every other command here. Still handled
806
+ synchronously within this request rather than deferred to the outbox
807
+ poll: the conversion has already happened by the time this function
808
+ would return either way, so there's no reason to make the user wait
809
+ on ANOTHER outbox poll cycle on top of that."""
810
+ if not replied_animation_file_b64:
811
+ await send_telegram_message(session, chat_id, md.escape("Reply to a GIF with /gifer to add it to your pack as a video sticker."))
812
+ return True
813
+ if not from_id:
814
+ await send_telegram_message(session, chat_id, md.escape("Couldn't identify who's asking."))
815
+ return True
816
+
817
+ import base64
818
+ try:
819
+ gif_bytes = base64.b64decode(replied_animation_file_b64)
820
+ except Exception:
821
+ gif_bytes = b""
822
+ if not gif_bytes:
823
+ await send_telegram_message(session, chat_id, md.escape("Couldn't read that GIF."))
824
+ return True
825
+
826
+ from notify import gif_sticker
827
+ webm_bytes = await gif_sticker.convert_gif_to_webm_sticker(gif_bytes)
828
+ if webm_bytes is None:
829
+ await send_telegram_message(
830
+ session, chat_id,
831
+ md.escape("Couldn't convert that GIF into a sticker that fits Telegram's size limit — try a shorter or simpler one."),
832
+ )
833
+ return True
834
+
835
+ import secrets
836
+ from db.models import TelegramConvertedSticker, TelegramStickerSet
837
+ token = secrets.token_urlsafe(24)
838
+ session.add(TelegramConvertedSticker(token=token, webm_bytes=webm_bytes))
839
+ exp = int(time.time()) + gif_sticker.GIF_STICKER_TOKEN_TTL_SECONDS
840
+ sig = gif_sticker.sign_gif_sticker_token(token, exp)
841
+ sticker_url = f"{_PULSAR_WEB_URL}/telegram/sticker.webm?token={token}&exp={exp}&sig={sig}"
842
+ emoji = "\U0001F642"
843
+
844
+ from notify import telegram_ai
845
+ tracked = await session.get(TelegramStickerSet, from_id)
846
+ if tracked:
847
+ telegram_ai.queue_sticker_delivery(session, chat_id, from_id, "sticker_add", {
848
+ "set_name": tracked.set_name, "title": f"Pulsar Stickers ({from_id})",
849
+ "sticker_url": sticker_url, "emoji": emoji, "sticker_format": "video",
850
+ })
851
+ return True
852
+
853
+ set_name = f"pulsar_u{from_id}_by_{TELEGRAM_BOT_USERNAME}"
854
+ session.add(TelegramStickerSet(from_id=from_id, set_name=set_name))
855
+ telegram_ai.queue_sticker_delivery(session, chat_id, from_id, "sticker_create", {
856
+ "set_name": set_name, "title": f"Pulsar Stickers ({from_id})",
857
+ "sticker_url": sticker_url, "emoji": emoji, "sticker_format": "video",
858
+ })
859
+ return True
860
+
861
+
862
  async def cmd_space(session: AsyncSession, chat_id: str, args: str, from_id: Optional[str], chat_type: str) -> bool:
863
  from notify import space
864
  apod = await space.get_apod()
 
1165
  replied_sticker: Optional[dict] = None,
1166
  message_id: Optional[int] = None,
1167
  replied_message_text: Optional[str] = None,
1168
+ replied_animation_file_b64: Optional[str] = None,
1169
  ) -> bool:
1170
  """Returns True if the caller (main.py's webhook route) should
1171
  synchronously claim and return whatever this command just queued,
 
1199
  # reason: different inputs than the general case).
1200
  if cmd == "/kang":
1201
  return await cmd_kang(session, chat_id, from_id, replied_sticker)
1202
+ if cmd == "/gifer":
1203
+ return await cmd_gifer(session, chat_id, from_id, replied_animation_file_b64)
1204
 
1205
  handler = _COMMANDS.get(cmd)
1206
  if handler is not None:
backend/tests/test_gif_sticker.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+
3
+ import pytest
4
+
5
+ from db.models import TelegramConvertedSticker
6
+ from notify import gif_sticker
7
+
8
+
9
+ # ---------- convert_gif_to_webm_sticker ----------
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_convert_returns_none_for_empty_bytes():
13
+ assert await gif_sticker.convert_gif_to_webm_sticker(b"") is None
14
+
15
+
16
+ @pytest.mark.asyncio
17
+ async def test_convert_returns_bytes_from_first_attempt_under_budget(monkeypatch):
18
+ calls = []
19
+
20
+ async def fake_run_ffmpeg(input_path, output_path, fps, crf):
21
+ calls.append((fps, crf))
22
+ with open(output_path, "wb") as f:
23
+ f.write(b"x" * 1000) # well under budget
24
+ return True
25
+
26
+ monkeypatch.setattr(gif_sticker, "_run_ffmpeg", fake_run_ffmpeg)
27
+ result = await gif_sticker.convert_gif_to_webm_sticker(b"fake gif bytes")
28
+ assert result == b"x" * 1000
29
+ # Only the first (highest-quality) rung of the ladder should have run.
30
+ assert calls == [(15, gif_sticker._CRF_LADDER_15FPS[0])]
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_convert_retries_down_the_ladder_until_under_budget(monkeypatch):
35
+ attempt = {"n": 0}
36
+
37
+ async def fake_run_ffmpeg(input_path, output_path, fps, crf):
38
+ attempt["n"] += 1
39
+ # First two attempts stay oversized, third finally fits.
40
+ size = gif_sticker._MAX_OUTPUT_BYTES + 1 if attempt["n"] < 3 else 500
41
+ with open(output_path, "wb") as f:
42
+ f.write(b"y" * size)
43
+ return True
44
+
45
+ monkeypatch.setattr(gif_sticker, "_run_ffmpeg", fake_run_ffmpeg)
46
+ result = await gif_sticker.convert_gif_to_webm_sticker(b"fake gif bytes")
47
+ assert result == b"y" * 500
48
+ assert attempt["n"] == 3
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_convert_returns_none_when_ffmpeg_fails_outright(monkeypatch):
53
+ async def fake_run_ffmpeg(input_path, output_path, fps, crf):
54
+ return False
55
+
56
+ monkeypatch.setattr(gif_sticker, "_run_ffmpeg", fake_run_ffmpeg)
57
+ assert await gif_sticker.convert_gif_to_webm_sticker(b"fake gif bytes") is None
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_convert_returns_none_when_nothing_ever_fits_budget(monkeypatch):
62
+ async def fake_run_ffmpeg(input_path, output_path, fps, crf):
63
+ with open(output_path, "wb") as f:
64
+ f.write(b"z" * (gif_sticker._MAX_OUTPUT_BYTES + 1))
65
+ return True
66
+
67
+ monkeypatch.setattr(gif_sticker, "_run_ffmpeg", fake_run_ffmpeg)
68
+ assert await gif_sticker.convert_gif_to_webm_sticker(b"fake gif bytes") is None
69
+
70
+
71
+ def test_sign_gif_sticker_token_is_deterministic_and_tamper_evident():
72
+ exp = int(time.time()) + 300
73
+ sig1 = gif_sticker.sign_gif_sticker_token("tok123", exp)
74
+ sig2 = gif_sticker.sign_gif_sticker_token("tok123", exp)
75
+ assert sig1 == sig2
76
+ assert sig1 != gif_sticker.sign_gif_sticker_token("tokABC", exp)
77
+ assert sig1 != gif_sticker.sign_gif_sticker_token("tok123", exp + 1)
78
+
79
+
80
+ # ---------- GET /telegram/sticker.webm ----------
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_sticker_webm_endpoint_returns_bytes_and_claims_row(client, db_session):
84
+ db_session.add(TelegramConvertedSticker(token="claim-me", webm_bytes=b"FAKEWEBMBYTES"))
85
+ await db_session.commit()
86
+
87
+ exp = int(time.time()) + 60
88
+ sig = gif_sticker.sign_gif_sticker_token("claim-me", exp)
89
+ resp = await client.get("/telegram/sticker.webm", params={"token": "claim-me", "exp": exp, "sig": sig})
90
+ assert resp.status_code == 200, resp.text
91
+ assert resp.headers["content-type"] == "video/webm"
92
+ assert resp.content == b"FAKEWEBMBYTES"
93
+
94
+ # Claimed exactly once — a second fetch finds nothing.
95
+ resp2 = await client.get("/telegram/sticker.webm", params={"token": "claim-me", "exp": exp, "sig": sig})
96
+ assert resp2.status_code == 404
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_sticker_webm_endpoint_rejects_bad_signature(client):
101
+ exp = int(time.time()) + 60
102
+ resp = await client.get("/telegram/sticker.webm", params={"token": "whatever", "exp": exp, "sig": "wrong"})
103
+ assert resp.status_code == 403
104
+
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_sticker_webm_endpoint_rejects_expired_token(client):
108
+ exp = 1
109
+ sig = gif_sticker.sign_gif_sticker_token("whatever", exp)
110
+ resp = await client.get("/telegram/sticker.webm", params={"token": "whatever", "exp": exp, "sig": sig})
111
+ assert resp.status_code == 403
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_sticker_webm_endpoint_404s_for_unknown_token(client):
116
+ exp = int(time.time()) + 60
117
+ sig = gif_sticker.sign_gif_sticker_token("never-existed", exp)
118
+ resp = await client.get("/telegram/sticker.webm", params={"token": "never-existed", "exp": exp, "sig": sig})
119
+ assert resp.status_code == 404
backend/tests/test_telegram_commands.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import datetime
2
 
3
  import pandas as pd
@@ -11,6 +12,7 @@ from db.models import (
11
  Signal,
12
  SignalModel,
13
  SignalSubscription,
 
14
  TelegramGroupSignalSubscription,
15
  TelegramStickerSet,
16
  User,
@@ -60,6 +62,7 @@ async def _clean_scan_tables(db_session):
60
  await db_session.execute(delete(TelegramGroupSignalSubscription))
61
  await db_session.execute(delete(WittyRemark))
62
  await db_session.execute(delete(TelegramStickerSet))
 
63
  await db_session.commit()
64
  yield
65
 
@@ -782,6 +785,100 @@ async def test_kang_copies_replied_sticker(client, db_session):
782
  assert row is not None
783
 
784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
  @pytest.mark.asyncio
786
  async def test_space_queues_photo(client, monkeypatch):
787
  from notify import space
 
1
+ import base64
2
  import datetime
3
 
4
  import pandas as pd
 
12
  Signal,
13
  SignalModel,
14
  SignalSubscription,
15
+ TelegramConvertedSticker,
16
  TelegramGroupSignalSubscription,
17
  TelegramStickerSet,
18
  User,
 
62
  await db_session.execute(delete(TelegramGroupSignalSubscription))
63
  await db_session.execute(delete(WittyRemark))
64
  await db_session.execute(delete(TelegramStickerSet))
65
+ await db_session.execute(delete(TelegramConvertedSticker))
66
  await db_session.commit()
67
  yield
68
 
 
785
  assert row is not None
786
 
787
 
788
+ @pytest.mark.asyncio
789
+ async def test_gifer_requires_a_replied_animation(client):
790
+ resp = await client.post(
791
+ "/telegram/webhook", params=RELAY_HEADERS,
792
+ json={"chat_id": "g1", "text": "/gifer", "from_id": "g1", "chat_type": "private"},
793
+ )
794
+ assert resp.status_code == 200
795
+ body = resp.json()
796
+ assert "reply to a gif" in body["messages"][0]["text"].lower()
797
+
798
+
799
+ @pytest.mark.asyncio
800
+ async def test_gifer_converts_and_creates_sticker(client, db_session, monkeypatch):
801
+ from notify import gif_sticker
802
+
803
+ async def fake_convert(gif_bytes):
804
+ assert gif_bytes == b"raw gif bytes"
805
+ return b"FAKEWEBMBYTES"
806
+
807
+ monkeypatch.setattr(gif_sticker, "convert_gif_to_webm_sticker", fake_convert)
808
+
809
+ b64 = base64.b64encode(b"raw gif bytes").decode()
810
+ resp = await client.post(
811
+ "/telegram/webhook", params=RELAY_HEADERS,
812
+ json={
813
+ "chat_id": "g2", "text": "/gifer", "from_id": "g2", "chat_type": "private",
814
+ "replied_animation_file_b64": b64,
815
+ },
816
+ )
817
+ assert resp.status_code == 200, resp.text
818
+ body = resp.json()
819
+ assert len(body.get("sticker_creates", [])) == 1
820
+ created = body["sticker_creates"][0]
821
+ assert created["sticker_format"] == "video"
822
+ assert "sticker.webm?token=" in created["sticker_url"]
823
+
824
+ row = await db_session.get(TelegramStickerSet, "g2")
825
+ assert row is not None
826
+
827
+ from db.models import TelegramConvertedSticker
828
+ all_rows = (await db_session.execute(select(TelegramConvertedSticker))).scalars().all()
829
+ assert len(all_rows) == 1
830
+ assert all_rows[0].webm_bytes == b"FAKEWEBMBYTES"
831
+
832
+
833
+ @pytest.mark.asyncio
834
+ async def test_gifer_adds_to_an_existing_pack(client, db_session, monkeypatch):
835
+ from notify import gif_sticker
836
+
837
+ db_session.add(TelegramStickerSet(from_id="g3", set_name="pulsar_u3_by_bot"))
838
+ await db_session.commit()
839
+
840
+ async def fake_convert(gif_bytes):
841
+ return b"FAKEWEBMBYTES"
842
+
843
+ monkeypatch.setattr(gif_sticker, "convert_gif_to_webm_sticker", fake_convert)
844
+
845
+ b64 = base64.b64encode(b"raw gif bytes").decode()
846
+ resp = await client.post(
847
+ "/telegram/webhook", params=RELAY_HEADERS,
848
+ json={
849
+ "chat_id": "g3", "text": "/gifer", "from_id": "g3", "chat_type": "private",
850
+ "replied_animation_file_b64": b64,
851
+ },
852
+ )
853
+ assert resp.status_code == 200, resp.text
854
+ body = resp.json()
855
+ assert len(body.get("sticker_adds", [])) == 1
856
+ assert body["sticker_adds"][0]["set_name"] == "pulsar_u3_by_bot"
857
+
858
+
859
+ @pytest.mark.asyncio
860
+ async def test_gifer_reports_failure_when_conversion_never_fits(client, monkeypatch):
861
+ from notify import gif_sticker
862
+
863
+ async def fake_convert(gif_bytes):
864
+ return None
865
+
866
+ monkeypatch.setattr(gif_sticker, "convert_gif_to_webm_sticker", fake_convert)
867
+
868
+ b64 = base64.b64encode(b"raw gif bytes").decode()
869
+ resp = await client.post(
870
+ "/telegram/webhook", params=RELAY_HEADERS,
871
+ json={
872
+ "chat_id": "g4", "text": "/gifer", "from_id": "g4", "chat_type": "private",
873
+ "replied_animation_file_b64": b64,
874
+ },
875
+ )
876
+ assert resp.status_code == 200, resp.text
877
+ body = resp.json()
878
+ assert body.get("sticker_creates", []) == []
879
+ assert "couldn't convert" in body["messages"][0]["text"].lower()
880
+
881
+
882
  @pytest.mark.asyncio
883
  async def test_space_queues_photo(client, monkeypatch):
884
  from notify import space
cloudflare/telegram-relay-worker.js CHANGED
@@ -109,6 +109,18 @@ export default {
109
  const isPhotoMessage = !!(message && Array.isArray(message.photo) && message.photo.length > 0);
110
  const isMediaMessage = isVoiceMessage || isDocumentMessage || isPhotoMessage;
111
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  // Ignore anything that isn't a plain text message, an analyzable
113
  // voice/document/photo, or a tapped inline-keyboard button
114
  // (channel_post, other unsupported content, etc.) — the backend's
@@ -122,6 +134,20 @@ export default {
122
  let voiceFileB64 = null;
123
  let documentFileB64 = null;
124
  let photoFileB64 = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  if (!callbackQuery && isMediaMessage) {
126
  // Telegram only gives a file_id in the update. The backend CANNOT
127
  // resolve/download this itself — HF Spaces' free tier reliably
@@ -210,6 +236,10 @@ export default {
210
  // above). Lets "@PulsarBot what does this mean?" as a reply to
211
  // someone else's message actually see what "this" refers to.
212
  replied_message_text: message.reply_to_message?.text || message.reply_to_message?.caption || null,
 
 
 
 
213
  };
214
 
215
  const forwardToBackend = async () => {
@@ -459,6 +489,33 @@ async function drainOutbox(env) {
459
  await Promise.all(stickerAdds.map((s) => addStickerToSet(env, s)));
460
  }
461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  async function createStickerSet(env, { chat_id, from_id, set_name, title, sticker_url, emoji, sticker_format }) {
463
  try {
464
  // `sticker` as a URL string OR an existing file_id — both work
@@ -469,16 +526,29 @@ async function createStickerSet(env, { chat_id, from_id, set_name, title, sticke
469
  // sticker actually is — only ANIMATED (TGS) or VIDEO (WEBM) originals
470
  // need that declared correctly, a mismatch here is what Telegram
471
  // would reject the request over.
472
- const resp = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/createNewStickerSet`, {
473
- method: "POST",
474
- headers: { "Content-Type": "application/json" },
475
- body: JSON.stringify({
476
- user_id: from_id,
477
- name: set_name,
478
- title,
479
- stickers: [{ sticker: sticker_url, format: sticker_format || "static", emoji_list: [emoji] }],
480
- }),
481
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  if (!resp.ok) {
483
  console.error("createNewStickerSet failed:", chat_id, set_name, resp.status, await resp.text());
484
  return;
@@ -501,15 +571,28 @@ async function createStickerSet(env, { chat_id, from_id, set_name, title, sticke
501
 
502
  async function addStickerToSet(env, { chat_id, from_id, set_name, title, sticker_url, emoji, sticker_format }) {
503
  try {
504
- const resp = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/addStickerToSet`, {
505
- method: "POST",
506
- headers: { "Content-Type": "application/json" },
507
- body: JSON.stringify({
508
- user_id: from_id,
509
- name: set_name,
510
- sticker: { sticker: sticker_url, format: sticker_format || "static", emoji_list: [emoji] },
511
- }),
512
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  if (resp.ok) {
514
  // Same visibility gap as createStickerSet above — an added sticker
515
  // is otherwise silent, and if the user hasn't added the pack yet
 
109
  const isPhotoMessage = !!(message && Array.isArray(message.photo) && message.photo.length > 0);
110
  const isMediaMessage = isVoiceMessage || isDocumentMessage || isPhotoMessage;
111
 
112
+ // /gifer replies to an earlier GIF (Telegram's `animation` message
113
+ // type) rather than attaching one to the current message — unlike
114
+ // voice/document/photo above, THIS message is plain command text, so
115
+ // this doesn't fold into isMediaMessage. Gated on the command name so
116
+ // an unrelated reply to a GIF doesn't trigger an expensive download
117
+ // for nothing.
118
+ const repliedAnimation = message?.reply_to_message?.animation;
119
+ const isGiferCommand = !!(
120
+ repliedAnimation && typeof message.text === "string" &&
121
+ message.text.trim().split(/\s+/)[0].split("@")[0].toLowerCase() === "/gifer"
122
+ );
123
+
124
  // Ignore anything that isn't a plain text message, an analyzable
125
  // voice/document/photo, or a tapped inline-keyboard button
126
  // (channel_post, other unsupported content, etc.) — the backend's
 
134
  let voiceFileB64 = null;
135
  let documentFileB64 = null;
136
  let photoFileB64 = null;
137
+ let animationFileB64 = null;
138
+ if (!callbackQuery && isGiferCommand) {
139
+ const result = await fetchTelegramFileAsBase64(env, repliedAnimation.file_id);
140
+ if (result === "TOO_LARGE") {
141
+ await sendTelegramMessage(env, {
142
+ chat_id: String(message.chat.id),
143
+ text: escapeMarkdownV2(
144
+ `That GIF's too large for me to process right now (over ${Math.round(MAX_MEDIA_BYTES / (1024 * 1024))} MB) — try a shorter one.`
145
+ ),
146
+ });
147
+ return new Response("ok", { status: 200 });
148
+ }
149
+ if (result) animationFileB64 = result;
150
+ }
151
  if (!callbackQuery && isMediaMessage) {
152
  // Telegram only gives a file_id in the update. The backend CANNOT
153
  // resolve/download this itself — HF Spaces' free tier reliably
 
236
  // above). Lets "@PulsarBot what does this mean?" as a reply to
237
  // someone else's message actually see what "this" refers to.
238
  replied_message_text: message.reply_to_message?.text || message.reply_to_message?.caption || null,
239
+ // Set only for /gifer replying to a GIF — see isGiferCommand
240
+ // above. Already-downloaded bytes, same reasoning as
241
+ // voice/document/photo: the backend can't fetch this itself.
242
+ replied_animation_file_b64: animationFileB64,
243
  };
244
 
245
  const forwardToBackend = async () => {
 
489
  await Promise.all(stickerAdds.map((s) => addStickerToSet(env, s)));
490
  }
491
 
492
+ // Telegram: "Animated and video stickers can't be uploaded via HTTP URL"
493
+ // (confirmed against the Bot API's InputSticker docs) — unlike static
494
+ // stickers, which Telegram fetches directly from sticker_url the same
495
+ // way sendPhoto/sendAudio do here. Only applies when sticker_url is
496
+ // actually a URL we're pointing Telegram at — copy_sticker_to_pack's
497
+ // file_id passthrough (an existing Telegram file, any format) was never
498
+ // a URL fetch to begin with, so it's unaffected either way.
499
+ function needsMultipartUpload(sticker_url, sticker_format) {
500
+ return (sticker_format === "video" || sticker_format === "animated") && /^https?:\/\//i.test(sticker_url || "");
501
+ }
502
+
503
+ /** Fetches the sticker bytes ourselves (from our own backend — see
504
+ * GET /telegram/sticker.webm, main.py) so they can be re-uploaded to
505
+ * Telegram as multipart/form-data instead of just handed over as a URL. */
506
+ async function fetchStickerBytes(url) {
507
+ const resp = await fetch(url);
508
+ if (!resp.ok) throw new Error(`sticker source fetch failed: ${resp.status}`);
509
+ return await resp.arrayBuffer();
510
+ }
511
+
512
+ function buildStickerUploadForm(baseFields, sticker_url, emoji, sticker_format, bytes) {
513
+ const form = new FormData();
514
+ for (const [key, value] of Object.entries(baseFields)) form.append(key, String(value));
515
+ form.append("sticker0", new Blob([bytes], { type: sticker_format === "video" ? "video/webm" : "application/x-tgsticker" }), "sticker0");
516
+ return form;
517
+ }
518
+
519
  async function createStickerSet(env, { chat_id, from_id, set_name, title, sticker_url, emoji, sticker_format }) {
520
  try {
521
  // `sticker` as a URL string OR an existing file_id — both work
 
526
  // sticker actually is — only ANIMATED (TGS) or VIDEO (WEBM) originals
527
  // need that declared correctly, a mismatch here is what Telegram
528
  // would reject the request over.
529
+ let resp;
530
+ if (needsMultipartUpload(sticker_url, sticker_format)) {
531
+ const bytes = await fetchStickerBytes(sticker_url);
532
+ const form = buildStickerUploadForm(
533
+ {
534
+ user_id: from_id, name: set_name, title,
535
+ stickers: JSON.stringify([{ sticker: "attach://sticker0", format: sticker_format, emoji_list: [emoji] }]),
536
+ },
537
+ sticker_url, emoji, sticker_format, bytes,
538
+ );
539
+ resp = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/createNewStickerSet`, { method: "POST", body: form });
540
+ } else {
541
+ resp = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/createNewStickerSet`, {
542
+ method: "POST",
543
+ headers: { "Content-Type": "application/json" },
544
+ body: JSON.stringify({
545
+ user_id: from_id,
546
+ name: set_name,
547
+ title,
548
+ stickers: [{ sticker: sticker_url, format: sticker_format || "static", emoji_list: [emoji] }],
549
+ }),
550
+ });
551
+ }
552
  if (!resp.ok) {
553
  console.error("createNewStickerSet failed:", chat_id, set_name, resp.status, await resp.text());
554
  return;
 
571
 
572
  async function addStickerToSet(env, { chat_id, from_id, set_name, title, sticker_url, emoji, sticker_format }) {
573
  try {
574
+ let resp;
575
+ if (needsMultipartUpload(sticker_url, sticker_format)) {
576
+ const bytes = await fetchStickerBytes(sticker_url);
577
+ const form = buildStickerUploadForm(
578
+ {
579
+ user_id: from_id, name: set_name,
580
+ sticker: JSON.stringify({ sticker: "attach://sticker0", format: sticker_format, emoji_list: [emoji] }),
581
+ },
582
+ sticker_url, emoji, sticker_format, bytes,
583
+ );
584
+ resp = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/addStickerToSet`, { method: "POST", body: form });
585
+ } else {
586
+ resp = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/addStickerToSet`, {
587
+ method: "POST",
588
+ headers: { "Content-Type": "application/json" },
589
+ body: JSON.stringify({
590
+ user_id: from_id,
591
+ name: set_name,
592
+ sticker: { sticker: sticker_url, format: sticker_format || "static", emoji_list: [emoji] },
593
+ }),
594
+ });
595
+ }
596
  if (resp.ok) {
597
  // Same visibility gap as createStickerSet above — an added sticker
598
  // is otherwise silent, and if the user hasn't added the pack yet