Compare commits

...
6 Commits
Author SHA1 Message Date
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
esefandClaude Opus 5 93e22a8364 statchannel: ignore steam's bogus zero player counts
Steam's GetNumberOfCurrentPlayers occasionally answers 200 with
player_count: 0 for a valid appid. Both Strinova stat channels were
renamed to "0" on the same 09:48 tick on 2026-10-20 (the only zero in
~300 renames over 3 days), and the name sat wrong until the next
10-minute tick.

Skip the rename on a non-positive count so the channel keeps its last
good value, and log it so the next one is visible. !strinova had the
same bug and would reply "0 players online right now".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-20 13:12:57 +03:00
esefandClaude Opus 5.5 d514601651 firebase_website: drop the sysinfo sync to Firestore
The website bot status tab was replaced by the VEGA status page, so
nothing reads sysinfo/server anymore. get_db() stays for calendar and
bucketlist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 15:23:41 +03:00
15 changed files with 197 additions and 212 deletions

No files matched your search

+1
View File
@@ -28,3 +28,4 @@ _check_students.py
_check_names.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"
BUNDLED_DB = _PKG_DIR / "ba_students.json"
BANNER_FILE = _DATA_DIR / "ba_banner_state.json"
PORTRAIT_CACHE_DIR = _DATA_DIR / "portraits"
ASSETS_DIR = _PKG_DIR / "assets"
BG_PATH = ASSETS_DIR / "gacha_bg.png"
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"
CHARACTER_API = "https://api.ennead.cc/buruaka/character"
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_VARIANT_MAP: dict[str, str] = {
"Bunny": "Bunny Girl",
@@ -80,6 +84,10 @@ STAR_BANNER_H = 36
NAME_STRIP_H = 42
PORTRAIT_TOP = STAR_BANNER_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 ───────────────────────────────────────────────────────
PAGE_SIZE = 10
+5 -1
View File
@@ -355,8 +355,12 @@ class StudentDB:
name = f"{base.strip()} ({variant})"
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:
"""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"
+76 -4
View File
@@ -6,6 +6,9 @@ import functools
import io
import logging
import math
import os
import time
import uuid
from pathlib import Path
from typing import Optional
@@ -21,7 +24,9 @@ from .constants import (
HEADER_Y, FOOTER_Y,
STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H,
RARITY_COLOR, RARITY_BG,
BUST_HEIGHT_FRAC, BUST_HEAD_FRAC,
BG_PATH, SPARK_TARGET,
PORTRAIT_CACHE_DIR, WIKI_RETRY_HOURS,
FONT_PATHS,
)
@@ -47,19 +52,86 @@ FOOTER_FONT = _get_font(18, bold=False)
# ── 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]:
"""Download a student's portrait (wiki bust art first, full sprite fallback)."""
for url in (db.cdn_portrait(student), db.cdn_sprite(student)):
"""A student's portrait: disk cache, else wiki art, collection art, sprite."""
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:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
if resp.status != 200:
continue
data = await resp.read()
img = Image.open(io.BytesIO(data)).convert("RGBA")
# Full-body art is tall; crop the upper portion to fit the card.
if kind == "sprite":
img = _bust_crop(img)
elif kind == "wiki":
# Wiki art is a face shot; only trim it if it is very tall.
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:
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 .data import db, fetch_banners, banner_unavailable_reason
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 = "✅", "⚠️", "❌", "ℹ️"
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]:
"""Probe wiki art and the sprite fallback separately for rate-up students."""
"""Probe each art source separately for rate-up students."""
s = "Portraits"
if not students:
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
async with aiohttp.ClientSession(timeout=timeout) as session:
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, (wiki, sprite) in zip(sample, results):
for st, (wiki, collection, sprite) in zip(sample, results):
if wiki is None:
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:
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:
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
+3
View File
@@ -31,6 +31,9 @@ class StatChannel(commands.Cog):
async with session.get('https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=1282270') as r:
data = await r.json()
count = data['response']['player_count']
if not isinstance(count, int) or count <= 0:
print(f'stat channel update skipped (channel {channel_id}): steam returned {count!r}')
return
channel = self.bot.get_channel(channel_id)
if channel:
await channel.edit(name=f'Strinova Players From Steam: {count:,}')
+3
View File
@@ -117,6 +117,9 @@ class SysInfo(commands.Cog):
async with session.get('https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=1282270') as r:
data = await r.json()
count = data['response']['player_count']
if not isinstance(count, int) or count <= 0:
await ctx.reply('🎮 Strinova — Steam is not returning a player count right now, try again in a bit')
return
await ctx.reply(f'🎮 Strinova — {count:,} players online right now')
+1 -4
View File
@@ -132,10 +132,7 @@ class TimestampModal(discord.ui.Modal, title="Timestamp Converter"):
# --- output single Long Date+Time format ---
lines = [
f"**{local_dt.strftime('%d %B at %H:%M')}**",
f"`<t:{unix_ts}:F>` ← Long Date+Time",
"",
"*Times display in each viewer's local timezone.*",
f"<t:{unix_ts}:F>",
]
await interaction.response.send_message(
+3 -20
View File
@@ -1,35 +1,18 @@
"""firebase_website — Bot → Firestore sync using the Admin SDK.
"""firebase_website — Bot → Firestore access using the Admin SDK.
Requires GOOGLE_APPLICATION_CREDENTIALS env var pointing to a service-account JSON.
This package is loaded as a Discord extension purely for lifecycle hooks
(setup / teardown). It does not register a Cog — it starts a background
SysInfoSync task instead.
Loaded as a Discord extension only so missing credentials fail at startup.
The calendar and bucketlist cogs use get_db().
Usage from main.py:
await bot.load_extension("firebase_website")
"""
######################################################################
import logging
from discord.ext import commands
from .config import get_db # noqa: re‑export for calendar, bucketlist, etc.
from .sysinfo_sync import SysInfoSync
log = logging.getLogger(__name__)
_sync: SysInfoSync | None = None
async def setup(bot: commands.Bot) -> None:
global _sync
get_db() # fail fast if credentials are missing
_sync = SysInfoSync()
_sync.start()
async def teardown(bot: commands.Bot) -> None:
global _sync
if _sync is not None:
await _sync.stop()
-1
View File
@@ -8,7 +8,6 @@ from google.cloud.firestore import AsyncClient
log = logging.getLogger(__name__)
PROJECT_ID = "esef-514bf"
SYSINFO_INTERVAL = 10 # seconds between pushes
_db: AsyncClient | None = None
-168
View File
@@ -1,168 +0,0 @@
"""Push system stats (CPU, RAM, disk, uptime) to Firestore via Admin SDK."""
######################################################################
import asyncio
import logging
import re
import time
import psutil
from google.cloud import firestore
from .config import get_db, SYSINFO_INTERVAL
log = logging.getLogger(__name__)
FASTFETCH_CACHE_SECS = 300 # refresh fastfetch every 5 minutes
VIRTUAL_NIC_PREFIXES = ("lo", "docker", "br-", "veth", "tailscale", "tun", "wg", "virbr")
def _fmt_size(b: int) -> str:
"""Bytes → human-readable string (auto-scales B → TB)."""
for unit in ("B", "KB", "MB", "GB", "TB"):
if abs(b) < 1024:
return f"{b:.1f} {unit}"
b /= 1024
return f"{b:.1f} PB"
def _fmt_duration(seconds: int) -> str:
days, r = divmod(seconds, 86400)
hours, r = divmod(r, 3600)
minutes, _ = divmod(r, 60)
return f"{days}d {hours}h {minutes}m" if days else f"{hours}h {minutes}m"
class SysInfoSync:
"""Background task: collect system stats and push to Firestore."""
def __init__(self) -> None:
self._task: asyncio.Task | None = None
self._fastfetch: str = ""
self._fastfetch_ts: float = 0
# ── public ──────────────────────────────────────────────────────────
def start(self) -> None:
if self._task is not None:
return
self._task = asyncio.create_task(self._run())
log.info("SysInfo → Firestore sync started (every %ds)", SYSINFO_INTERVAL)
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
self._task = None
log.info("SysInfo → Firestore sync stopped")
# turns the dot on the website red
try:
await get_db().collection("sysinfo").document("server").set(
{"online": False, "updatedAt": firestore.SERVER_TIMESTAMP}, merge=True)
except Exception:
log.warning("Could not mark sysinfo offline", exc_info=True)
# ── loop ────────────────────────────────────────────────────────────
async def _run(self) -> None:
while True:
try:
await self._push()
except asyncio.CancelledError:
return
except Exception:
log.warning("SysInfo push failed", exc_info=True)
await asyncio.sleep(SYSINFO_INTERVAL)
# ── push ────────────────────────────────────────────────────────────
async def _push(self) -> None:
stats = await self._collect_stats()
fetch = await self._get_fastfetch()
payload = {
"online": True,
"cpu": stats["cpu"],
"ram": stats["ram"],
"ramUsed": stats["ram_used"],
"ramTotal": stats["ram_total"],
"disk": stats["disk"],
"diskUsed": stats["disk_used"],
"diskTotal": stats["disk_total"],
"swap": stats["swap"],
"swapUsed": stats["swap_used"],
"swapTotal": stats["swap_total"],
"uptime": stats["uptime"],
"botUptime": stats["bot_uptime"],
"processes": stats["processes"],
"load1": stats["load1"],
"load5": stats["load5"],
"load15": stats["load15"],
"netSent": stats["net_sent"],
"netRecv": stats["net_recv"],
"fetch": fetch,
"updatedAt": firestore.SERVER_TIMESTAMP,
}
db = get_db()
await db.collection("sysinfo").document("server").set(payload, merge=True)
# ── collectors ──────────────────────────────────────────────────────
async def _collect_stats(self) -> dict:
"""Gather all psutil stats; offloads blocking calls to a thread."""
cpu = await asyncio.to_thread(psutil.cpu_percent, interval=1)
mem = psutil.virtual_memory()
disk = psutil.disk_usage("/")
swap = psutil.swap_memory()
load1, load5, load15 = psutil.getloadavg()
nics = psutil.net_io_counters(pernic=True)
physical = [c for name, c in nics.items() if not name.startswith(VIRTUAL_NIC_PREFIXES)]
net_sent = sum(c.bytes_sent for c in physical)
net_recv = sum(c.bytes_recv for c in physical)
now = time.time()
uptime_seconds = int(now - psutil.boot_time())
bot_uptime_seconds = int(now - psutil.Process().create_time())
return {
"cpu": round(cpu, 1),
"ram": round(mem.percent, 1),
"ram_used": _fmt_size(mem.used),
"ram_total": _fmt_size(mem.total),
"disk": round(disk.percent, 1),
"disk_used": _fmt_size(disk.used),
"disk_total": _fmt_size(disk.total),
"swap": round(swap.percent, 1),
"swap_used": _fmt_size(swap.used),
"swap_total": _fmt_size(swap.total),
"uptime": _fmt_duration(uptime_seconds),
"bot_uptime": _fmt_duration(bot_uptime_seconds),
"processes": len(psutil.pids()),
"load1": round(load1, 1),
"load5": round(load5, 1),
"load15": round(load15, 1),
"net_sent": _fmt_size(net_sent),
"net_recv": _fmt_size(net_recv),
}
async def _get_fastfetch(self) -> str:
"""Return cached fastfetch output; refreshed every 5 minutes."""
now = time.monotonic()
if self._fastfetch and (now - self._fastfetch_ts) < FASTFETCH_CACHE_SECS:
return self._fastfetch
try:
proc = await asyncio.create_subprocess_exec(
"fastfetch", "--pipe", "--logo", "none",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
clean = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", stdout.decode())
lines = [line for line in clean.strip().splitlines() if line.strip()]
self._fastfetch = "\n".join(lines[:15])
self._fastfetch_ts = now
except Exception:
if not self._fastfetch:
self._fastfetch = ""
return self._fastfetch
+15
View File
@@ -117,3 +117,18 @@ def load_routes() -> list[Route]:
def avatar_channel_id() -> int | None:
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)
from telegram.error import TelegramError
from . import entities, store
from . import config, entities, store
log = logging.getLogger(__name__)
@@ -29,7 +29,7 @@ class DiscordToTelegramForwarder(commands.Cog):
def __init__(self, bot: commands.Bot, tg_token: str, routes=None):
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
self.routes: dict[int, int] = {}
for route in routes or []:
+64 -5
View File
@@ -4,8 +4,10 @@
import asyncio
import io
import logging
import os
import time
from telegram.error import BadRequest, NetworkError
from telegram.ext import Application, MessageHandler, filters
from . import config, entities, media, store
@@ -18,9 +20,24 @@ log = logging.getLogger(__name__)
ALBUM_DEBOUNCE = 1.5
ALBUM_MAX_FILES = 10
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
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:
def __init__(self, token, discord_bot=None, avatar_channel_id=None,
routes=None, drop_pending=True):
@@ -67,7 +84,10 @@ class TelegramForwarder:
if 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_error_handler(self._on_error)
@@ -125,12 +145,43 @@ class TelegramForwarder:
# ── downloads ───────────────────────────────────────────────────────────
async def _download(self, file_id: str):
"""-> (bytes, filename). Raises on failure."""
"""-> (bytes, filename). Raises on failure.
Retried once: a dropped read mid-transfer is the one failure here
nothing upstream recovers from. BadRequest subclasses NetworkError,
so it has to be ruled out first or a rejection gets re-sent.
"""
for attempt in (1, 2):
try:
tg_file = await asyncio.wait_for(
self._app.bot.get_file(file_id), timeout=30.0)
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), timeout=DOWNLOAD_TIMEOUT)
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")
@@ -347,8 +398,16 @@ class TelegramForwarder:
for message in messages:
try:
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:
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)*"
files.extend(got)
if note: