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+)"
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user