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:
@@ -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+)"
|
||||
VIDEO_URL_PATTERN = rf"(?:{TWITTER_URL_PATTERN}|{INSTAGRAM_URL_PATTERN}|{YOUTUBE_URL_PATTERN}|{TIKTOK_URL_PATTERN})"
|
||||
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")
|
||||
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()}
|
||||
@@ -160,6 +161,17 @@ class VideoCommand(Command):
|
||||
if not matches:
|
||||
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
|
||||
|
||||
for m in matches:
|
||||
@@ -167,13 +179,17 @@ class VideoCommand(Command):
|
||||
|
||||
# The URL pattern stops at the video id, so any ?t=/&t= timestamp
|
||||
# lives in the characters that follow. Grab the whole whitespace-
|
||||
# delimited token to recover it. Only YouTube uses these offsets.
|
||||
token = re.match(r"\S+", c.message.text[m.start():]).group(0)
|
||||
clip_start = (
|
||||
_extract_timestamp(token)
|
||||
if re.match(YOUTUBE_URL_PATTERN, url)
|
||||
else None
|
||||
)
|
||||
# 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)
|
||||
start = _extract_timestamp(token)
|
||||
if mclip:
|
||||
# Explicit /clip clips even without a timestamp (from 0).
|
||||
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
|
||||
url = re.sub(
|
||||
@@ -187,13 +203,11 @@ class VideoCommand(Command):
|
||||
continue
|
||||
|
||||
_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:
|
||||
clip = None
|
||||
if clip_start is not None:
|
||||
clip = (clip_start, clip_start + CLIP_DURATION)
|
||||
log.info("Clipping %s to %d-%ds around shared timestamp", url, clip[0], clip[1])
|
||||
async def _download_and_send(self, c: Context, url: str, clip: tuple[int, int] | None = None) -> None:
|
||||
if clip is not None:
|
||||
log.info("Clipping %s to window %d-%ds", url, clip[0], clip[1])
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outpath = os.path.join(tmpdir, "video.mp4")
|
||||
ok, err = self._run_ytdlp(url, outpath, tmpdir, clip)
|
||||
@@ -530,6 +544,37 @@ class ReverseCommand(Command):
|
||||
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:
|
||||
for attr in ("source", "source_number", "sourceNumber"):
|
||||
v = getattr(msg, attr, None)
|
||||
@@ -609,6 +654,7 @@ def main():
|
||||
bot.register(VideoCommand(), contacts=False, groups=True)
|
||||
bot.register(ReverseCommand(), 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)
|
||||
log.info("Starting Signal video bot...")
|
||||
bot.start()
|
||||
|
||||
Reference in New Issue
Block a user