Create giffer_bot.py
This commit is contained in:
791
giffer_bot.py
Normal file
791
giffer_bot.py
Normal file
@@ -0,0 +1,791 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
import socket
|
||||
import tempfile
|
||||
import subprocess
|
||||
import json
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional, Tuple, Dict, Any, Callable, TypeVar, List
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
try:
|
||||
from urllib3.util.retry import Retry
|
||||
except Exception:
|
||||
Retry = None # type: ignore
|
||||
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
except Exception:
|
||||
imageio_ffmpeg = None # type: ignore
|
||||
|
||||
|
||||
# =======================
|
||||
# ENV loader (Windows-friendly)
|
||||
# =======================
|
||||
|
||||
def load_env_file(path: str = "config.env") -> None:
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for raw in f:
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or line.startswith(";"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
|
||||
if v and v[0] not in "\"'":
|
||||
m = re.search(r"\s+#", v)
|
||||
if m:
|
||||
v = v[: m.start()].rstrip()
|
||||
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
os.environ.setdefault(k, v)
|
||||
|
||||
|
||||
load_env_file()
|
||||
|
||||
|
||||
# =======================
|
||||
# CONFIG
|
||||
# =======================
|
||||
|
||||
MASTODON_BASE_URL = os.getenv("MASTODON_BASE_URL", "https://bronyfurry.com").strip().rstrip("/")
|
||||
MASTODON_ACCESS_TOKEN = os.getenv("MASTODON_ACCESS_TOKEN", "").strip()
|
||||
|
||||
FURBOORU_BASE_URL = os.getenv("FURBOORU_BASE_URL", "https://furbooru.org").strip().rstrip("/")
|
||||
FURBOORU_API_KEY = os.getenv("FURBOORU_API_KEY", "").strip()
|
||||
SAFE_FILTER_ID = os.getenv("SAFE_FILTER_ID", "").strip()
|
||||
NSFW_FILTER_ID = os.getenv("NSFW_FILTER_ID", "").strip()
|
||||
|
||||
USER_AGENT = os.getenv("USER_AGENT", "giffer-bot/3.3 (by @giffer@bronyfurry.com)").strip()
|
||||
|
||||
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "30"))
|
||||
|
||||
# Rate-limit / anti-spam
|
||||
USER_COOLDOWN_SEC = int(os.getenv("USER_COOLDOWN_SEC", "20"))
|
||||
GLOBAL_RATE_PER_SEC = float(os.getenv("GLOBAL_RATE_PER_SEC", "1.0"))
|
||||
GLOBAL_BURST = int(os.getenv("GLOBAL_BURST", "3"))
|
||||
|
||||
# Network hardening
|
||||
CONNECT_TIMEOUT = float(os.getenv("CONNECT_TIMEOUT", "5"))
|
||||
READ_TIMEOUT = float(os.getenv("READ_TIMEOUT", "20"))
|
||||
SOCKET_DEFAULT_TIMEOUT = float(os.getenv("SOCKET_DEFAULT_TIMEOUT", "25"))
|
||||
|
||||
# Watchdog for Mastodon calls
|
||||
MASTO_CALL_TIMEOUT = float(os.getenv("MASTO_CALL_TIMEOUT", "25"))
|
||||
|
||||
# Media download safety
|
||||
MAX_GIF_BYTES = int(os.getenv("MAX_GIF_BYTES", str(25 * 1024 * 1024))) # 25MB
|
||||
DOWNLOAD_TIMEOUT = float(os.getenv("DOWNLOAD_TIMEOUT", "40"))
|
||||
|
||||
# Wait for Mastodon media processing
|
||||
MEDIA_PROCESS_MAX_WAIT = float(os.getenv("MEDIA_PROCESS_MAX_WAIT", "60"))
|
||||
|
||||
NSFW_VISIBILITY = os.getenv("NSFW_VISIBILITY", "public").strip() # public/unlisted/private/direct
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
LOG_FILE = os.getenv("LOG_FILE", "giffer.log").strip() or "giffer.log"
|
||||
|
||||
# State (for "do not reply twice")
|
||||
STATE_FILE = os.getenv("STATE_FILE", "giffer_state.json").strip() or "giffer_state.json"
|
||||
PROCESSED_CACHE_MAX = int(os.getenv("PROCESSED_CACHE_MAX", "800")) # how many status_ids to remember
|
||||
|
||||
|
||||
# =======================
|
||||
# LOGGING
|
||||
# =======================
|
||||
|
||||
logger = logging.getLogger("giffer")
|
||||
logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
|
||||
logger.handlers.clear()
|
||||
|
||||
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
ch = logging.StreamHandler()
|
||||
ch.setFormatter(fmt)
|
||||
logger.addHandler(ch)
|
||||
|
||||
fh = RotatingFileHandler(LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8")
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
|
||||
|
||||
# =======================
|
||||
# GLOBAL NETWORK SETTINGS
|
||||
# =======================
|
||||
|
||||
socket.setdefaulttimeout(SOCKET_DEFAULT_TIMEOUT)
|
||||
|
||||
|
||||
def make_session() -> requests.Session:
|
||||
s = requests.Session()
|
||||
s.headers.update({"User-Agent": USER_AGENT})
|
||||
|
||||
if Retry is not None:
|
||||
retry = Retry(
|
||||
total=3,
|
||||
connect=3,
|
||||
read=2,
|
||||
status=2,
|
||||
backoff_factor=0.6,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=("GET", "POST"),
|
||||
raise_on_status=False,
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10)
|
||||
else:
|
||||
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10)
|
||||
|
||||
s.mount("https://", adapter)
|
||||
s.mount("http://", adapter)
|
||||
return s
|
||||
|
||||
|
||||
http = make_session()
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def run_with_timeout(fn: Callable[[], T], timeout: float, what: str) -> Optional[T]:
|
||||
fut = _executor.submit(fn)
|
||||
try:
|
||||
return fut.result(timeout=timeout)
|
||||
except FuturesTimeoutError:
|
||||
logger.error("Timeout: %s (>%ss). Continuing.", what, timeout)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Error in %s: %s", what, e)
|
||||
return None
|
||||
|
||||
|
||||
# =======================
|
||||
# STATE (persist last_seen + processed status_ids)
|
||||
# =======================
|
||||
|
||||
def load_state() -> Dict[str, Any]:
|
||||
if not os.path.exists(STATE_FILE):
|
||||
return {"last_seen_notif_id": None, "processed_status_ids": []}
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if "last_seen_notif_id" not in data:
|
||||
data["last_seen_notif_id"] = None
|
||||
if "processed_status_ids" not in data or not isinstance(data["processed_status_ids"], list):
|
||||
data["processed_status_ids"] = []
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read state file (%s). Starting fresh.", e)
|
||||
return {"last_seen_notif_id": None, "processed_status_ids": []}
|
||||
|
||||
def save_state(state: Dict[str, Any]) -> None:
|
||||
try:
|
||||
tmp = STATE_FILE + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False)
|
||||
os.replace(tmp, STATE_FILE)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to write state file: %s", e)
|
||||
|
||||
def remember_processed(state: Dict[str, Any], status_id: int) -> None:
|
||||
arr = state.get("processed_status_ids", [])
|
||||
arr.append(int(status_id))
|
||||
# keep last N
|
||||
if len(arr) > PROCESSED_CACHE_MAX:
|
||||
arr = arr[-PROCESSED_CACHE_MAX:]
|
||||
state["processed_status_ids"] = arr
|
||||
|
||||
def is_processed(state: Dict[str, Any], status_id: int) -> bool:
|
||||
try:
|
||||
return int(status_id) in set(state.get("processed_status_ids", []))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# =======================
|
||||
# RATE LIMIT HELPERS
|
||||
# =======================
|
||||
|
||||
_user_last: Dict[str, float] = {}
|
||||
_global_tokens = float(GLOBAL_BURST)
|
||||
_global_last = time.monotonic()
|
||||
|
||||
|
||||
def user_allowed(acct: str) -> bool:
|
||||
now = time.time()
|
||||
last = _user_last.get(acct, 0.0)
|
||||
if now - last < USER_COOLDOWN_SEC:
|
||||
logger.info("Cooldown hit for user=%s (wait %.1fs)", acct, USER_COOLDOWN_SEC - (now - last))
|
||||
return False
|
||||
_user_last[acct] = now
|
||||
return True
|
||||
|
||||
|
||||
def global_wait_if_needed() -> None:
|
||||
global _global_tokens, _global_last
|
||||
now = time.monotonic()
|
||||
elapsed = now - _global_last
|
||||
_global_last = now
|
||||
|
||||
_global_tokens = min(float(GLOBAL_BURST), _global_tokens + elapsed * GLOBAL_RATE_PER_SEC)
|
||||
|
||||
if _global_tokens >= 1.0:
|
||||
_global_tokens -= 1.0
|
||||
return
|
||||
|
||||
need = (1.0 - _global_tokens) / max(GLOBAL_RATE_PER_SEC, 0.001)
|
||||
logger.info("Global rate-limit wait %.2fs", need)
|
||||
time.sleep(max(0.0, need))
|
||||
_global_tokens = 0.0
|
||||
|
||||
|
||||
# =======================
|
||||
# TEXT HELPERS
|
||||
# =======================
|
||||
|
||||
def strip_html(html: str) -> str:
|
||||
text = re.sub(r"<[^>]+>", " ", html)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def parse_query(content_html: str) -> Tuple[str, bool]:
|
||||
text = strip_html(content_html).lower()
|
||||
text = re.sub(r"@\s*[a-z0-9_]+(?:@[a-z0-9\.\-]+)?", " ", text, flags=re.I)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
is_nsfw = bool(re.search(r"\bnsfw\b", text))
|
||||
text = re.sub(r"\bnsfw\b", " ", text).strip()
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text, is_nsfw
|
||||
|
||||
|
||||
def split_tags(query: str) -> List[str]:
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
q = q.replace(",", " ")
|
||||
parts = re.findall(r'"([^"]+)"|(\S+)', q)
|
||||
tokens = []
|
||||
for a, b in parts:
|
||||
t = (a or b).strip()
|
||||
if t:
|
||||
tokens.append(t)
|
||||
tokens = [t for t in tokens if t.lower() not in {"random", "rnd"}]
|
||||
return tokens
|
||||
|
||||
|
||||
def safe_visibility(v: str) -> str:
|
||||
v = (v or "public").strip().lower()
|
||||
return v if v in {"public", "unlisted", "private", "direct"} else "public"
|
||||
|
||||
|
||||
def make_alt_text(img: Dict[str, Any], query: str, is_nsfw: bool) -> str:
|
||||
tags = img.get("tags", [])
|
||||
if isinstance(tags, list):
|
||||
raw = [str(t).strip() for t in tags]
|
||||
elif isinstance(tags, str):
|
||||
raw = [t.strip() for t in tags.split(",")]
|
||||
else:
|
||||
raw = []
|
||||
|
||||
block = {"safe", "questionable", "explicit", "nsfw", "sfw", "animated", "gif"}
|
||||
cleaned = []
|
||||
for t in raw:
|
||||
if not t:
|
||||
continue
|
||||
t2 = t.lower().strip()
|
||||
if t2 in block:
|
||||
continue
|
||||
cleaned.append(t2.replace("_", " "))
|
||||
|
||||
seen = set()
|
||||
uniq = []
|
||||
for t in cleaned:
|
||||
if t in seen:
|
||||
continue
|
||||
seen.add(t)
|
||||
uniq.append(t)
|
||||
|
||||
prefix = "NSFW animated GIF" if is_nsfw else "Animated GIF"
|
||||
if uniq:
|
||||
return (f"{prefix}: " + ", ".join(uniq[:20]))[:900]
|
||||
if query:
|
||||
return f"{prefix} (from query): {query[:900]}"
|
||||
return prefix
|
||||
|
||||
|
||||
def source_link(img: Dict[str, Any]) -> str:
|
||||
vu = img.get("view_url")
|
||||
if isinstance(vu, str) and vu.startswith("http"):
|
||||
return vu
|
||||
img_id = img.get("id")
|
||||
if img_id is not None:
|
||||
return f"{FURBOORU_BASE_URL}/images/{img_id}"
|
||||
return FURBOORU_BASE_URL
|
||||
|
||||
|
||||
# =======================
|
||||
# FURBOORU
|
||||
# =======================
|
||||
|
||||
def furbooru_search_gif(query: str, nsfw: bool) -> Optional[Dict[str, Any]]:
|
||||
global_wait_if_needed()
|
||||
|
||||
url = f"{FURBOORU_BASE_URL}/api/v1/json/search/images"
|
||||
base_tags = ["animated", "gif"] if nsfw else ["safe", "animated", "gif"]
|
||||
user_tags = split_tags(query)
|
||||
all_tags = user_tags + base_tags
|
||||
q = ", ".join(all_tags) if all_tags else ", ".join(base_tags)
|
||||
|
||||
params: Dict[str, Any] = {"q": q, "per_page": 50, "page": 1}
|
||||
if FURBOORU_API_KEY:
|
||||
params["key"] = FURBOORU_API_KEY
|
||||
if nsfw and NSFW_FILTER_ID:
|
||||
params["filter_id"] = NSFW_FILTER_ID
|
||||
if (not nsfw) and SAFE_FILTER_ID:
|
||||
params["filter_id"] = SAFE_FILTER_ID
|
||||
|
||||
backoff = 1.0
|
||||
for attempt in range(4):
|
||||
logger.info("Furbooru search attempt=%d q=%s", attempt + 1, q)
|
||||
try:
|
||||
r = http.get(url, params=params, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT))
|
||||
except requests.RequestException as e:
|
||||
logger.error("Furbooru request error: %s", e)
|
||||
return None
|
||||
|
||||
if r.status_code == 429:
|
||||
logger.warning("Furbooru 429 Too Many Requests. Backoff %.1fs", backoff)
|
||||
time.sleep(backoff)
|
||||
backoff *= 2
|
||||
continue
|
||||
|
||||
if r.status_code >= 400:
|
||||
logger.error("Furbooru HTTP %s: %r", r.status_code, (r.text or "")[:200])
|
||||
return None
|
||||
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception as e:
|
||||
logger.error("Furbooru JSON parse error: %s", e)
|
||||
return None
|
||||
|
||||
images = data.get("images", [])
|
||||
candidates = []
|
||||
for img in images:
|
||||
if img.get("format") != "gif":
|
||||
continue
|
||||
if not img.get("thumbnails_generated", False):
|
||||
continue
|
||||
reps = img.get("representations") or {}
|
||||
if not any(reps.get(k) for k in ("full", "large", "medium", "small", "thumb")):
|
||||
continue
|
||||
candidates.append(img)
|
||||
|
||||
logger.info("Furbooru found=%d candidates=%d", len(images), len(candidates))
|
||||
return random.choice(candidates) if candidates else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def representation_candidates(img: Dict[str, Any]) -> List[str]:
|
||||
reps = img.get("representations") or {}
|
||||
keys = ["full", "large", "medium", "small", "thumb"]
|
||||
out: List[str] = []
|
||||
for k in keys:
|
||||
u = reps.get(k)
|
||||
if isinstance(u, str) and u.startswith("http") and u not in out:
|
||||
out.append(u)
|
||||
return out
|
||||
|
||||
|
||||
def download_bytes(url: str, max_bytes: int) -> Optional[bytes]:
|
||||
logger.info("Downloading %s", url)
|
||||
try:
|
||||
with http.get(url, timeout=(CONNECT_TIMEOUT, DOWNLOAD_TIMEOUT), stream=True) as r:
|
||||
if r.status_code >= 400:
|
||||
logger.error("Download HTTP %s", r.status_code)
|
||||
return None
|
||||
total = 0
|
||||
chunks = []
|
||||
for chunk in r.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise ValueError(f"File too large (> {max_bytes} bytes)")
|
||||
chunks.append(chunk)
|
||||
logger.info("Downloaded bytes=%d", total)
|
||||
return b"".join(chunks)
|
||||
except ValueError:
|
||||
raise
|
||||
except requests.RequestException as e:
|
||||
logger.error("Download error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# =======================
|
||||
# GIF -> MP4 (ffmpeg)
|
||||
# =======================
|
||||
|
||||
def find_ffmpeg_exe() -> Optional[str]:
|
||||
for cmd in ("ffmpeg", "ffmpeg.exe"):
|
||||
try:
|
||||
p = subprocess.run([cmd, "-version"], capture_output=True, text=True)
|
||||
if p.returncode == 0:
|
||||
return cmd
|
||||
except Exception:
|
||||
pass
|
||||
if imageio_ffmpeg is not None:
|
||||
try:
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def gif_bytes_to_mp4(gif_bytes: bytes) -> Optional[bytes]:
|
||||
ffmpeg = find_ffmpeg_exe()
|
||||
if not ffmpeg:
|
||||
logger.error("FFmpeg not found. Install ffmpeg or `pip install imageio-ffmpeg`.")
|
||||
return None
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
in_gif = os.path.join(td, "in.gif")
|
||||
out_mp4 = os.path.join(td, "out.mp4")
|
||||
|
||||
with open(in_gif, "wb") as f:
|
||||
f.write(gif_bytes)
|
||||
|
||||
cmd = [
|
||||
ffmpeg, "-y",
|
||||
"-i", in_gif,
|
||||
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
||||
"-movflags", "+faststart",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "28",
|
||||
"-an",
|
||||
out_mp4
|
||||
]
|
||||
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
except Exception as e:
|
||||
logger.error("FFmpeg execution error: %s", e)
|
||||
return None
|
||||
|
||||
if p.returncode != 0:
|
||||
logger.error("FFmpeg failed: %s", (p.stderr or "")[-400:])
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(out_mp4, "rb") as f:
|
||||
mp4 = f.read()
|
||||
logger.info("Converted GIF->MP4 bytes=%d", len(mp4))
|
||||
return mp4
|
||||
except Exception as e:
|
||||
logger.error("Reading MP4 failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# =======================
|
||||
# MASTODON
|
||||
# =======================
|
||||
|
||||
def init_mastodon():
|
||||
if not MASTODON_ACCESS_TOKEN:
|
||||
raise SystemExit("MASTODON_ACCESS_TOKEN is empty. Put it into config.env or env vars.")
|
||||
from mastodon import Mastodon
|
||||
return Mastodon(
|
||||
access_token=MASTODON_ACCESS_TOKEN,
|
||||
api_base_url=MASTODON_BASE_URL,
|
||||
user_agent=USER_AGENT,
|
||||
request_timeout=max(CONNECT_TIMEOUT + READ_TIMEOUT, 10.0),
|
||||
)
|
||||
|
||||
|
||||
def upload_media(mastodon, data: bytes, mime: str, alt_text: str) -> Optional[int]:
|
||||
bio = io.BytesIO(data)
|
||||
bio.name = "giffer." + ("mp4" if mime == "video/mp4" else "gif")
|
||||
logger.info("Uploading media mime=%s (alt_len=%d)", mime, len(alt_text))
|
||||
|
||||
def _call():
|
||||
return mastodon.media_post(media_file=bio, mime_type=mime, description=alt_text)
|
||||
|
||||
media = run_with_timeout(_call, MASTO_CALL_TIMEOUT, f"mastodon.media_post({mime})")
|
||||
if not media:
|
||||
return None
|
||||
return int(media["id"])
|
||||
|
||||
|
||||
def wait_media_ready(mastodon, media_id: int, max_wait: float) -> bool:
|
||||
start = time.time()
|
||||
delay = 0.6
|
||||
while time.time() - start < max_wait:
|
||||
att = run_with_timeout(lambda: mastodon.media(media_id), MASTO_CALL_TIMEOUT, "mastodon.media(get)")
|
||||
if att and att.get("url"):
|
||||
return True
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 1.4, 3.0)
|
||||
return False
|
||||
|
||||
|
||||
def post_reply_safe(mastodon, status_id: int, text: str, visibility: str) -> None:
|
||||
def _call():
|
||||
return mastodon.status_post(text, in_reply_to_id=status_id, visibility=visibility)
|
||||
run_with_timeout(_call, MASTO_CALL_TIMEOUT, "mastodon.status_post(reply)")
|
||||
|
||||
|
||||
def post_status_with_media(mastodon, status_id: int, text: str, media_id: int,
|
||||
visibility: str, nsfw: bool) -> bool:
|
||||
if nsfw:
|
||||
def _call():
|
||||
return mastodon.status_post(
|
||||
text,
|
||||
in_reply_to_id=status_id,
|
||||
media_ids=[media_id],
|
||||
sensitive=True,
|
||||
spoiler_text="NSFW",
|
||||
visibility=visibility,
|
||||
)
|
||||
res = run_with_timeout(_call, MASTO_CALL_TIMEOUT, "mastodon.status_post(nsfw)")
|
||||
return res is not None
|
||||
else:
|
||||
def _call():
|
||||
return mastodon.status_post(
|
||||
text,
|
||||
in_reply_to_id=status_id,
|
||||
media_ids=[media_id],
|
||||
visibility=visibility,
|
||||
)
|
||||
res = run_with_timeout(_call, MASTO_CALL_TIMEOUT, "mastodon.status_post(sfw)")
|
||||
return res is not None
|
||||
|
||||
|
||||
def is_mastodon_422_unsupported(err_msg: str) -> bool:
|
||||
s = (err_msg or "").lower()
|
||||
return (" 422" in s or "unprocessable" in s) and ("not supported" in s or "gif" in s or "supported" in s)
|
||||
|
||||
|
||||
def upload_gif_then_mp4_fallback(mastodon, img: Dict[str, Any], alt: str) -> Tuple[Optional[int], str]:
|
||||
urls = representation_candidates(img)
|
||||
if not urls:
|
||||
return None, ""
|
||||
|
||||
last_err = ""
|
||||
|
||||
# GIF attempts (big -> small)
|
||||
for u in urls:
|
||||
try:
|
||||
gif_bytes = download_bytes(u, MAX_GIF_BYTES)
|
||||
if not gif_bytes:
|
||||
last_err = "download failed"
|
||||
continue
|
||||
mid = upload_media(mastodon, gif_bytes, "image/gif", alt)
|
||||
if mid is not None:
|
||||
return mid, "image/gif"
|
||||
last_err = "upload timeout/none"
|
||||
except Exception as e:
|
||||
last_err = str(e)
|
||||
if is_mastodon_422_unsupported(last_err):
|
||||
logger.warning("GIF rejected (422) for %s: %s. Trying smaller...", u, last_err)
|
||||
continue
|
||||
logger.error("GIF upload failed hard: %s", last_err)
|
||||
break
|
||||
|
||||
# MP4 fallback (use smallest downloadable gif)
|
||||
logger.info("Trying MP4 fallback...")
|
||||
gif_bytes = None
|
||||
for u in reversed(urls):
|
||||
b = download_bytes(u, MAX_GIF_BYTES)
|
||||
if b:
|
||||
gif_bytes = b
|
||||
break
|
||||
if not gif_bytes:
|
||||
logger.error("Cannot download any GIF for MP4 conversion. Last error: %s", last_err)
|
||||
return None, ""
|
||||
|
||||
mp4 = gif_bytes_to_mp4(gif_bytes)
|
||||
if not mp4:
|
||||
return None, ""
|
||||
|
||||
mid = upload_media(mastodon, mp4, "video/mp4", alt)
|
||||
if mid is None:
|
||||
return None, ""
|
||||
return mid, "video/mp4"
|
||||
|
||||
|
||||
# =======================
|
||||
# MAIN LOOP
|
||||
# =======================
|
||||
|
||||
def reply_text(prefix: str, query: str, src: str, used_mime: str) -> str:
|
||||
q = query if query else "random"
|
||||
note = "\n(конвертировано в MP4 из-за ограничений инстанса)" if used_mime == "video/mp4" else ""
|
||||
return f"{prefix} по запросу: `{q}`{note}\nОриг: {src}"
|
||||
|
||||
|
||||
def main():
|
||||
state = load_state()
|
||||
last_seen_id = state.get("last_seen_notif_id", None)
|
||||
|
||||
# Convert to int if it's stored as string
|
||||
try:
|
||||
if last_seen_id is not None:
|
||||
last_seen_id = int(last_seen_id)
|
||||
except Exception:
|
||||
last_seen_id = None
|
||||
|
||||
processed_set = set(int(x) for x in state.get("processed_status_ids", []) if isinstance(x, int) or str(x).isdigit())
|
||||
# normalize back into state list
|
||||
state["processed_status_ids"] = list(processed_set)[-PROCESSED_CACHE_MAX:]
|
||||
save_state(state)
|
||||
|
||||
mastodon = init_mastodon()
|
||||
from mastodon import MastodonError
|
||||
|
||||
logger.info("Starting giffer bot on %s (Furbooru: %s)", MASTODON_BASE_URL, FURBOORU_BASE_URL)
|
||||
logger.info("Loaded state: last_seen_notif_id=%s processed_cache=%d", last_seen_id, len(state["processed_status_ids"]))
|
||||
|
||||
while True:
|
||||
logger.info("Polling mentions... since_id=%s", last_seen_id)
|
||||
|
||||
def _fetch():
|
||||
return mastodon.notifications(types=["mention"], since_id=last_seen_id)
|
||||
|
||||
notifs = run_with_timeout(_fetch, MASTO_CALL_TIMEOUT, "mastodon.notifications(mention)")
|
||||
if notifs is None:
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
continue
|
||||
|
||||
logger.info("Got %d mention notifications", len(notifs))
|
||||
|
||||
for notif in reversed(notifs):
|
||||
notif_id = notif.get("id")
|
||||
if notif_id is not None:
|
||||
try:
|
||||
last_seen_id = int(notif_id)
|
||||
state["last_seen_notif_id"] = last_seen_id
|
||||
save_state(state) # <- important: persist immediately
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
acct = notif.get("account", {}).get("acct", "unknown")
|
||||
status = notif.get("status") or {}
|
||||
status_id = status.get("id")
|
||||
if not status_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
status_id_int = int(status_id)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# ---- NEW: don't reply twice ----
|
||||
if status_id_int in processed_set:
|
||||
logger.info("Skipping already processed status_id=%s", status_id_int)
|
||||
continue
|
||||
|
||||
# mark as processed early to prevent double-processing if we crash mid-way
|
||||
processed_set.add(status_id_int)
|
||||
remember_processed(state, status_id_int)
|
||||
save_state(state)
|
||||
|
||||
if not user_allowed(acct):
|
||||
continue
|
||||
|
||||
query, is_nsfw = parse_query(status.get("content", ""))
|
||||
logger.info("Mention from=%s nsfw=%s query=%r status_id=%s", acct, is_nsfw, query, status_id_int)
|
||||
|
||||
try:
|
||||
img = furbooru_search_gif(query, is_nsfw)
|
||||
if not img:
|
||||
post_reply_safe(
|
||||
mastodon,
|
||||
status_id_int,
|
||||
f"Ничего не нашёл по запросу: `{query or 'random'}` 😿",
|
||||
safe_visibility(status.get("visibility", "public")),
|
||||
)
|
||||
continue
|
||||
|
||||
src = source_link(img)
|
||||
alt = make_alt_text(img, query, is_nsfw)
|
||||
|
||||
media_id, used_mime = upload_gif_then_mp4_fallback(mastodon, img, alt)
|
||||
if not media_id:
|
||||
post_reply_safe(
|
||||
mastodon,
|
||||
status_id_int,
|
||||
"Не смог загрузить (инстанс отклоняет GIF/видео или проблемы сети). Попробуй другой запрос 🙏",
|
||||
safe_visibility(status.get("visibility", "public")),
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info("Uploaded media_id=%s mime=%s. Waiting processing...", media_id, used_mime)
|
||||
if not wait_media_ready(mastodon, media_id, max_wait=MEDIA_PROCESS_MAX_WAIT):
|
||||
post_reply_safe(
|
||||
mastodon,
|
||||
status_id_int,
|
||||
"Медиа загрузилось, но Mastodon слишком долго его обрабатывает. Попробуй ещё раз через минуту 🙏",
|
||||
safe_visibility(status.get("visibility", "public")),
|
||||
)
|
||||
continue
|
||||
|
||||
visibility = safe_visibility(NSFW_VISIBILITY if is_nsfw else status.get("visibility", "public"))
|
||||
text = reply_text("NSFW" if is_nsfw else "GIF", query, src, used_mime)
|
||||
|
||||
posted = post_status_with_media(mastodon, status_id_int, text, media_id, visibility, nsfw=is_nsfw)
|
||||
if posted:
|
||||
logger.info("Posted successfully for user=%s", acct)
|
||||
else:
|
||||
logger.error("Failed to post status for user=%s (status_id=%s)", acct, status_id_int)
|
||||
|
||||
except ValueError as e:
|
||||
post_reply_safe(
|
||||
mastodon,
|
||||
status_id_int,
|
||||
f"Не могу загрузить: {e}",
|
||||
safe_visibility(status.get("visibility", "public")),
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
post_reply_safe(
|
||||
mastodon,
|
||||
status_id_int,
|
||||
f"Ошибка сети: {e}",
|
||||
safe_visibility(status.get("visibility", "public")),
|
||||
)
|
||||
except MastodonError as e:
|
||||
logger.error("Mastodon error: %s", e)
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error: %s", e)
|
||||
post_reply_safe(
|
||||
mastodon,
|
||||
status_id_int,
|
||||
f"Неожиданная ошибка: {e}",
|
||||
safe_visibility(status.get("visibility", "public")),
|
||||
)
|
||||
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user