Add /gifer: convert a replied-to GIF into a Telegram video sticker
Browse filesReply 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 +7 -0
- backend/db/models.py +21 -1
- backend/main.py +38 -2
- backend/notify/gif_sticker.py +131 -0
- backend/notify/telegram.py +10 -1
- backend/notify/telegram_commands.py +71 -0
- backend/tests/test_gif_sticker.py +119 -0
- backend/tests/test_telegram_commands.py +97 -0
- cloudflare/telegram-relay-worker.js +102 -19
|
@@ -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 |
|
|
@@ -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
|
|
@@ -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,
|
|
@@ -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
|
|
@@ -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 |
)
|
|
@@ -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:
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 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 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 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
|