Compare commits

...
5 Commits
Author SHA1 Message Date
esefandClaude Opus 5 9d1f6778c4 tgbridge: shrink oversize gifs sent as files, not just as animations
A .gif uploaded as a document never matches the animation branch, so it
went to discord untouched and came back 413. First real test of the local
api server hit exactly this: a 27.2 MB gif fetched fine, then bounced.

Same ladder, same .gif out — that file lands at 5.73 MB.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-31 14:41:11 +03:00
esefandClaude Opus 5 78c755a7a2 tgbridge: support a self-hosted bot api server, and say when a file is too big
The cloud api refuses getFile over 20 MB, so an oversize video or gif never
reached ffmpeg and the bridge blamed the fetch. Six of the nine media
failures in the last month were that; the other three were a dropped read.

- config.local_api() turns TELEGRAM_API_BASE_URL into base_url/base_file_url/
  local_mode. Both directions read it, or only one of them migrates.
- in local mode getFile hands back a path on disk and ptb reads it directly,
  so there is no 20 MB cap and no http transfer to drop.
- MAX_INGEST_BYTES keeps that from being a way to pull a gigabyte into the
  bot's memory; falls back to stat() when the server omits file_size.
- explicit read_timeout: ptb defaults to 5s, which is what "Timed out" was.
  one retry covers a dropped read. BadRequest subclasses NetworkError, so it
  is re-raised first or an oversize rejection gets re-sent.
- the note now distinguishes too-large from could-not-fetch, and the warning
  records which attachment and which message.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-31 13:36:11 +03:00
esef 460f4bf78c fix the fucknig timezone 2026-10-29 00:49:49 +03:00
esef ff579807f1 fuck you php 2026-10-21 22:59:39 +03:00
esefandClaude Opus 5 810a9a6b12 blue_archive: refresh bundled roster (264 students)
Weekly discordbot-refresh timer output, committed from the server.
Adds Nagusa (Swimsuit); nothing removed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-20 13:16:57 +03:00
10 changed files with 208 additions and 35 deletions

No files matched your search

+1
View File
@@ -28,3 +28,4 @@ _check_students.py
_check_names.py _check_names.py
_check_banner_api.py _check_banner_api.py
credentials_test.txt
+1 -1
View File
@@ -1 +1 @@
[{"Id": 10000, "IsReleased": [true, true, true], "DefaultOrder": 0, "PathName": "Aru", "DevName": "Aru", "Name": "Aru", "School": "Gehenna", "Club": "Kohshinjo68", "StarGrade": 3, "SquadType": "Main", "TacticRole": "DamageDealer", "Summons": [], "Position": "Back", "BulletType": "Explosion", "ArmorType": "LightArmor", "StreetBattleAdaptation": 4, "OutdoorBattleAdaptation": 2, "IndoorBattleAdaptation": 0, "WeaponType": "SR", "WeaponImg": "weapon_icon_10000", "Cover": true, "Equipment": ["Hat", "Hairpin", "Watch"], "CollectionBG": "BG_Gehenna_Collection", "FamilyName": "Rikuhachima", "PersonalName": "Aru", "SchoolYear": "2nd Year", "CharacterAge": "16 years old", "Birthday": "March 12th", "CharacterSSRNew": "Heh heh. Good choice.", "ProfileIntroduction": "The self-proclaimed boss of Problem Solver 68.\n\nAru expanded Gehenna Academy's Problem Solver 68 club without permission to conduct illegal business activities. She aspires to be seen as a dashing and debonair villain, but her frequent blunders always expose her as a poser.", "Hobby": "Studying management", "CharacterVoice": "Reina Kondō", "BirthDay": "3/12", "Illustrator": "DoReMi", "Designer": "DoReMi", "CharHeightMetric": "160cm", "CharHeightImperial": "5'2\"", "StabilityPoint": 1988, "AttackPower1": 369, "AttackPower100": 3690, "MaxHP1": 2236, "MaxHP100": 19390, "DefensePower1": 19, "DefensePower100": 119, "HealPower1": 1408, "HealPower100": 4225, "DodgePoint": 201, "AccuracyPoint": 905, "CriticalPoint": 201, "CriticalDamageRate": 20000, "AmmoCount": 5, "AmmoCost": 1, "Range": 750, "RegenCost": 700, "Skills": [{"SkillType": "autoattack", "Effects": [{"Type": "DMGSingle", "Hits": [10000], "Scale": [10000], "Frames": {"AttackEnterDuration": 45, "AttackStartDuration": 15, "AttackEndDuration": 16, "AttackBurstRoundOverDelay": 50, "AttackIngDuration": 42, "AttackReloadDuration": 70}, "CriticalCheck": "Check"}]}, {"SkillType": "ex", "Name": "Hard-Boiled Shot", "Desc": "Fires a shot that deals <?1> damage to one enemy and explodes for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["274%", "315%", "397%", "438%", "521%"], ["292%", "335%", "423%", "467%", "554%"]], "Cost": [4, 4, 4, 4, 4], "Duration": 132, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 200}], "Icon": "COMMON_SKILLICON_CIRCLE", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [27438, 31554, 39781, 43898, 52125]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [29207, 33584, 42344, 46727, 55487]}]}, {"SkillType": "normal", "Name": "Noir Attack", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 50% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["152%", "160%", "168%", "198%", "206%", "213%", "244%", "251%", "259%", "290%"], ["251%", "263%", "276%", "326%", "338%", "351%", "401%", "414%", "426%", "476%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15270, 16035, 16801, 19851, 20617, 21376, 24433, 25198, 25958, 29014]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25102, 26355, 27608, 32631, 33884, 35137, 40155, 41414, 42667, 47685]}]}, {"SkillType": "gearnormal", "Name": "Noir Attack+", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 100% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["156%", "163%", "171%", "203%", "210%", "218%", "249%", "257%", "265%", "296%"], ["256%", "269%", "282%", "333%", "346%", "359%", "410%", "423%", "436%", "487%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15616, 16398, 17181, 20305, 21082, 21864, 24988, 25771, 26547, 29672]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25669, 26956, 28237, 33368, 34656, 35937, 41068, 42355, 43637, 48768]}]}, {"SkillType": "passive", "Name": "The Gravity of a Boss", "Desc": "Increases <b:CriticalDamage> by <?1>.", "Parameters": [["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Coefficient", "Scale": [1400, 1470, 1540, 1820, 1890, 1960, 2240, 2310, 2380, 2660]}]}, {"SkillType": "weaponpassive", "Name": "The Gravity of a Boss+", "Desc": "Increases <b:CriticalDamage> by <?1> plus a further <?2>.", "Parameters": [["2000", "2100", "2200", "2600", "2700", "2800", "3200", "3300", "3400", "3800"], ["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Base", "Scale": [2000, 2100, 2200, 2600, 2700,Line truncated [{"Id": 10000, "IsReleased": [true, true, true], "DefaultOrder": 0, "PathName": "Aru", "DevName": "Aru", "Name": "Aru", "School": "Gehenna", "Club": "Kohshinjo68", "StarGrade": 3, "SquadType": "Main", "TacticRole": "DamageDealer", "Summons": [], "Position": "Back", "BulletType": "Explosion", "ArmorType": "LightArmor", "StreetBattleAdaptation": 4, "OutdoorBattleAdaptation": 2, "IndoorBattleAdaptation": 0, "WeaponType": "SR", "WeaponImg": "weapon_icon_10000", "Cover": true, "Equipment": ["Hat", "Hairpin", "Watch"], "CollectionBG": "BG_Gehenna_Collection", "FamilyName": "Rikuhachima", "PersonalName": "Aru", "SchoolYear": "2nd Year", "CharacterAge": "16 years old", "Birthday": "March 12th", "CharacterSSRNew": "Heh heh. Good choice.", "ProfileIntroduction": "The self-proclaimed boss of Problem Solver 68.\n\nAru expanded Gehenna Academy's Problem Solver 68 club without permission to conduct illegal business activities. She aspires to be seen as a dashing and debonair villain, but her frequent blunders always expose her as a poser.", "Hobby": "Studying management", "CharacterVoice": "Reina Kondō", "BirthDay": "3/12", "Illustrator": "DoReMi", "Designer": "DoReMi", "CharHeightMetric": "160cm", "CharHeightImperial": "5'2\"", "StabilityPoint": 1988, "AttackPower1": 369, "AttackPower100": 3690, "MaxHP1": 2236, "MaxHP100": 19390, "DefensePower1": 19, "DefensePower100": 119, "HealPower1": 1408, "HealPower100": 4225, "DodgePoint": 201, "AccuracyPoint": 905, "CriticalPoint": 201, "CriticalDamageRate": 20000, "AmmoCount": 5, "AmmoCost": 1, "Range": 750, "RegenCost": 700, "Skills": [{"SkillType": "autoattack", "Effects": [{"Type": "DMGSingle", "Hits": [10000], "Scale": [10000], "Frames": {"AttackEnterDuration": 45, "AttackStartDuration": 15, "AttackEndDuration": 16, "AttackBurstRoundOverDelay": 50, "AttackIngDuration": 42, "AttackReloadDuration": 70}, "CriticalCheck": "Check"}]}, {"SkillType": "ex", "Name": "Hard-Boiled Shot", "Desc": "Fires a shot that deals <?1> damage to one enemy and explodes for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["274%", "315%", "397%", "438%", "521%"], ["292%", "335%", "423%", "467%", "554%"]], "Cost": [4, 4, 4, 4, 4], "Duration": 132, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 200}], "Icon": "COMMON_SKILLICON_CIRCLE", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [27438, 31554, 39781, 43898, 52125]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [29207, 33584, 42344, 46727, 55487]}]}, {"SkillType": "normal", "Name": "Noir Attack", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 50% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["152%", "160%", "168%", "198%", "206%", "213%", "244%", "251%", "259%", "290%"], ["251%", "263%", "276%", "326%", "338%", "351%", "401%", "414%", "426%", "476%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15270, 16035, 16801, 19851, 20617, 21376, 24433, 25198, 25958, 29014]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25102, 26355, 27608, 32631, 33884, 35137, 40155, 41414, 42667, 47685]}]}, {"SkillType": "gearnormal", "Name": "Noir Attack+", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 100% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["156%", "163%", "171%", "203%", "210%", "218%", "249%", "257%", "265%", "296%"], ["256%", "269%", "282%", "333%", "346%", "359%", "410%", "423%", "436%", "487%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15616, 16398, 17181, 20305, 21082, 21864, 24988, 25771, 26547, 29672]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25669, 26956, 28237, 33368, 34656, 35937, 41068, 42355, 43637, 48768]}]}, {"SkillType": "passive", "Name": "The Gravity of a Boss", "Desc": "Increases <b:CriticalDamage> by <?1>.", "Parameters": [["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Coefficient", "Scale": [1400, 1470, 1540, 1820, 1890, 1960, 2240, 2310, 2380, 2660]}]}, {"SkillType": "weaponpassive", "Name": "The Gravity of a Boss+", "Desc": "Increases <b:CriticalDamage> by <?1> plus a further <?2>.", "Parameters": [["2000", "2100", "2200", "2600", "2700", "2800", "3200", "3300", "3400", "3800"], ["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Base", "Scale": [2000, 2100, 2200, 2600, 2700,Line truncated
+8
View File
@@ -8,6 +8,7 @@ _PKG_DIR = Path(__file__).parent
_DATA_DIR = _PKG_DIR.parent / "data" _DATA_DIR = _PKG_DIR.parent / "data"
BUNDLED_DB = _PKG_DIR / "ba_students.json" BUNDLED_DB = _PKG_DIR / "ba_students.json"
BANNER_FILE = _DATA_DIR / "ba_banner_state.json" BANNER_FILE = _DATA_DIR / "ba_banner_state.json"
PORTRAIT_CACHE_DIR = _DATA_DIR / "portraits"
ASSETS_DIR = _PKG_DIR / "assets" ASSETS_DIR = _PKG_DIR / "assets"
BG_PATH = ASSETS_DIR / "gacha_bg.png" BG_PATH = ASSETS_DIR / "gacha_bg.png"
GACHA_ANIM_PATH = ASSETS_DIR / "Arona_Blue_Gacha_Animation.gif" GACHA_ANIM_PATH = ASSETS_DIR / "Arona_Blue_Gacha_Animation.gif"
@@ -17,6 +18,9 @@ GACHA_ANIM_DURATION = 3.5 # 195 frames × 100 ms (used to be 19.5 will be chang
BANNER_API = "https://api.ennead.cc/buruaka/banner" BANNER_API = "https://api.ennead.cc/buruaka/banner"
CHARACTER_API = "https://api.ennead.cc/buruaka/character" CHARACTER_API = "https://api.ennead.cc/buruaka/character"
PORTRAIT_CDN = "https://schaledb.com/images" PORTRAIT_CDN = "https://schaledb.com/images"
# Wiki art is the nicest but its host goes down for days at a time. Once a
# student is cached from a lesser source, re-check the wiki this often.
WIKI_RETRY_HOURS = 24
WIKI_PORTRAIT_BASE = "https://bluearchive.wiki/wiki/Special:FilePath/" WIKI_PORTRAIT_BASE = "https://bluearchive.wiki/wiki/Special:FilePath/"
WIKI_VARIANT_MAP: dict[str, str] = { WIKI_VARIANT_MAP: dict[str, str] = {
"Bunny": "Bunny Girl", "Bunny": "Bunny Girl",
@@ -80,6 +84,10 @@ STAR_BANNER_H = 36
NAME_STRIP_H = 42 NAME_STRIP_H = 42
PORTRAIT_TOP = STAR_BANNER_H + 4 PORTRAIT_TOP = STAR_BANNER_H + 4
PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4 PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4
# Fraction of a full-body sprite kept when cropping it to a bust, and the
# fraction of it searched to locate the head horizontally.
BUST_HEIGHT_FRAC = 0.45
BUST_HEAD_FRAC = 0.25
# ── Inventory display ─────────────────────────────────────────────────────── # ── Inventory display ───────────────────────────────────────────────────────
PAGE_SIZE = 10 PAGE_SIZE = 10
+5 -1
View File
@@ -355,8 +355,12 @@ class StudentDB:
name = f"{base.strip()} ({variant})" name = f"{base.strip()} ({variant})"
return WIKI_PORTRAIT_BASE + quote(f"Portrait {name}.png", safe="") return WIKI_PORTRAIT_BASE + quote(f"Portrait {name}.png", safe="")
def cdn_collection(self, student: dict) -> str:
"""SchaleDB CDN collection art URL (in-game card portrait, keyed by ID)."""
return f"{PORTRAIT_CDN}/student/collection/{student['Id']}.webp"
def cdn_sprite(self, student: dict) -> str: def cdn_sprite(self, student: dict) -> str:
"""SchaleDB CDN full-body sprite URL (fallback, keyed by ID).""" """SchaleDB CDN full-body sprite URL (last resort, keyed by ID)."""
return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp" return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp"
+83 -11
View File
@@ -6,6 +6,9 @@ import functools
import io import io
import logging import logging
import math import math
import os
import time
import uuid
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -21,7 +24,9 @@ from .constants import (
HEADER_Y, FOOTER_Y, HEADER_Y, FOOTER_Y,
STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H, STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H,
RARITY_COLOR, RARITY_BG, RARITY_COLOR, RARITY_BG,
BUST_HEIGHT_FRAC, BUST_HEAD_FRAC,
BG_PATH, SPARK_TARGET, BG_PATH, SPARK_TARGET,
PORTRAIT_CACHE_DIR, WIKI_RETRY_HOURS,
FONT_PATHS, FONT_PATHS,
) )
@@ -47,20 +52,87 @@ FOOTER_FONT = _get_font(18, bold=False)
# ── Image fetching ───────────────────────────────────────────────────────── # ── Image fetching ─────────────────────────────────────────────────────────
def _bust_crop(img: Image.Image) -> Image.Image:
"""Crop a full-body sprite to head-and-shoulders so it fills the card.
Anchored on the alpha box top (Kid variants sit lower in the frame) and on
the alpha-weighted centre of the top slice, so thin props like rifles and
parasols barely pull the crop off the face.
"""
alpha = img.getchannel("A")
box = alpha.getbbox()
if not box:
return img
left, top, right, bottom = box
fig_h = bottom - top
crop_h = min(img.height - top, max(1, int(fig_h * BUST_HEIGHT_FRAC)))
head = alpha.crop((left, top, right, top + max(1, int(fig_h * BUST_HEAD_FRAC))))
cols = head.resize((head.width, 1), Image.BOX).tobytes()
weight = sum(cols)
offset = sum(x * c for x, c in enumerate(cols)) // weight if weight else head.width // 2
cx = left + offset
crop_w = min(img.width, max(1, round(crop_h * (CARD_W - 12) / PORTRAIT_H)))
x0 = max(0, min(img.width - crop_w, cx - crop_w // 2))
return img.crop((x0, top, x0 + crop_w, top + crop_h))
def _cached_portrait(student: dict) -> Optional[Image.Image]:
"""Load a cached portrait, or None when one should be (re)fetched."""
try:
wiki = PORTRAIT_CACHE_DIR / f"{student['Id']}.wiki.png"
if wiki.exists():
return Image.open(wiki).convert("RGBA")
alt = PORTRAIT_CACHE_DIR / f"{student['Id']}.alt.png"
if alt.exists() and time.time() - alt.stat().st_mtime < WIKI_RETRY_HOURS * 3600:
return Image.open(alt).convert("RGBA")
except Exception: # unreadable file would otherwise blank the card forever
log.warning("bad cached portrait for %s, refetching", student["Name"])
return None
def _cache_portrait(student: dict, img: Image.Image, from_wiki: bool) -> None:
"""Keep fetched art on disk so an upstream outage can't change the cards."""
try:
PORTRAIT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
kind = "wiki" if from_wiki else "alt"
path = PORTRAIT_CACHE_DIR / f"{student['Id']}.{kind}.png"
tmp = path.parent / f"{path.name}.{uuid.uuid4().hex[:8]}.tmp"
img.save(tmp, format="PNG") # the .tmp name hides the format from Pillow
os.replace(tmp, path) # a restart mid-save must not leave half a card
if from_wiki: # wiki art wins from here on, drop the stand-in
(PORTRAIT_CACHE_DIR / f"{student['Id']}.alt.png").unlink(missing_ok=True)
except Exception:
log.warning("could not cache portrait for %s", student["Name"], exc_info=True)
async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]: async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]:
"""Download a student's portrait (wiki bust art first, full sprite fallback).""" """A student's portrait: disk cache, else wiki art, collection art, sprite."""
for url in (db.cdn_portrait(student), db.cdn_sprite(student)): cached = await asyncio.to_thread(_cached_portrait, student)
if cached is not None:
return cached
sources = (
(db.cdn_portrait(student), "wiki"),
(db.cdn_collection(student), "collection"),
(db.cdn_sprite(student), "sprite"),
)
for url, kind in sources:
try: try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200: if resp.status != 200:
data = await resp.read() continue
img = Image.open(io.BytesIO(data)).convert("RGBA") data = await resp.read()
# Full-body art is tall; crop the upper portion to fit the card. img = Image.open(io.BytesIO(data)).convert("RGBA")
p_w, p_h = img.size if kind == "sprite":
crop_h = int(p_w * 1.20) img = _bust_crop(img)
if crop_h < p_h: elif kind == "wiki":
img = img.crop((0, 0, p_w, crop_h)) # Wiki art is a face shot; only trim it if it is very tall.
return img p_w, p_h = img.size
crop_h = int(p_w * 1.20)
if crop_h < p_h:
img = img.crop((0, 0, p_w, crop_h))
await asyncio.to_thread(_cache_portrait, student, img, kind == "wiki")
return img
except Exception: except Exception:
log.debug("portrait fetch failed via %s", url) log.debug("portrait fetch failed via %s", url)
+15 -6
View File
@@ -17,7 +17,7 @@ from PIL import Image
from . import db as gacha_db from . import db as gacha_db
from .data import db, fetch_banners, banner_unavailable_reason from .data import db, fetch_banners, banner_unavailable_reason
from .gacha_renderer import render_pull from .gacha_renderer import render_pull
from .constants import CHARACTER_API, BG_PATH, GACHA_ANIM_PATH, FONT_PATHS from .constants import CHARACTER_API, BG_PATH, GACHA_ANIM_PATH, FONT_PATHS, PORTRAIT_CACHE_DIR
OK, WARN, FAIL, INFO = "✅", "⚠️", "❌", "ℹ️" OK, WARN, FAIL, INFO = "✅", "⚠️", "❌", "ℹ️"
REFRESH_INTERVAL = 6 * 3600 # must match BlueArchiveGacha._periodic_refresh REFRESH_INTERVAL = 6 * 3600 # must match BlueArchiveGacha._periodic_refresh
@@ -121,7 +121,7 @@ def _check_rateups(live_banners: list[dict]) -> tuple[list[Row], list[dict]]:
async def _check_portraits(students: list[dict]) -> list[Row]: async def _check_portraits(students: list[dict]) -> list[Row]:
"""Probe wiki art and the sprite fallback separately for rate-up students.""" """Probe each art source separately for rate-up students."""
s = "Portraits" s = "Portraits"
if not students: if not students:
return [(INFO, s, "No live rate-up students to probe")] return [(INFO, s, "No live rate-up students to probe")]
@@ -136,16 +136,25 @@ async def _check_portraits(students: list[dict]) -> list[Row]:
timeout = aiohttp.ClientTimeout(total=30, connect=5) # same as render_pull timeout = aiohttp.ClientTimeout(total=30, connect=5) # same as render_pull
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
results = await asyncio.gather(*( results = await asyncio.gather(*(
asyncio.gather(_probe_image(session, db.cdn_portrait(st)), _probe_image(session, db.cdn_sprite(st))) asyncio.gather(
_probe_image(session, db.cdn_portrait(st)),
_probe_image(session, db.cdn_collection(st)),
_probe_image(session, db.cdn_sprite(st)),
)
for st in sample for st in sample
)) ))
for st, (wiki, sprite) in zip(sample, results): for st, (wiki, collection, sprite) in zip(sample, results):
if wiki is None: if wiki is None:
rows.append((OK, s, f"{st['Name']}: wiki art OK")) rows.append((OK, s, f"{st['Name']}: wiki art OK"))
elif collection is None:
rows.append((WARN, s, f"{st['Name']}: wiki art failed ({wiki}), collection art OK"))
elif sprite is None: elif sprite is None:
rows.append((WARN, s, f"{st['Name']}: wiki art failed ({wiki}), sprite fallback OK")) rows.append((WARN, s, f"{st['Name']}: wiki+collection failed, sprite fallback OK"))
else: else:
rows.append((FAIL, s, f"{st['Name']}: no art (wiki {wiki}, sprite {sprite}) — card renders blank")) rows.append((FAIL, s, f"{st['Name']}: no art (wiki {wiki}, collection {collection}, "
f"sprite {sprite}) — card renders blank"))
cached = len(list(PORTRAIT_CACHE_DIR.glob("*.png"))) if PORTRAIT_CACHE_DIR.exists() else 0
rows.append((INFO, s, f"{cached} portraits cached on disk"))
return rows return rows
+1 -4
View File
@@ -132,10 +132,7 @@ class TimestampModal(discord.ui.Modal, title="Timestamp Converter"):
# --- output single Long Date+Time format --- # --- output single Long Date+Time format ---
lines = [ lines = [
f"**{local_dt.strftime('%d %B at %H:%M')}**", f"<t:{unix_ts}:F>",
f"`<t:{unix_ts}:F>` ← Long Date+Time",
"",
"*Times display in each viewer's local timezone.*",
] ]
await interaction.response.send_message( await interaction.response.send_message(
+15
View File
@@ -117,3 +117,18 @@ def load_routes() -> list[Route]:
def avatar_channel_id() -> int | None: def avatar_channel_id() -> int | None:
return _int_or_none(os.getenv("AVATAR_CACHE_CHANNEL_ID")) return _int_or_none(os.getenv("AVATAR_CACHE_CHANNEL_ID"))
def local_api() -> dict:
"""-> bot kwargs for a self-hosted bot api server, {} for the cloud one.
A local server lifts telegram's 20 MB getFile cap and hands back a path on
disk instead of a url, which ptb reads for itself once local_mode is on.
Both directions read this or only one of them migrates.
"""
base = os.getenv("TELEGRAM_API_BASE_URL", "").strip().rstrip("/")
if not base:
return {}
return {"base_url": f"{base}/bot",
"base_file_url": f"{base}/file/bot",
"local_mode": True}
+2 -2
View File
@@ -15,7 +15,7 @@ from telegram import (Bot, InputMediaAudio, InputMediaDocument,
InputMediaPhoto, InputMediaVideo, ReplyParameters) InputMediaPhoto, InputMediaVideo, ReplyParameters)
from telegram.error import TelegramError from telegram.error import TelegramError
from . import entities, store from . import config, entities, store
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -29,7 +29,7 @@ class DiscordToTelegramForwarder(commands.Cog):
def __init__(self, bot: commands.Bot, tg_token: str, routes=None): def __init__(self, bot: commands.Bot, tg_token: str, routes=None):
self.bot = bot self.bot = bot
self._tg_bot = Bot(token=tg_token) self._tg_bot = Bot(token=tg_token, **config.local_api())
# discord_channel_id -> telegram_chat_id # discord_channel_id -> telegram_chat_id
self.routes: dict[int, int] = {} self.routes: dict[int, int] = {}
for route in routes or []: for route in routes or []:
+77 -10
View File
@@ -4,8 +4,10 @@
import asyncio import asyncio
import io import io
import logging import logging
import os
import time import time
from telegram.error import BadRequest, NetworkError
from telegram.ext import Application, MessageHandler, filters from telegram.ext import Application, MessageHandler, filters
from . import config, entities, media, store from . import config, entities, media, store
@@ -18,9 +20,24 @@ log = logging.getLogger(__name__)
ALBUM_DEBOUNCE = 1.5 ALBUM_DEBOUNCE = 1.5
ALBUM_MAX_FILES = 10 ALBUM_MAX_FILES = 10
DOWNLOAD_TIMEOUT = 90.0 DOWNLOAD_TIMEOUT = 90.0
# ptb defaults read_timeout to 5s, which a slow transfer trips long before
# the ceilings above ever matter. kept under 24s so pool+connect+read stays
# inside the 30s get_file ceiling and ptb stays the one reporting the failure
READ_TIMEOUT = 20.0
# a local api server lifts telegram's 20 MB cap, but _download still buffers
# the whole file in memory — past this, post the note instead of eating the box
MAX_INGEST_BYTES = 200 * 1024 * 1024
REPLY_SNIPPET = 120 REPLY_SNIPPET = 120
class TooLarge(Exception):
"""An attachment we will not pull into memory."""
def __init__(self, megabytes: float | None = None):
super().__init__("attachment too large to bridge")
self.megabytes = megabytes
class TelegramForwarder: class TelegramForwarder:
def __init__(self, token, discord_bot=None, avatar_channel_id=None, def __init__(self, token, discord_bot=None, avatar_channel_id=None,
routes=None, drop_pending=True): routes=None, drop_pending=True):
@@ -67,7 +84,10 @@ class TelegramForwarder:
if removed: if removed:
log.info("pruned %d expired message mappings", removed) log.info("pruned %d expired message mappings", removed)
self._app = Application.builder().token(self.token).build() builder = Application.builder().token(self.token)
for name, value in config.local_api().items():
builder = getattr(builder, name)(value)
self._app = builder.build()
self._app.add_handler(MessageHandler(filters.ALL, self._on_update)) self._app.add_handler(MessageHandler(filters.ALL, self._on_update))
self._app.add_error_handler(self._on_error) self._app.add_error_handler(self._on_error)
@@ -125,14 +145,45 @@ class TelegramForwarder:
# ── downloads ─────────────────────────────────────────────────────────── # ── downloads ───────────────────────────────────────────────────────────
async def _download(self, file_id: str): async def _download(self, file_id: str):
"""-> (bytes, filename). Raises on failure.""" """-> (bytes, filename). Raises on failure.
tg_file = await asyncio.wait_for(
self._app.bot.get_file(file_id), timeout=30.0) Retried once: a dropped read mid-transfer is the one failure here
buf = io.BytesIO() nothing upstream recovers from. BadRequest subclasses NetworkError,
await asyncio.wait_for( so it has to be ruled out first or a rejection gets re-sent.
tg_file.download_to_memory(buf), timeout=DOWNLOAD_TIMEOUT) """
path = tg_file.file_path or "" for attempt in (1, 2):
return buf.getvalue(), (path.split("/")[-1] or "file") try:
tg_file = await asyncio.wait_for(
self._app.bot.get_file(file_id, read_timeout=READ_TIMEOUT),
timeout=30.0)
size = tg_file.file_size or 0
if not size and os.path.isabs(tg_file.file_path or ""):
# local mode hands back a path, not a url — if the
# server left file_size out, ask the filesystem
try:
size = os.path.getsize(tg_file.file_path)
except OSError:
size = 0
if size > MAX_INGEST_BYTES:
raise TooLarge(size / 1048576)
buf = io.BytesIO()
await asyncio.wait_for(
tg_file.download_to_memory(buf, read_timeout=READ_TIMEOUT),
timeout=DOWNLOAD_TIMEOUT)
except BadRequest as exc:
# only the cloud api refuses outright; a local server will
# hand the file over and MAX_INGEST_BYTES becomes the limit
if "too big" in str(exc).lower():
raise TooLarge() from exc
raise
except NetworkError as exc:
if attempt == 2:
raise
log.info("download retry after %s: %s",
type(exc).__name__, exc)
continue
path = tg_file.file_path or ""
return buf.getvalue(), (path.split("/")[-1] or "file")
# ── context lines ─────────────────────────────────────────────────────── # ── context lines ───────────────────────────────────────────────────────
@@ -264,6 +315,14 @@ class TelegramForwarder:
if document: if document:
data, filename = await self._download(document.file_id) data, filename = await self._download(document.file_id)
# a gif sent as a file never reaches the animation branch, so it
# went to discord at full size — same ladder, same .gif out
is_gif = (doc_mime == "image/gif"
or filename.lower().endswith(".gif"))
if is_gif and len(data) > media.MAX_CONVERTED_BYTES:
smaller = await media.to_gif(data, ".gif")
if smaller:
return [(filename, smaller)], ""
return [(filename, data)], "" return [(filename, data)], ""
if message.voice: if message.voice:
@@ -347,8 +406,16 @@ class TelegramForwarder:
for message in messages: for message in messages:
try: try:
got, note = await self._render_media(message) got, note = await self._render_media(message)
except TooLarge as exc:
what = _describe_media(message) or "attachment"
size = f" — {exc.megabytes:.0f} MB" if exc.megabytes else ""
log.info("%s in message %s too large to bridge%s",
what, message.message_id, size)
got, note = [], f"*(attachment too large to bridge{size})*"
except Exception as exc: except Exception as exc:
log.warning("media render failed: %s", exc) log.warning("media render failed for %s in message %s: %s: %s",
_describe_media(message) or "attachment",
message.message_id, type(exc).__name__, exc)
got, note = [], "*(attachment could not be fetched)*" got, note = [], "*(attachment could not be fetched)*"
files.extend(got) files.extend(got)
if note: if note: