Add /clip duration override and /help command

/clip <seconds> in a link's message overrides the default 60s clip
window (capped at 600s); with a ?t= it sets the window length, without
one it clips from the start. /help lists every command with examples.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:35:28 -04:00
parent f9e73333ba
commit 26a5ecb2d0
+59 -13
View File
@@ -17,7 +17,8 @@ YOUTUBE_URL_PATTERN = r"https?://(?:www\.)?(?:youtube\.com/(?:watch\?v=|shorts/)
TIKTOK_URL_PATTERN = r"https?://(?:(?:www|m)\.tiktok\.com/(?:@[\w.-]+/video/\d+|t/\w+|v/\d+)|(?:vm|vt)\.tiktok\.com/\w+)" TIKTOK_URL_PATTERN = r"https?://(?:(?:www|m)\.tiktok\.com/(?:@[\w.-]+/video/\d+|t/\w+|v/\d+)|(?:vm|vt)\.tiktok\.com/\w+)"
VIDEO_URL_PATTERN = rf"(?:{TWITTER_URL_PATTERN}|{INSTAGRAM_URL_PATTERN}|{YOUTUBE_URL_PATTERN}|{TIKTOK_URL_PATTERN})" VIDEO_URL_PATTERN = rf"(?:{TWITTER_URL_PATTERN}|{INSTAGRAM_URL_PATTERN}|{YOUTUBE_URL_PATTERN}|{TIKTOK_URL_PATTERN})"
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB
CLIP_DURATION = 60 # seconds to grab around a shared ?t= timestamp CLIP_DURATION = 60 # default seconds to grab around a shared ?t= timestamp
MAX_CLIP_DURATION = 600 # ceiling for a user-supplied /clip override
YTDLP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv", "bin", "yt-dlp") YTDLP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv", "bin", "yt-dlp")
COOKIES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cookies.txt") COOKIES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cookies.txt")
ADMIN_NUMBERS = {n.strip() for n in os.environ.get("BOT_ADMINS", "").split(",") if n.strip()} ADMIN_NUMBERS = {n.strip() for n in os.environ.get("BOT_ADMINS", "").split(",") if n.strip()}
@@ -160,6 +161,17 @@ class VideoCommand(Command):
if not matches: if not matches:
return return
# An optional "/clip <seconds>" anywhere in the message overrides the
# default window length for any clip produced from this message.
clip_len = CLIP_DURATION
mclip = re.search(r"/clip\s+(\S+)", c.message.text, re.IGNORECASE)
if mclip:
secs = _parse_timestamp(mclip.group(1))
if secs is None or secs < 1:
await c.reply("`/clip` needs a length in seconds, e.g. `/clip 30`.")
return
clip_len = min(secs, MAX_CLIP_DURATION)
is_edit = c.message.type == MessageType.EDIT_MESSAGE is_edit = c.message.type == MessageType.EDIT_MESSAGE
for m in matches: for m in matches:
@@ -167,13 +179,17 @@ class VideoCommand(Command):
# The URL pattern stops at the video id, so any ?t=/&t= timestamp # The URL pattern stops at the video id, so any ?t=/&t= timestamp
# lives in the characters that follow. Grab the whole whitespace- # lives in the characters that follow. Grab the whole whitespace-
# delimited token to recover it. Only YouTube uses these offsets. # delimited token to recover it. Timestamps/clips apply to YouTube.
clip = None
if re.match(YOUTUBE_URL_PATTERN, url):
token = re.match(r"\S+", c.message.text[m.start():]).group(0) token = re.match(r"\S+", c.message.text[m.start():]).group(0)
clip_start = ( start = _extract_timestamp(token)
_extract_timestamp(token) if mclip:
if re.match(YOUTUBE_URL_PATTERN, url) # Explicit /clip clips even without a timestamp (from 0).
else None start = start or 0
) clip = (start, start + clip_len)
elif start is not None:
clip = (start, start + clip_len)
# Normalize fxtwitter/vxtwitter wrappers to x.com # Normalize fxtwitter/vxtwitter wrappers to x.com
url = re.sub( url = re.sub(
@@ -187,13 +203,11 @@ class VideoCommand(Command):
continue continue
_mark_url_handled(c.message.group, url) _mark_url_handled(c.message.group, url)
await self._download_and_send(c, url, clip_start) await self._download_and_send(c, url, clip)
async def _download_and_send(self, c: Context, url: str, clip_start: int | None = None) -> None: async def _download_and_send(self, c: Context, url: str, clip: tuple[int, int] | None = None) -> None:
clip = None if clip is not None:
if clip_start is not None: log.info("Clipping %s to window %d-%ds", url, clip[0], clip[1])
clip = (clip_start, clip_start + CLIP_DURATION)
log.info("Clipping %s to %d-%ds around shared timestamp", url, clip[0], clip[1])
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
outpath = os.path.join(tmpdir, "video.mp4") outpath = os.path.join(tmpdir, "video.mp4")
ok, err = self._run_ytdlp(url, outpath, tmpdir, clip) ok, err = self._run_ytdlp(url, outpath, tmpdir, clip)
@@ -530,6 +544,37 @@ class ReverseCommand(Command):
await c.send("", base64_attachments=[b64_reversed]) await c.send("", base64_attachments=[b64_reversed])
HELP_TEXT = f"""🎬 Video bot — what I can do
Share a video link (X/Twitter, Instagram, YouTube, TikTok) and I'll post the video back to the group.
e.g. https://x.com/user/status/123456789
A YouTube link with a timestamp → I post a {CLIP_DURATION}s clip starting at that moment.
e.g. https://youtu.be/dQw4w9WgXcQ?t=90
/clip <seconds> — set the clip length for a link in the same message (max {MAX_CLIP_DURATION}s). With a ?t= it sets the window; without one it clips from the start.
e.g. /clip 30 https://youtu.be/dQw4w9WgXcQ?t=90
e.g. /clip 15 https://youtu.be/dQw4w9WgXcQ
/speed [factor] — speed up the last video (default 2x).
e.g. /speed /speed 4 /speed 0.5
/rev — reverse the last video.
/help — show this message.
(In a DM, admins can run /cookies to refresh Instagram login cookies.)"""
class HelpCommand(Command):
async def handle(self, c: Context) -> None:
if not c.message.is_group():
return
if (c.message.text or "").strip().lower() not in ("/help", "/commands"):
return
await c.reply(HELP_TEXT)
def _sender_number(msg) -> str | None: def _sender_number(msg) -> str | None:
for attr in ("source", "source_number", "sourceNumber"): for attr in ("source", "source_number", "sourceNumber"):
v = getattr(msg, attr, None) v = getattr(msg, attr, None)
@@ -609,6 +654,7 @@ def main():
bot.register(VideoCommand(), contacts=False, groups=True) bot.register(VideoCommand(), contacts=False, groups=True)
bot.register(ReverseCommand(), contacts=False, groups=True) bot.register(ReverseCommand(), contacts=False, groups=True)
bot.register(SpeedCommand(), contacts=False, groups=True) bot.register(SpeedCommand(), contacts=False, groups=True)
bot.register(HelpCommand(), contacts=False, groups=True)
bot.register(CookiesCommand(), contacts=True, groups=False) bot.register(CookiesCommand(), contacts=True, groups=False)
log.info("Starting Signal video bot...") log.info("Starting Signal video bot...")
bot.start() bot.start()