| import os |
| |
| import os |
| import time |
| import uuid |
|
|
|
|
| import json |
| import base64 |
| import asyncio |
| import nest_asyncio |
| import random |
| import logging |
| import atexit |
| import pathlib |
| from threading import Thread |
| from datetime import datetime, timedelta, timezone |
| from typing import Optional, Tuple, List |
|
|
| |
| from flask import Flask, render_template, request, redirect, session |
|
|
| |
| from moviepy.editor import ( |
| VideoFileClip, |
| ImageClip, |
| ColorClip, |
| CompositeVideoClip, |
| concatenate_videoclips |
| ) |
| from moviepy.video.fx import resize |
|
|
| |
| from PIL import Image, ImageDraw, ImageFont |
|
|
| |
| import numpy as np |
|
|
| |
| import requests |
|
|
| |
| import emoji |
|
|
| |
| from pymongo import MongoClient |
|
|
| |
| from google.oauth2.credentials import Credentials |
| from google.auth.transport.requests import Request |
| from googleapiclient.discovery import build |
| from googleapiclient.http import MediaFileUpload |
|
|
| |
| from yt_dlp import YoutubeDL |
|
|
| |
| from telegram import Update |
| from telegram.ext import ( |
| Application, |
| CommandHandler, |
| MessageHandler, |
| filters, |
| ContextTypes, |
| JobQueue |
| ) |
|
|
| UPLOAD_TIMES = [] |
| NEXT_RESET = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) |
|
|
|
|
| def patch_pillow_compat(): |
| """Pillow >= 10 removed ``Image.ANTIALIAS`` and the other module-level |
| resampling constants, but moviepy 1.0.3 (which we rely on) still calls |
| ``Image.ANTIALIAS`` inside its resizer. The previous ``patch_moviepy`` never |
| actually fixed this β it only worked because Pillow was pinned to 9.4.0. |
| |
| Restoring the constants lets us run a modern Pillow (required by instagrapi) |
| while keeping the stable, well-tested moviepy 1.0.3 video pipeline intact. |
| """ |
| resampling = getattr(Image, "Resampling", None) |
| if resampling is None: |
| return |
| for name, res in ( |
| ("ANTIALIAS", "LANCZOS"), ("LANCZOS", "LANCZOS"), ("BICUBIC", "BICUBIC"), |
| ("BILINEAR", "BILINEAR"), ("NEAREST", "NEAREST"), ("HAMMING", "HAMMING"), |
| ("BOX", "BOX"), |
| ): |
| if not hasattr(Image, name): |
| setattr(Image, name, getattr(resampling, res)) |
|
|
| patch_pillow_compat() |
|
|
| |
| |
| |
| try: |
| nest_asyncio.apply() |
| except Exception: |
| pass |
|
|
|
|
| def _textsize(draw, text, font): |
| """``ImageDraw.textsize`` was removed in Pillow 10. Reimplement it with |
| ``textbbox`` so text measurement works on every Pillow version.""" |
| if hasattr(draw, "textbbox"): |
| left, top, right, bottom = draw.textbbox((0, 0), text, font=font) |
| return right - left, bottom - top |
| return draw.textsize(text, font=font) |
|
|
|
|
| _FONT_CANDIDATES = ( |
| "DejaVuSans-Bold.ttf", |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", |
| "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", |
| ) |
|
|
|
|
| def _load_font(size, name=None): |
| """Load a TrueType font robustly. Try the given name/path, then common system |
| locations, then a correctly-sized default. NEVER raises (a missing font used |
| to crash create_text_image / create_text_image_with_shadow).""" |
| for cand in ([name] if name else []) + list(_FONT_CANDIDATES): |
| try: |
| return ImageFont.truetype(cand, size=int(size)) |
| except OSError: |
| continue |
| try: |
| return ImageFont.load_default(size=int(size)) |
| except TypeError: |
| return ImageFont.load_default() |
|
|
| import emoji |
| from PIL import Image, ImageDraw, ImageFont |
| import emoji |
| import os |
| import requests |
| from PIL import Image, ImageDraw, ImageFont |
| import emoji |
| import os, requests |
|
|
| import os |
| import emoji |
| import requests |
| from PIL import Image, ImageDraw, ImageFont |
|
|
| def create_text_image_2( |
| text: str, |
| width: int, |
| height: int, |
| *, |
| font_size: int = 60, |
| align: str = "center", |
| bg_color=(255, 255, 255), |
| text_color=(0, 0, 0) |
| ): |
| img = Image.new("RGBA", (width, height), color=bg_color) |
| draw = ImageDraw.Draw(img) |
|
|
| |
| font = _load_font(font_size) |
|
|
| |
| all_emojis = emoji.emoji_list(text) |
| plain_text = emoji.replace_emoji(text, replace='') |
|
|
| |
| text_width, text_height = _textsize(draw, plain_text, font) |
| total_emoji_width = len(all_emojis) * font_size |
| full_width = text_width + total_emoji_width + 5 * len(all_emojis) |
|
|
| |
| if align == "left": |
| x_start = 20 |
| elif align == "right": |
| x_start = max(20, width - full_width - 20) |
| else: |
| x_start = max(20, (width - full_width) // 2) |
|
|
| y_start = (height - text_height) // 2 |
|
|
| |
| draw.text((x_start, y_start), plain_text, font=font, fill=text_color) |
|
|
| |
| x = x_start + text_width + 5 |
| for em in all_emojis: |
| char = em["emoji"] |
| hexcode = "-".join(f"{ord(c):x}" for c in char) |
|
|
| |
| emoji_path = f"emoji_pngs/{hexcode}.png" |
| if not os.path.exists(emoji_path): |
| url = f"https://github.com/twitter/twemoji/raw/master/assets/72x72/{hexcode}.png" |
| os.makedirs("emoji_pngs", exist_ok=True) |
| try: |
| response = requests.get(url, timeout=5) |
| if response.ok: |
| with open(emoji_path, "wb") as f: |
| f.write(response.content) |
| except Exception: |
| continue |
|
|
| |
| if os.path.exists(emoji_path): |
| em_img = Image.open(emoji_path).convert("RGBA").resize((font_size, font_size)) |
| img.paste(em_img, (x, y_start), em_img) |
| x += font_size + 5 |
|
|
| return img |
|
|
| from PIL import Image, ImageDraw, ImageFont |
|
|
| def create_text_image_with_shadow( |
| text: str, |
| width: int, |
| height: int, |
| *, |
| font_size: int = 60, |
| align: str = "center", |
| bg_color=(0, 0, 0, 0), |
| text_color=(255, 255, 255), |
| shadow_color=(0, 0, 0), |
| font_name="DejaVuSans-Bold.ttf" |
| ): |
| |
| img = Image.new("RGBA", (width, height), bg_color) |
| draw = ImageDraw.Draw(img) |
|
|
| |
| font = _load_font(font_size, font_name) |
|
|
| |
| text_bbox = draw.textbbox((0, 0), text, font=font) |
| text_width = text_bbox[2] - text_bbox[0] |
| text_height = text_bbox[3] - text_bbox[1] |
|
|
| |
| if align == "left": |
| x = 0 |
| elif align == "right": |
| x = width - text_width |
| else: |
| x = (width - text_width) // 2 |
| y = (height - text_height) // 2 |
|
|
| |
| draw.text((x + 1, y), text, font=font, fill=shadow_color) |
| |
| draw.text((x, y), text, font=font, fill=text_color) |
|
|
| return img |
| |
|
|
| def create_text_image(text, width, height): |
| img = Image.new("RGB", (width, height), color=(255, 255, 255)) |
| draw = ImageDraw.Draw(img) |
|
|
| |
| font = _load_font(60) |
|
|
| |
| emojis = emoji.emoji_list(text) |
| pure_text = emoji.replace_emoji(text, replace='') |
|
|
| |
| max_font_size = 70 |
| while True: |
| font = _load_font(max_font_size) |
| text_width, text_height = _textsize(draw, pure_text, font) |
| total_width = text_width + (len(emojis) * 60) + 20 |
| if total_width <= width - 40 or max_font_size <= 30: |
| break |
| max_font_size -= 2 |
|
|
| |
| start_x = (width - total_width) // 2 |
| y = (height - text_height) // 2 |
|
|
| |
| draw.text((start_x, y), pure_text, font=font, fill=(0, 0, 0)) |
|
|
| |
| x = start_x + text_width + 10 |
| for e in emojis: |
| hexcode = '-'.join(f"{ord(c):x}" for c in e['emoji']) |
| emoji_path = f"emoji_pngs/{hexcode}.png" |
| if not os.path.exists(emoji_path): |
| download_emoji_png(e['emoji']) |
|
|
| if os.path.exists(emoji_path): |
| emoji_img = Image.open(emoji_path).convert("RGBA") |
| emoji_img = emoji_img.resize((60, 60)) |
| img.paste(emoji_img, (x, y), emoji_img) |
| x += 60 + 4 |
|
|
| return img |
|
|
| from PIL import Image, ImageDraw, ImageFont |
| import numpy as np |
| from moviepy.editor import ImageClip |
|
|
| def generate_watermark_img(text, width, height=50): |
| img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) |
| draw = ImageDraw.Draw(img) |
|
|
| font = _load_font(35) |
|
|
| text_width, text_height = _textsize(draw, text, font) |
| draw.text((5, height - text_height - 2), text, fill="white", font=font, stroke_width=1, stroke_fill="black") |
|
|
| return img |
| |
| def download_emoji_png(emoji_char): |
| hexcode = '-'.join(f"{ord(c):x}" for c in emoji_char) |
| url = f"https://github.com/twitter/twemoji/raw/master/assets/72x72/{hexcode}.png" |
| os.makedirs("emoji_pngs", exist_ok=True) |
| path = f"emoji_pngs/{hexcode}.png" |
| try: |
| r = requests.get(url) |
| if r.status_code == 200: |
| with open(path, "wb") as f: |
| f.write(r.content) |
| print(f"β
Downloaded emoji: {emoji_char} β {path}") |
| else: |
| print(f"β Failed to download emoji: {emoji_char}") |
| except Exception as e: |
| print(f"β οΈ Error downloading emoji {emoji_char}: {e}") |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def _probe_width(path: str): |
| """Return the pixel width of a video using ffprobe, or None if unavailable.""" |
| import shutil, subprocess |
| ff = shutil.which("ffprobe") |
| if not ff: |
| return None |
| try: |
| out = subprocess.run( |
| [ff, "-v", "error", "-select_streams", "v:0", |
| "-show_entries", "stream=width", "-of", "default=nw=1:nk=1", path], |
| capture_output=True, text=True, timeout=30, |
| ).stdout.strip() |
| return int(out.split()[0]) if out else None |
| except Exception: |
| return None |
|
|
|
|
| def _log_quality(path: str) -> None: |
| """Log resolution / size / bitrate of an exported file so quality parity |
| across every upload is visible and verifiable.""" |
| import shutil, subprocess |
| try: |
| size_mb = os.path.getsize(path) / 1_000_000 |
| except OSError: |
| size_mb = 0.0 |
| details = "" |
| ff = shutil.which("ffprobe") |
| if ff: |
| try: |
| details = " ".join(subprocess.run( |
| [ff, "-v", "error", "-select_streams", "v:0", |
| "-show_entries", "stream=width,height,r_frame_rate,bit_rate", |
| "-of", "default=nw=1:nk=1", path], |
| capture_output=True, text=True, timeout=30, |
| ).stdout.split()) |
| except Exception: |
| pass |
| logger.info(f"[ποΈ] export {os.path.basename(path)} | {size_mb:.1f} MB | {details}") |
|
|
|
|
| def write_high_quality(final, out_path: str, fps=None): |
| """The single, shared exporter used by every edit function so all uploads |
| (1st .. Nth in the same hour) get IDENTICAL maximum-quality encoding.""" |
| crf = os.getenv("CRF", "15") |
| preset = os.getenv("FFMPEG_PRESET", "medium") |
| try: |
| threads = int(os.getenv("FFMPEG_THREADS", "2")) |
| except ValueError: |
| threads = 2 |
| ffmpeg_params = [ |
| "-crf", str(crf), |
| "-pix_fmt", "yuv420p", |
| "-profile:v", "high", |
| "-bf", "2", |
| "-g", "60", |
| "-movflags", "+faststart", |
| ] |
| kwargs = dict( |
| codec="libx264", |
| audio_codec="aac", |
| audio_bitrate="192k", |
| preset=preset, |
| ffmpeg_params=ffmpeg_params, |
| threads=threads, |
| verbose=False, |
| logger=None, |
| ) |
| if fps: |
| kwargs["fps"] = fps |
| final.write_videofile(out_path, **kwargs) |
| _log_quality(out_path) |
| return out_path |
|
|
|
|
| |
| |
| |
| |
| |
| OUT_W, OUT_H = 1080, 1920 |
|
|
|
|
| def _cover_fit(clip, tw, th): |
| """Scale `clip` to COVER a tw x th box (fill it completely) then center-crop. |
| Guarantees the box is filled edge-to-edge with NO black bars on width/height.""" |
| scale = max(tw / clip.w, th / clip.h) |
| resized = clip.resize(width=max(int(round(clip.w * scale)), int(tw))) |
| return resized.crop(x_center=resized.w / 2, y_center=resized.h / 2, width=tw, height=th) |
|
|
|
|
| def edit_video(video_path): |
| clip = VideoFileClip(video_path) |
|
|
| BAR_H = 140 |
| MAIN_H = OUT_H - BAR_H |
|
|
| |
| final_bg = ColorClip(size=(OUT_W, OUT_H), color=(255, 255, 255), duration=clip.duration) |
|
|
| |
| caption = random.choice(CAPTIONS) |
| caption_img = create_text_image(caption, OUT_W, BAR_H) |
| caption_clip = ImageClip(np.array(caption_img)).set_duration(clip.duration).set_position((0, 0)) |
|
|
| |
| main_video = _cover_fit(clip, OUT_W, MAIN_H).set_duration(clip.duration).set_position((0, BAR_H)) |
|
|
| |
| eye_protection = ColorClip(size=(OUT_W, MAIN_H), color=(255, 255, 255), duration=clip.duration) |
| eye_protection = eye_protection.set_opacity(0.1).set_position((0, BAR_H)) |
|
|
| final = CompositeVideoClip( |
| [final_bg, caption_clip, main_video, eye_protection], |
| size=(OUT_W, OUT_H), |
| ) |
|
|
| os.makedirs("edited", exist_ok=True) |
| output_path = f"edited/{uuid.uuid4().hex}.mp4" |
| |
| write_high_quality(final, output_path, fps=clip.fps) |
| clip.close() |
| final.close() |
| return output_path |
|
|
| def edit_video_raw(video_path: str) -> str: |
| import os, uuid, random |
| import numpy as np |
| from moviepy.editor import ( |
| VideoFileClip, ImageClip, ColorClip, CompositeVideoClip |
| ) |
|
|
| |
| if not CAPTIONS: |
| raise ValueError("CAPTIONS list is empty!") |
|
|
| |
| clip = VideoFileClip(video_path) |
| vw, vh = OUT_W, OUT_H |
|
|
| |
| CAPTION_H = 156 |
| LAUGH_H = 457 |
| MID_H = 110 |
| MAIN_H = vh - (CAPTION_H + LAUGH_H + MID_H) |
|
|
| if clip.duration < 6: |
| raise ValueError("Main video must be at least 6 seconds.") |
| if clip.duration > 180: |
| clip = clip.subclip(0, 180) |
|
|
| |
| caption_text = random.choice(CAPTIONS) |
| caption_img = create_text_image_2( |
| caption_text, vw, CAPTION_H, |
| font_size=72, align="center", |
| bg_color=(255, 255, 255), text_color=(0, 0, 0) |
| ) |
| caption_clip = ImageClip(np.array(caption_img)) \ |
| .set_duration(clip.duration) \ |
| .set_position((0, 0)) |
|
|
| |
| mid_text = random.choice([ |
| "Pura 1 din ke baad ye mila π€£", |
| "Ye miss mat kr dena π", |
| "Kha thi ye reel ab tak π€¨π€", |
| "Wait, ye dekh kr hi janna π₯π₯", |
| ]) |
| mid_img = create_text_image_2( |
| mid_text, vw, MID_H, |
| font_size=64, align="center", |
| bg_color=(0, 0, 0), text_color=(255, 255, 255) |
| ) |
| mid_caption_clip = ImageClip(np.array(mid_img)) \ |
| .set_duration(clip.duration) \ |
| .set_position((0, CAPTION_H + LAUGH_H)) |
|
|
| |
| clip = clip.resize(width=vw) |
| crop_y1 = 200 |
| crop_y2 = crop_y1 + MAIN_H |
| main_video = clip.crop(y1=crop_y1, y2=crop_y2) \ |
| .set_position((0, CAPTION_H + LAUGH_H + MID_H)) |
|
|
| |
| overlay = ColorClip(size=(vw, MAIN_H), color=(255, 255, 255), duration=clip.duration) \ |
| .set_opacity(0.1).set_position((0, CAPTION_H + LAUGH_H + MID_H)) |
|
|
| |
| laugh_files = ["laugh/laugh_one.mp4", "laugh/laugh_two.mp4"] |
| laugh_path = random.choice(laugh_files) |
| if not os.path.exists(laugh_path): |
| raise FileNotFoundError(f"β Laugh meme not found: {laugh_path}") |
|
|
| laugh_clip = VideoFileClip(laugh_path).resize(width=vw).subclip(0, 4) |
| y1 = (laugh_clip.h - LAUGH_H) // 2 |
| y2 = y1 + LAUGH_H |
| laugh_crop = laugh_clip.crop(y1=y1, y2=y2).set_position((0, CAPTION_H)) |
|
|
| |
| laugh_part1 = laugh_crop.subclip(0, 2).set_start(0) |
| laugh_frozen = ImageClip(laugh_crop.get_frame(2)) \ |
| .set_duration(clip.duration - 4).set_start(2) \ |
| .set_position((0, CAPTION_H)) |
| laugh_part3 = laugh_crop.subclip(2, 4).set_start(clip.duration - 2) |
|
|
| |
| final = CompositeVideoClip([ |
| caption_clip, |
| laugh_part1, |
| laugh_frozen, |
| laugh_part3, |
| mid_caption_clip, |
| main_video, |
| overlay |
| ], size=(vw, vh)).set_duration(clip.duration) |
|
|
| |
| os.makedirs("edited", exist_ok=True) |
| out_path = f"edited/{uuid.uuid4().hex}.mp4" |
| |
| write_high_quality(final, out_path, fps=clip.fps) |
|
|
| clip.close(), laugh_clip.close(), final.close() |
| return out_path |
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s - %(levelname)s - %(message)s", |
| handlers=[ |
| logging.FileHandler("app.log"), |
| logging.StreamHandler() |
| ] |
| ) |
| logger = logging.getLogger(__name__) |
|
|
| |
| CAPTIONS = [ |
| "Wait for it π", "Watch till end π", "Try not to laugh π€£", |
| "Don't skip this π₯", "You won't expect this! π", "Keep watching π", |
| "Stay till end! π₯", "Funniest one yet" |
| ] |
| BLOCKLIST = [ |
| "nsfw", "18+", "xxx", "sexy", "adult", "porn", "onlyfans", "escort", |
| "betting", "gambling", "iplwin", "1xbet", "winzo", "my11circle", "dream11", |
| "rummy", "teenpatti", "fantasy", "casino", "promotion" |
| ] |
|
|
| UPLOAD_TIMES: List[datetime] = [] |
| NEXT_RESET: datetime | None = None |
| first_run = True |
|
|
| |
| client = MongoClient(os.getenv("MONGO_URI")) |
| db1 = client.shortttt |
| meta = db1.meta |
| sync = db1.sync |
| |
| |
| |
| config = db1.config |
|
|
| botdb = client.teleg4am_reelssss |
| a_raw = botdb.raw_links |
| a_reacted = botdb.reacted_links |
|
|
| |
| from flask import Flask |
|
|
| app = Flask(__name__) |
|
|
| @app.route("/") |
| def home(): |
| return "β
Code is running!" |
|
|
|
|
| |
| def get_random_link() -> Tuple[Optional[str], Optional[str]]: |
| raw_left = list(a_raw.find({"used": False})) |
| reacted_left = list(a_reacted.find({"used": False})) |
| if not raw_left and not reacted_left: |
| return None, None |
|
|
| choice_pool = "raw" if random.random() < 0.4 else "reacted" |
| if choice_pool == "raw" and not raw_left: |
| choice_pool = "reacted" |
| if choice_pool == "reacted" and not reacted_left: |
| choice_pool = "raw" |
|
|
| col, pool_list = (a_raw, raw_left) if choice_pool == "raw" else (a_reacted, reacted_left) |
| doc = random.choice(pool_list) |
| col.update_one({"_id": doc["_id"]}, {"$set": {"used": True}}) |
| return doc["link"], choice_pool |
| import os |
| import re |
| import uuid |
| import asyncio |
| import pathlib |
| import logging |
| from typing import Optional, Tuple |
|
|
| from telethon import TelegramClient |
| from telethon.sessions import StringSession |
| from telethon.tl.types import DocumentAttributeVideo |
| from moviepy.editor import VideoFileClip |
|
|
| API_ID = int(os.getenv("TG_API_ID", "3704772")) |
| API_HASH = os.getenv("TG_API_HASH", "b8e50a035abb851c0dd424e14cac4c06") |
| SESSION_STR = os.getenv("SESSION") |
| TARGET_BOT = "instasavegrambot" |
|
|
| logger = logging.getLogger(__name__) |
|
|
| def tg_duration_seconds(message) -> Optional[int]: |
| if not message or not message.media or not message.media.document: |
| return None |
| for attr in message.media.document.attributes: |
| if isinstance(attr, DocumentAttributeVideo): |
| return attr.duration |
| return None |
|
|
|
|
| def message_has_video(message) -> bool: |
| """True if the message carries a video FILE, regardless of its (often 0 or |
| missing) duration attribute. Many reels arrive with duration=0 β Telegram |
| then shows '0:00' even though the clip is 20s+ β so we must NOT rely on the |
| duration to decide whether a video was sent.""" |
| if not message or not getattr(message, "media", None): |
| return False |
| doc = getattr(message.media, "document", None) |
| if not doc: |
| return False |
| mime = (getattr(doc, "mime_type", "") or "").lower() |
| if mime.startswith("video/"): |
| return True |
| for attr in getattr(doc, "attributes", []): |
| if isinstance(attr, DocumentAttributeVideo): |
| return True |
| return False |
| import asyncio |
| import logging |
| import shutil |
|
|
| async def download_url_mp4(url: str, filename: str, timeout: int = 30) -> bool: |
| |
| if not shutil.which("wget"): |
| logger.error("β wget is not installed or not in PATH.") |
| return False |
|
|
| |
| wget_cmd = [ |
| "wget", |
| "--quiet", |
| f"--timeout={timeout}", |
| "--header=User-Agent: Mozilla/5.0 (Linux; Android 10)", |
| "--header=Referer: https://www.instagram.com/", |
| "-O", filename, |
| url |
| ] |
|
|
| try: |
| |
| proc = await asyncio.create_subprocess_exec( |
| *wget_cmd, |
| stdout=asyncio.subprocess.PIPE, |
| stderr=asyncio.subprocess.PIPE, |
| ) |
|
|
| |
| stdout, stderr = await proc.communicate() |
|
|
| if proc.returncode == 0: |
| logger.info(f"π₯ Downloaded MP4 β {filename}") |
| return True |
| else: |
| logger.error( |
| f"β wget exited with {proc.returncode}\nSTDERR: {stderr.decode().strip()}" |
| ) |
| return False |
|
|
| except Exception as e: |
| logger.error(f"β wget launch failed: {e}") |
| return False |
|
|
|
|
| async def send_to_bot_and_get_video(link: str) -> Tuple[Optional[str], Optional[int]]: |
| async with TelegramClient(StringSession(SESSION_STR), API_ID, API_HASH) as client: |
| bot = await client.get_entity(TARGET_BOT) |
|
|
| async def attempt(send_link: str, depth=0) -> Tuple[Optional[str], Optional[int]]: |
| if depth > 2: |
| logger.warning("π Retry limit reached.") |
| return None, None |
|
|
| async with client.conversation(bot, timeout=30) as conv: |
| await conv.send_message(send_link) |
| logger.info(f"π€ Sent to {TARGET_BOT}: {send_link}") |
|
|
| wait_minutes = 4 |
| deadline = asyncio.get_event_loop().time() + (wait_minutes * 60) |
|
|
| while True: |
| remaining = deadline - asyncio.get_event_loop().time() |
| if remaining <= 0: |
| logger.warning(f"β° No video received within {wait_minutes} minutes. Giving up on this link.") |
| return None, None |
|
|
| try: |
| msg = await conv.get_response(timeout=remaining) |
| except asyncio.TimeoutError: |
| logger.warning(f"β° No reply from bot within {wait_minutes} minutes.") |
| return None, None |
|
|
| text = msg.message or "" |
| logger.info(f"π¬ Bot replied: {text[:80]}") |
|
|
| |
| |
| |
| if message_has_video(msg): |
| tg_dur = tg_duration_seconds(msg) or 0 |
| pathlib.Path("reels").mkdir(exist_ok=True) |
| file_path = await msg.download_media(file="reels/") |
| if not file_path: |
| logger.warning("ποΈ Video download returned no file β waitingβ¦") |
| continue |
|
|
| real_dur = tg_dur |
| try: |
| clip = VideoFileClip(file_path) |
| real_dur = int(clip.duration) |
| clip.close() |
| except Exception as e: |
| logger.warning(f"ποΈ Could not probe duration ({e}); " |
| f"falling back to tg={tg_dur}s") |
|
|
| |
| if real_dur and not (20 <= real_dur <= 180): |
| logger.info(f"β© Skipped due to duration = {real_dur}s") |
| try: |
| os.remove(file_path) |
| except OSError: |
| pass |
| return None, None |
|
|
| logger.info(f"β
Downloaded video β {file_path} " |
| f"(duration={real_dur or 'unknown'}s, tg={tg_dur}s)") |
| return file_path, (real_dur or tg_dur or 30) |
|
|
| |
| urls = re.findall(r"https://[^\s]+", text) |
| if urls: |
| cdn_url = urls[0].strip() |
| pathlib.Path("reels").mkdir(exist_ok=True) |
| fname = f"reels/{uuid.uuid4().hex}.mp4" |
| success = await download_url_mp4(cdn_url, fname) |
| if not success: |
| return None, None |
|
|
| try: |
| clip = VideoFileClip(fname) |
| duration = int(clip.duration) |
| clip.close() |
| if 20 <= duration <= 180: |
| return fname, duration |
| else: |
| logger.info(f"β© CDN duration = {duration}s β Skipped") |
| os.remove(fname) |
| return None, None |
| except Exception as e: |
| logger.warning(f"ποΈ Duration read failed: {e}") |
| return None, None |
|
|
| |
| if "Request failed" in text: |
| logger.warning("π Bot said request failed, retrying onceβ¦") |
| await asyncio.sleep(5) |
| return await attempt(send_link, depth + 1) |
| if "We are experiencing high" in text: |
| await asyncio.sleep(3600) |
| return await attempt(send_link, depth + 1) |
|
|
| |
| logger.info("β Not a video yet, still waiting within the 4-minute windowβ¦") |
| continue |
|
|
| return await attempt(link) |
| |
| |
| async def fetch_valid_reel() -> Tuple[Optional[str], Optional[str]]: |
| for _ in range(10): |
| link, pool = get_random_link() |
| if not link: |
| return None, None |
| logger.info(f"Trying {pool} link: {link}") |
| video_path, duration = await send_to_bot_and_get_video(link) |
| if video_path: |
| return video_path, pool |
| await asyncio.sleep(15) |
| return None, None |
|
|
| db = client["youtube"] |
| tokens = db["tokens"] |
|
|
| import os |
| from google.auth.transport.requests import Request |
| from google.oauth2.credentials import Credentials |
| from googleapiclient.discovery import build |
| from googleapiclient.http import MediaFileUpload |
|
|
| |
|
|
| def get_stored_token(): |
| return tokens.find_one({"_id": "youtube_token"}) |
|
|
| def save_token(creds: Credentials): |
| tokens.update_one( |
| {"_id": "youtube_token"}, |
| {"$set": { |
| "access_token": creds.token, |
| "expiry": creds.expiry.isoformat() if creds.expiry else None, |
| }}, |
| upsert=True |
| ) |
|
|
| def upload_to_youtube(video_path, title, desc): |
| |
| stored = get_stored_token() |
| token = stored["access_token"] if stored else None |
| expiry = stored["expiry"] if stored else None |
|
|
| |
| if expiry: |
| expiry = datetime.fromisoformat(expiry) |
| now = datetime.utcnow() |
|
|
| |
| creds = Credentials( |
| token=token if expiry and now < expiry else None, |
| refresh_token=os.getenv("YT_REFRESH_TOKEN"), |
| token_uri="https://oauth2.googleapis.com/token", |
| client_id=os.getenv("YT_CLIENT_ID"), |
| client_secret=os.getenv("YT_CLIENT_SECRET"), |
| scopes=["https://www.googleapis.com/auth/youtube.upload"] |
| ) |
|
|
| |
| if not creds.valid or creds.expired: |
| creds.refresh(Request()) |
| save_token(creds) |
| logger.info("π Refreshed access token") |
|
|
| |
| youtube = build("youtube", "v3", credentials=creds) |
|
|
| |
| request = youtube.videos().insert( |
| part="snippet,status", |
| body={ |
| "snippet": { |
| "title": title, |
| "description": desc, |
| "tags": ["funny", "memes", "comedy", "shorts"], |
| "categoryId": "23" |
| }, |
| "status": { |
| "privacyStatus": "public", |
| "madeForKids": False |
| } |
| }, |
| media_body=MediaFileUpload(video_path) |
| ) |
|
|
| res = request.execute() |
| video_url = f"https://youtube.com/watch?v={res['id']}" |
| logger.info(f"β
Uploaded: {video_url}") |
| return video_url |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| def _decode_b64_cookies() -> str: |
| """Decode IG_COOKIES_B64 into the raw netscape cookies text.""" |
| b64 = os.getenv("IG_COOKIES_B64", "").strip() |
| if not b64: |
| return "" |
| |
| if b64.lower().startswith("data:") and "," in b64: |
| b64 = b64.split(",", 1)[1] |
| b64 = "".join(b64.split()) |
| b64 += "=" * (-len(b64) % 4) |
| try: |
| return base64.b64decode(b64).decode("utf-8", "ignore") |
| except Exception as e: |
| logger.error(f"[IG] Could not base64-decode IG_COOKIES_B64: {e}") |
| return "" |
|
|
|
|
| def parse_netscape_cookies(text: str) -> dict: |
| """Parse a netscape/cookies.txt body into a {name: value} dict.""" |
| cookies = {} |
| for line in text.splitlines(): |
| raw = line.strip() |
| if not raw: |
| continue |
| |
| if raw.startswith("#HttpOnly_"): |
| raw = raw[len("#HttpOnly_"):] |
| elif raw.startswith("#"): |
| continue |
| parts = raw.split("\t") |
| if len(parts) < 7: |
| parts = raw.split() |
| if len(parts) >= 7: |
| cookies[parts[5]] = parts[6] |
| return cookies |
|
|
|
|
| def get_ig_cookies() -> dict: |
| return parse_netscape_cookies(_decode_b64_cookies()) |
|
|
|
|
| def sync_ig_cookies_to_db() -> None: |
| """Mirror the HF env cookies (IG_COOKIES_B64) into MongoDB so the Telegram |
| bot that uploads to Instagram always uses the latest cookies. Change the |
| cookies on Hugging Face and they propagate to the bot automatically.""" |
| b64 = os.getenv("IG_COOKIES_B64", "").strip() |
| if not b64: |
| logger.info("[IG] IG_COOKIES_B64 not set on HF β leaving MongoDB cookies as-is") |
| return |
| |
| if "sessionid" not in get_ig_cookies(): |
| logger.warning("[IG] IG_COOKIES_B64 has no 'sessionid' β not syncing to DB") |
| return |
| try: |
| config.update_one( |
| {"_id": "ig_cookies"}, |
| {"$set": {"cookies_b64": b64}}, |
| upsert=True, |
| ) |
| logger.info("[IG] β
synced IG_COOKIES_B64 (env) β MongoDB for the bot") |
| except Exception as e: |
| logger.warning(f"[IG] could not sync cookies to DB: {e}") |
|
|
|
|
| def _parse_id_csv(raw: str) -> list: |
| """Parse a CSV of Telegram user ids into a list of ints (ignores junk).""" |
| ids = [] |
| for tok in (raw or "").replace(" ", "").split(","): |
| if not tok: |
| continue |
| try: |
| ids.append(int(tok)) |
| except ValueError: |
| logger.warning(f"[bot] ignoring non-numeric authorized id '{tok}'") |
| return ids |
|
|
|
|
| def sync_telegram_config_to_db() -> None: |
| """Store the HF env Telegram settings into MongoDB (config._id='telegram'): |
| β’ BOT_TOKEN β bot_token (the bot's login token) |
| β’ USER_ID β authorized_user_ids (only this user may send videos to the bot) |
| The Koyeb bot logs in with the token and writes back its own bot_id/bot_username, |
| so HF learns where to send. Only provided fields are written (no clobbering).""" |
| token = os.getenv("BOT_TOKEN", "").strip() |
| |
| ids = _parse_id_csv(os.getenv("USER_ID", "") or os.getenv("AUTHORIZED_USER_IDS", "")) |
|
|
| fields = {} |
| if token: |
| fields["bot_token"] = token |
| if ids: |
| fields["authorized_user_ids"] = ids |
|
|
| if not fields: |
| logger.info("[bot] no BOT_TOKEN/USER_ID on HF β leaving MongoDB telegram config as-is") |
| return |
| try: |
| config.update_one({"_id": "telegram"}, {"$set": fields}, upsert=True) |
| logger.info(f"[bot] β
stored Telegram config β MongoDB ({', '.join(fields.keys())})") |
| except Exception as e: |
| logger.warning(f"[bot] could not store Telegram config in DB: {e}") |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def get_bot_target(): |
| """Resolve the Telegram bot the edited video is sent to. The Koyeb bot writes |
| its own bot_username/bot_id into MongoDB after it logs in, so HF reads it from |
| there. IG_BOT_TARGET env is a manual override (e.g. before Koyeb's first run).""" |
| override = os.getenv("IG_BOT_TARGET", "").strip() |
| if override: |
| return override.lstrip("@") |
| try: |
| doc = config.find_one({"_id": "telegram"}) or {} |
| except Exception as e: |
| logger.warning(f"[bot] could not read telegram config from MongoDB: {e}") |
| doc = {} |
| target = doc.get("bot_username") or doc.get("bot_id") or "" |
| if isinstance(target, str): |
| target = target.strip() |
| return target |
|
|
|
|
| async def _send_video_to_bot(video_path: str, caption: str) -> None: |
| target = get_bot_target() |
| if not target: |
| raise RuntimeError( |
| "Telegram bot target unknown " |
| "(set config.telegram.bot_username in MongoDB or IG_BOT_TARGET)" |
| ) |
| if not SESSION_STR: |
| raise RuntimeError("SESSION (telethon user session) is not set β cannot reach the bot") |
| |
| entity = target |
| if isinstance(target, str) and target.lstrip("-").isdigit(): |
| entity = int(target) |
| async with TelegramClient(StringSession(SESSION_STR), API_ID, API_HASH) as tg: |
| bot = await tg.get_entity(entity) |
| |
| |
| await tg.send_file(bot, video_path, caption=caption, force_document=True) |
| logger.info(f"[bot] π€ sent edited video β {target} for Instagram upload") |
|
|
|
|
| def send_video_to_bot(video_path: str, caption: str) -> str: |
| """Deliver the edited Reel to the Instagram Telegram bot (with light retries). |
| Returns a status string; the real Instagram URL is produced asynchronously by |
| the bot, which replies to the sender itself.""" |
| try: |
| attempts = max(1, int(os.getenv("IG_RETRIES", "3"))) |
| except ValueError: |
| attempts = 3 |
| last_err = None |
| for i in range(1, attempts + 1): |
| try: |
| asyncio.run(_send_video_to_bot(video_path, caption)) |
| return "sent-to-telegram-bot" |
| except Exception as e: |
| last_err = e |
| logger.warning(f"[bot] attempt {i}/{attempts} failed: {e}") |
| if i < attempts: |
| try: |
| time.sleep(int(os.getenv("IG_RETRY_SLEEP", "10"))) |
| except ValueError: |
| time.sleep(10) |
| raise last_err |
|
|
|
|
| def generate_ig_caption(part: int) -> str: |
| line = random.choice(CAPTIONS) |
| tags = ("#funny #memes #comedy #reels #reelsindia #viral #trending #fun " |
| "#instareels #explore #foryou #shorts #funnyvideos #comedyreels #laugh") |
| return f"{line}\n.\n.\n{tags}" |
|
|
|
|
| |
| |
| |
|
|
| def prepare_yt_file(master_path: str) -> str: |
| """Optionally UPSCALE the master to MASTER_WIDTH (default 1440) so YouTube |
| transcodes it with the higher-quality VP9 codec β much smaller visible |
| quality drop right after upload. Returns the master unchanged if disabled.""" |
| import shutil, subprocess |
| try: |
| target_w = int(os.getenv("MASTER_WIDTH", "1440") or 0) |
| except ValueError: |
| target_w = 0 |
| if target_w <= 0: |
| return master_path |
| w = _probe_width(master_path) |
| if w and w >= target_w: |
| return master_path |
| ff = shutil.which("ffmpeg") |
| if not ff: |
| logger.warning("[YT] ffmpeg not found β uploading master as-is") |
| return master_path |
| os.makedirs("edited", exist_ok=True) |
| out = f"edited/yt_{uuid.uuid4().hex}.mp4" |
| crf = os.getenv("CRF", "15") |
| cmd = [ |
| ff, "-y", "-i", master_path, |
| "-vf", f"scale={target_w}:-2:flags=lanczos", |
| "-c:v", "libx264", "-preset", os.getenv("SCALE_PRESET", "fast"), |
| "-crf", str(crf), "-pix_fmt", "yuv420p", "-profile:v", "high", |
| "-movflags", "+faststart", "-c:a", "aac", "-b:a", "192k", out, |
| ] |
| try: |
| r = subprocess.run(cmd, capture_output=True, text=True, timeout=1200) |
| if r.returncode == 0 and os.path.exists(out) and os.path.getsize(out) > 0: |
| _log_quality(out) |
| return out |
| logger.warning(f"[YT] upscale failed ({r.returncode}); using master. {r.stderr[-300:]}") |
| except Exception as e: |
| logger.warning(f"[YT] upscale error: {e}; using master") |
| return master_path |
|
|
|
|
| def prepare_ig_file(master_path: str) -> str: |
| """Instagram always recompresses and prefers <=1080px wide. Downscale a big |
| master so IG accepts it cleanly. Set IG_FORCE_9_16=1 to pad to exact 1080x1920.""" |
| import shutil, subprocess |
| force = os.getenv("IG_FORCE_9_16", "0") == "1" |
| w = _probe_width(master_path) |
| if not force and (not w or w <= 1080): |
| return master_path |
| ff = shutil.which("ffmpeg") |
| if not ff: |
| return master_path |
| os.makedirs("edited", exist_ok=True) |
| out = f"edited/ig_{uuid.uuid4().hex}.mp4" |
| if force: |
| vf = ("scale=1080:1920:force_original_aspect_ratio=decrease," |
| "pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,setsar=1") |
| else: |
| vf = "scale=1080:-2:flags=lanczos" |
| cmd = [ |
| ff, "-y", "-i", master_path, "-vf", vf, |
| "-c:v", "libx264", "-preset", os.getenv("SCALE_PRESET", "fast"), |
| "-crf", "16", "-pix_fmt", "yuv420p", "-profile:v", "high", |
| "-movflags", "+faststart", "-c:a", "aac", "-b:a", "192k", out, |
| ] |
| try: |
| r = subprocess.run(cmd, capture_output=True, text=True, timeout=1200) |
| if r.returncode == 0 and os.path.exists(out) and os.path.getsize(out) > 0: |
| _log_quality(out) |
| return out |
| logger.warning(f"[IG] downscale failed ({r.returncode}); using master. {r.stderr[-300:]}") |
| except Exception as e: |
| logger.warning(f"[IG] downscale error: {e}; using master") |
| return master_path |
|
|
|
|
| def upload_to_both(yt_file: str, ig_file: str, yt_title: str, yt_desc: str, ig_caption: str) -> dict: |
| """Upload the SAME video to YouTube and Instagram AT THE SAME TIME. |
| |
| Both run in parallel threads; one platform failing never blocks the other. |
| Returns {"youtube": url?, "instagram": url?, "*_error": msg?}. |
| """ |
| results: dict = {} |
|
|
| def _yt(): |
| try: |
| results["youtube"] = upload_to_youtube(yt_file, yt_title, yt_desc) |
| except Exception as e: |
| logger.error(f"[YT] β upload failed: {e}") |
| results["youtube_error"] = str(e) |
|
|
| def _ig(): |
| |
| |
| if not get_bot_target(): |
| logger.warning("[IG] skipped β no Telegram bot target " |
| "(set config.telegram.bot_username in MongoDB or IG_BOT_TARGET)") |
| results["instagram_error"] = "no bot target" |
| return |
| try: |
| results["instagram"] = send_video_to_bot(ig_file, ig_caption) |
| except Exception as e: |
| logger.error(f"[IG] β send-to-bot failed: {e}") |
| results["instagram_error"] = str(e) |
|
|
| t_yt, t_ig = Thread(target=_yt), Thread(target=_ig) |
| t_yt.start(); t_ig.start() |
| t_yt.join(); t_ig.join() |
| return results |
|
|
|
|
| def _load_slots(): |
| """Daily upload times (IST). Override with UPLOAD_SLOTS="11:30,18:30,20:00". |
| List as many times as you want (e.g. 8 entries within one hour).""" |
| raw = os.getenv("UPLOAD_SLOTS", "").strip() |
| if raw: |
| slots = [] |
| for tok in raw.split(","): |
| tok = tok.strip() |
| if not tok: |
| continue |
| try: |
| h, m = tok.split(":") |
| slots.append((int(h), int(m))) |
| except Exception: |
| logger.warning(f"[slots] ignoring invalid slot '{tok}'") |
| if slots: |
| return slots |
| return [(11, 30), (14, 5), (20, 0)] |
|
|
|
|
| def get_next_part(): |
| last = meta.find_one(sort=[("part", -1)]) |
| return 1 if not last else last["part"] + 1 |
|
|
| def generate_description(title): |
| return f"Watch this hilarious clip: {title}" |
|
|
| def save_to_db(part, title, desc, link): |
| meta.insert_one({"part": part, "title": title, "description": desc, "link": link, "uploaded": time.time()}) |
|
|
| |
|
|
| def auto_loop(): |
| asyncio.set_event_loop(asyncio.new_event_loop()) |
| global UPLOAD_TIMES, NEXT_RESET |
|
|
| from datetime import datetime, timedelta, timezone |
| import time, os |
|
|
| ist = timezone(timedelta(hours=5, minutes=30)) |
|
|
| |
| |
| DAILY_SLOTS = _load_slots() |
|
|
| uploads_done_today = 0 |
| NEXT_RESET = datetime.now(ist).replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) |
|
|
| logger.info(f"[π
] Daily upload slots (IST): {DAILY_SLOTS}") |
| logger.info( |
| "[βΉοΈ] Quality note: every video is encoded with identical max-quality " |
| "settings, so uploading 8 in one hour does NOT lower local quality. Any " |
| "drop you see is YouTube/Instagram server-side transcoding right after " |
| "upload (it finishes HD later β 'recovers next day'). MASTER_WIDTH=1440 " |
| "makes YouTube use the higher-quality VP9 codec to minimise it." |
| ) |
|
|
| |
| |
| |
| sync_telegram_config_to_db() |
| sync_ig_cookies_to_db() |
| logger.info(f"[βΉοΈ] Instagram via Telegram bot: {get_bot_target() or '(not configured)'}") |
|
|
| while True: |
| try: |
| now = datetime.now(ist) |
|
|
| |
| if now >= NEXT_RESET: |
| UPLOAD_TIMES.clear() |
| uploads_done_today = 0 |
| NEXT_RESET = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) |
| logger.info("[π] Daily reset completed.") |
|
|
| |
| if uploads_done_today >= len(DAILY_SLOTS): |
| time.sleep(60) |
| continue |
|
|
| |
| next_slot_hour, next_slot_min = DAILY_SLOTS[uploads_done_today] |
| next_upload_time = now.replace(hour=next_slot_hour, minute=next_slot_min, second=0, microsecond=0) |
|
|
| |
| if now > next_upload_time: |
| uploads_done_today += 1 |
| continue |
|
|
| |
| sleep_seconds = (next_upload_time - now).total_seconds() |
| if sleep_seconds > 0: |
| logger.info(f"[β³] Sleeping until next slot: {next_upload_time.strftime('%I:%M %p')}") |
| time.sleep(sleep_seconds) |
|
|
| |
| logger.info(f"[π] Uploading at golden time: {next_upload_time.strftime('%I:%M %p')}") |
|
|
| video_path, reel_type = asyncio.run(fetch_valid_reel()) |
|
|
| if not video_path: |
| logger.warning("[β οΈ] No valid reel found. Retrying in 5 mins...") |
| time.sleep(300) |
| continue |
|
|
| edited = edit_video_raw(video_path) if reel_type == "raw" else edit_video(video_path) |
|
|
| part = get_next_part() |
| title = f"Try not to laugh || #{part} #funny #memes #comedy #shorts" |
| desc = generate_description(title) |
| ig_caption = generate_ig_caption(part) |
|
|
| |
| yt_file = prepare_yt_file(edited) |
| ig_file = prepare_ig_file(edited) |
|
|
| |
| results = upload_to_both(yt_file, ig_file, title, desc, ig_caption) |
| link = results.get("youtube") or results.get("instagram") |
|
|
| if link: |
| save_to_db(part, title, desc, link) |
| logger.info(f"[π€] Uploaded #{part}: {results}") |
| UPLOAD_TIMES.append(datetime.now(ist)) |
| else: |
| logger.error(f"[β] Both uploads failed for #{part}: {results}") |
| uploads_done_today += 1 |
|
|
| |
| for p in {video_path, edited, yt_file, ig_file}: |
| try: |
| if p and os.path.exists(p): |
| os.remove(p) |
| except OSError: |
| pass |
|
|
| |
| time.sleep(int(os.getenv("POST_UPLOAD_SLEEP", "30"))) |
|
|
| except Exception as e: |
| logger.error(f"[β] Loop error: {e}") |
| time.sleep(60) |
|
|
| |
| if __name__ == "__main__": |
| import asyncio |
| from threading import Thread |
|
|
| Thread(target=lambda: app.run(host="0.0.0.0", port=7860, debug=False, use_reloader=False)).start() |
|
|
| |
| Thread(target=auto_loop, daemon=True).start() |