Compare commits
17
Commits
3d49c65de3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d1f6778c4 | ||
|
|
78c755a7a2 | ||
|
|
460f4bf78c | ||
|
|
ff579807f1 | ||
|
|
810a9a6b12 | ||
|
|
93e22a8364 | ||
|
|
d514601651 | ||
|
|
1b074d4b58 | ||
|
|
5adcf3478e | ||
|
|
c51b2ff3f3 | ||
|
|
2af2adc780 | ||
|
|
f8b3501637 | ||
|
|
234fbe88aa | ||
|
|
2358679904 | ||
|
|
2691806453 | ||
|
|
4fa7d5ca50 | ||
|
|
b1ff45794a |
No files matched your search
@@ -28,3 +28,4 @@ _check_students.py
|
|||||||
_check_names.py
|
_check_names.py
|
||||||
_check_banner_api.py
|
_check_banner_api.py
|
||||||
|
|
||||||
|
credentials_test.txt
|
||||||
@@ -6,10 +6,10 @@ it started as a simple music bot and became a massive mess of features i sometim
|
|||||||
## features
|
## features
|
||||||
|
|
||||||
- **ai** — uses deepseek v4 (flash/pro). text-only (no image/vision input), continuous memory summarization (so it doesn't forget context), per-channel personas (yuuka/rem), and native deepseek-reasoner thinking toggles. doesn't break character.
|
- **ai** — uses deepseek v4 (flash/pro). text-only (no image/vision input), continuous memory summarization (so it doesn't forget context), per-channel personas (yuuka/rem), and native deepseek-reasoner thinking toggles. doesn't break character.
|
||||||
- **bridges** — two-way telegram <-> discord message forwarding. twitter/x leaks via rss is implemented but currently disabled (Nitter RSS instances are dead).
|
- **bridges** — two-way telegram <-> discord message forwarding. twitter/x leaks: new @CalabiyauLeaks tweets go to two servers as fxtwitter links (`twitterbridge/`), from a self-hosted RSSHub feed on yuuka (esefrss). `!leaks` shows the latest 5. if the feed breaks for 90 min the bot DMs the owner.
|
||||||
- **media & music** — lavalink + wavelink music playback (youtube search + http/icecast radio streams). also has ffmpeg media conversion commands (!togif, !caption, !reverse, !speed, !tomp4, !tomp3, !toopus).
|
- **media & music** — lavalink + wavelink music playback (youtube search + http/icecast radio streams). also has ffmpeg media conversion slash commands (/togif, /caption, /reverse, /speed, /tomp4, /tomp3, /toopus).
|
||||||
- **sysinfo** — live monitoring of my laptop's cpu, ram, temps (lm-sensors), top processes, and fastfetch.
|
- **sysinfo** — live monitoring of my laptop's cpu, ram, temps (lm-sensors), top processes, and fastfetch.
|
||||||
- **utilities** — live currency conversion, steam stat tracking (strinova player counts).
|
- **utilities** — live currency conversion (/currency), steam stat tracking (strinova player counts).
|
||||||
- **calendar** — firestore-backed event booking via modal. autoconverts local times to UTC using a country list, feeds a web calendar frontend.
|
- **calendar** — firestore-backed event booking via modal. autoconverts local times to UTC using a country list, feeds a web calendar frontend.
|
||||||
- **timestamp** — `/timestamp` modal that generates Discord `<t:unix:FORMAT>` tags. accepts day numbers or day names ("Sunday"), handles month rollover, outputs all 7 Discord time formats.
|
- **timestamp** — `/timestamp` modal that generates Discord `<t:unix:FORMAT>` tags. accepts day numbers or day names ("Sunday"), handles month rollover, outputs all 7 Discord time formats.
|
||||||
- **timestamp (friends)** — passive message listener: when a whitelisted user says "my time" in chat, the bot parses the surrounding text for time expressions, auto-converts to their timezone, and replies with timestamp tags. no commands needed.
|
- **timestamp (friends)** — passive message listener: when a whitelisted user says "my time" in chat, the bot parses the surrounding text for time expressions, auto-converts to their timezone, and replies with timestamp tags. no commands needed.
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -89,4 +97,6 @@ FILTER_LABELS: dict[str, int | None] = {"All": None, "3★": 3, "2★": 2, "1★
|
|||||||
FONT_PATHS = [
|
FONT_PATHS = [
|
||||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Debian (fonts-dejavu-core)
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
]
|
]
|
||||||
+23
-2
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
@@ -412,6 +416,22 @@ async def fetch_banners(session: Optional[aiohttp.ClientSession] = None) -> Opti
|
|||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def banner_unavailable_reason(banner: dict) -> Optional[str]:
|
||||||
|
"""Return why a banner can't be pulled right now, or None if it's live.
|
||||||
|
|
||||||
|
Banners without timestamps are treated as permanent.
|
||||||
|
"""
|
||||||
|
now_ms = datetime.now(timezone.utc).timestamp() * 1000
|
||||||
|
started = banner.get("startedAt", 0)
|
||||||
|
ended = banner.get("endedAt", 0)
|
||||||
|
if started and now_ms < started:
|
||||||
|
start_dt = datetime.fromtimestamp(started / 1000, tz=timezone.utc)
|
||||||
|
return f"This banner hasn't started yet (starts {start_dt.strftime('%b %d')})."
|
||||||
|
if ended and now_ms >= ended:
|
||||||
|
return "This banner has ended."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def format_banner_embed(banner: dict, index: int) -> str:
|
def format_banner_embed(banner: dict, index: int) -> str:
|
||||||
"""Format a single banner as an embed field value string."""
|
"""Format a single banner as an embed field value string."""
|
||||||
gtype = banner.get("gachaType", "PickupGacha")
|
gtype = banner.get("gachaType", "PickupGacha")
|
||||||
@@ -427,8 +447,9 @@ def format_banner_embed(banner: dict, index: int) -> str:
|
|||||||
dates = "Permanent"
|
dates = "Permanent"
|
||||||
|
|
||||||
is_fes = " [FES - 6% 3*!]" if gtype == "FesGacha" else ""
|
is_fes = " [FES - 6% 3*!]" if gtype == "FesGacha" else ""
|
||||||
|
upcoming = " [UPCOMING]" if started and started > datetime.now(timezone.utc).timestamp() * 1000 else ""
|
||||||
return (
|
return (
|
||||||
f"**#{index + 1} — {gtype}{is_fes}**\n"
|
f"**#{index + 1} — {gtype}{is_fes}{upcoming}**\n"
|
||||||
f"Rate-up: {rateups}\n"
|
f"Rate-up: {rateups}\n"
|
||||||
f"{dates}"
|
f"{dates}"
|
||||||
)
|
)
|
||||||
+51
-1
@@ -4,6 +4,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -15,10 +16,12 @@ from .data import (
|
|||||||
db,
|
db,
|
||||||
fetch_banners,
|
fetch_banners,
|
||||||
format_banner_embed,
|
format_banner_embed,
|
||||||
|
banner_unavailable_reason,
|
||||||
roll_rarity,
|
roll_rarity,
|
||||||
get_rates_for_banner,
|
get_rates_for_banner,
|
||||||
)
|
)
|
||||||
from .gacha_renderer import render_pull
|
from .gacha_renderer import render_pull
|
||||||
|
from .health import collect_health, OK, INFO
|
||||||
from .constants import SPARK_TARGET, BANNER_FILE, DEFAULT_RATES, PULL10_RATES, GACHA_ANIM_PATH, GACHA_ANIM_DURATION
|
from .constants import SPARK_TARGET, BANNER_FILE, DEFAULT_RATES, PULL10_RATES, GACHA_ANIM_PATH, GACHA_ANIM_DURATION
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -69,6 +72,10 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
self.bot = bot
|
self.bot = bot
|
||||||
self._banner_cache: dict = {"current": [], "upcoming": [], "ended": []}
|
self._banner_cache: dict = {"current": [], "upcoming": [], "ended": []}
|
||||||
self._cache_ready = asyncio.Event()
|
self._cache_ready = asyncio.Event()
|
||||||
|
# Refresh bookkeeping, reported by !gacha health
|
||||||
|
self._last_refresh_at: Optional[float] = None
|
||||||
|
self._last_refresh_ok: Optional[bool] = None
|
||||||
|
self._last_good_at: Optional[float] = None
|
||||||
|
|
||||||
# ── Lifecycle ───────────────────────────────────────────────────────
|
# ── Lifecycle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -87,14 +94,17 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
"""Refresh banners, then merge any new students they might feature."""
|
"""Refresh banners, then merge any new students they might feature."""
|
||||||
try:
|
try:
|
||||||
banners = await fetch_banners()
|
banners = await fetch_banners()
|
||||||
|
self._last_refresh_ok = banners is not None
|
||||||
if banners is None:
|
if banners is None:
|
||||||
log.warning("Banner refresh failed; keeping previous banner data")
|
log.warning("Banner refresh failed; keeping previous banner data")
|
||||||
else:
|
else:
|
||||||
self._banner_cache = banners
|
self._banner_cache = banners
|
||||||
|
self._last_good_at = time.time()
|
||||||
await db.merge_ennead(self._banner_cache)
|
await db.merge_ennead(self._banner_cache)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Banner refresh failed")
|
log.exception("Banner refresh failed")
|
||||||
finally:
|
finally:
|
||||||
|
self._last_refresh_at = time.time()
|
||||||
self._cache_ready.set()
|
self._cache_ready.set()
|
||||||
|
|
||||||
async def _periodic_refresh(self) -> None:
|
async def _periodic_refresh(self) -> None:
|
||||||
@@ -167,7 +177,8 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
"`!gacha` — list current banners\n"
|
"`!gacha` — list current banners\n"
|
||||||
"`!gacha pick <n>` — select banner #n\n"
|
"`!gacha pick <n>` — select banner #n\n"
|
||||||
"`!gacha pick regular` — permanent pool\n"
|
"`!gacha pick regular` — permanent pool\n"
|
||||||
"`!gacha info` — your status & spark"
|
"`!gacha info` — your status & spark\n"
|
||||||
|
"`!gacha health` — check everything is working"
|
||||||
),
|
),
|
||||||
inline=False,
|
inline=False,
|
||||||
)
|
)
|
||||||
@@ -229,6 +240,10 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
banner = banners[idx]
|
banner = banners[idx]
|
||||||
|
reason = banner_unavailable_reason(banner)
|
||||||
|
if reason:
|
||||||
|
await ctx.reply(reason)
|
||||||
|
return
|
||||||
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
|
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
|
||||||
gtype = banner.get("gachaType", "PickupGacha")
|
gtype = banner.get("gachaType", "PickupGacha")
|
||||||
|
|
||||||
@@ -280,6 +295,13 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
"Pick another with `!gacha pick <n>` (see `!gacha`) or `!gacha pick regular`."
|
"Pick another with `!gacha pick <n>` (see `!gacha`) or `!gacha pick regular`."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
reason = banner_unavailable_reason(banner)
|
||||||
|
if reason:
|
||||||
|
await ctx.reply(
|
||||||
|
f"{reason}\n"
|
||||||
|
"Pick another with `!gacha pick <n>` (see `!gacha`) or `!gacha pick regular`."
|
||||||
|
)
|
||||||
|
return
|
||||||
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
|
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
|
||||||
gtype = banner.get("gachaType", "PickupGacha")
|
gtype = banner.get("gachaType", "PickupGacha")
|
||||||
banner_name = f"{gtype} — {rateups}"
|
banner_name = f"{gtype} — {rateups}"
|
||||||
@@ -420,6 +442,10 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
"Your selected banner has ended or is unavailable right now, so it can't be sparked."
|
"Your selected banner has ended or is unavailable right now, so it can't be sparked."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
reason = banner_unavailable_reason(banner)
|
||||||
|
if reason:
|
||||||
|
await ctx.reply(reason)
|
||||||
|
return
|
||||||
rateups = [n.lower() for n in banner.get("rateups", [])]
|
rateups = [n.lower() for n in banner.get("rateups", [])]
|
||||||
if student["Name"].lower() not in db.resolve_rateups(rateups):
|
if student["Name"].lower() not in db.resolve_rateups(rateups):
|
||||||
rateup_str = ", ".join(banner.get("rateups", []))
|
rateup_str = ", ".join(banner.get("rateups", []))
|
||||||
@@ -445,6 +471,30 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
f"Remaining Recruitment Points: {remaining}"
|
f"Remaining Recruitment Points: {remaining}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@commands.cooldown(1, 30, commands.BucketType.user)
|
||||||
|
@gacha.command(name="health")
|
||||||
|
async def gacha_health(self, ctx: commands.Context) -> None:
|
||||||
|
"""Run live checks on everything a pull depends on."""
|
||||||
|
async with ctx.typing():
|
||||||
|
rows = await collect_health(self, ctx.author.id, await _get_banner_id(ctx.author.id))
|
||||||
|
|
||||||
|
bad = [r for r in rows if r[0] not in (OK, INFO)]
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Gacha Health — all checks passed" if not bad else f"Gacha Health — {len(bad)} issue(s)",
|
||||||
|
color=0x57F287 if not bad else 0xED4245,
|
||||||
|
timestamp=discord.utils.utcnow(),
|
||||||
|
)
|
||||||
|
sections: dict[str, list[str]] = {}
|
||||||
|
for status, section, text in rows:
|
||||||
|
sections.setdefault(section, []).append(f"{status} {text}")
|
||||||
|
for section, lines in sections.items():
|
||||||
|
value = "\n".join(lines)
|
||||||
|
if len(value) > 1024:
|
||||||
|
value = value[:1020] + " …"
|
||||||
|
embed.add_field(name=section, value=value, inline=False)
|
||||||
|
embed.set_footer(text="Every line was checked just now")
|
||||||
|
await ctx.reply(embed=embed)
|
||||||
|
|
||||||
@gacha.command(name="info")
|
@gacha.command(name="info")
|
||||||
async def gacha_info(self, ctx: commands.Context) -> None:
|
async def gacha_info(self, ctx: commands.Context) -> None:
|
||||||
"""Show your active banner, spark count, and collection summary."""
|
"""Show your active banner, spark count, and collection summary."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
#this runs live health checks for the gacha system, used by !gacha health##
|
||||||
|
"""Gacha health checks — every row is a check actually run just now.
|
||||||
|
|
||||||
|
Nothing here changes bot state: live data is fetched and compared, never
|
||||||
|
written into the banner cache or the roster.
|
||||||
|
"""
|
||||||
|
######################################################################
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
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, PORTRAIT_CACHE_DIR
|
||||||
|
|
||||||
|
OK, WARN, FAIL, INFO = "✅", "⚠️", "❌", "ℹ️"
|
||||||
|
REFRESH_INTERVAL = 6 * 3600 # must match BlueArchiveGacha._periodic_refresh
|
||||||
|
MAX_PORTRAIT_PROBES = 6
|
||||||
|
BANNER_TIMEOUT = 20
|
||||||
|
|
||||||
|
Row = tuple[str, str, str] # (status, section, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(ts: Optional[float]) -> str:
|
||||||
|
return f"<t:{int(ts)}:R>" if ts else "never"
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe_image(session: aiohttp.ClientSession, url: str) -> Optional[str]:
|
||||||
|
"""Fetch and decode an image exactly like the renderer. None = success."""
|
||||||
|
try:
|
||||||
|
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return f"HTTP {resp.status}"
|
||||||
|
data = await resp.read()
|
||||||
|
Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
|
return None
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return "timed out after 5s"
|
||||||
|
except Exception as e:
|
||||||
|
return type(e).__name__
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_banners(cog) -> tuple[list[Row], list[dict]]:
|
||||||
|
"""Cached banner state (what pulls use) + a live fetch compared against it."""
|
||||||
|
rows: list[Row] = []
|
||||||
|
s = "Banners"
|
||||||
|
cache = cog._banner_cache
|
||||||
|
cached = cache.get("current", []) + cache.get("upcoming", [])
|
||||||
|
|
||||||
|
if cog._last_good_at is None:
|
||||||
|
rows.append((FAIL, s, "Banner data has never been fetched successfully — every pull falls back to no rate-up"))
|
||||||
|
elif cog._last_refresh_ok is False:
|
||||||
|
rows.append((WARN, s, f"Last refresh {_ago(cog._last_refresh_at)} FAILED; pulls use data from {_ago(cog._last_good_at)}"))
|
||||||
|
else:
|
||||||
|
rows.append((OK, s, f"Pull data refreshed {_ago(cog._last_good_at)} ({len(cached)} current/upcoming)"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
live = await asyncio.wait_for(fetch_banners(), timeout=BANNER_TIMEOUT)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
live = None
|
||||||
|
rows.append((FAIL, s, f"Banner API check now: timed out after {BANNER_TIMEOUT}s"))
|
||||||
|
else:
|
||||||
|
if live is None:
|
||||||
|
rows.append((FAIL, s, "Banner API check now: request failed (see bot log)"))
|
||||||
|
if live is not None:
|
||||||
|
live_list = live.get("current", []) + live.get("upcoming", [])
|
||||||
|
if {str(b.get("id")) for b in live_list} == {str(b.get("id")) for b in cached}:
|
||||||
|
rows.append((OK, s, "Banner API check now: reachable, matches pull data"))
|
||||||
|
else:
|
||||||
|
rows.append((WARN, s, f"Banner API check now: reachable, but has {len(live_list)} banners vs {len(cached)} "
|
||||||
|
"in pull data — pulls use stale data until the next refresh"))
|
||||||
|
|
||||||
|
now_ms = time.time() * 1000
|
||||||
|
live_banners = [b for b in cached if banner_unavailable_reason(b) is None]
|
||||||
|
upcoming = [b for b in cached if b.get("startedAt", 0) > now_ms]
|
||||||
|
ended = len(cached) - len(live_banners) - len(upcoming)
|
||||||
|
rows.append((INFO, s, f"{len(live_banners)} live now, {len(upcoming)} upcoming"
|
||||||
|
+ (f", {ended} ended but still listed (blocked from pulls)" if ended else "")))
|
||||||
|
return rows, live_banners
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rateups(live_banners: list[dict]) -> tuple[list[Row], list[dict]]:
|
||||||
|
"""Run each live banner through the exact pool builder pulls use."""
|
||||||
|
rows: list[Row] = []
|
||||||
|
s = "Rate-ups (live banners)"
|
||||||
|
students: list[dict] = []
|
||||||
|
if not live_banners:
|
||||||
|
rows.append((INFO, s, "No live banners to check"))
|
||||||
|
return rows, students
|
||||||
|
|
||||||
|
for b in live_banners[:6]:
|
||||||
|
pool = db.build_pool(b)
|
||||||
|
pooled = {st["Name"].lower() for r in pool.values() for st in r}
|
||||||
|
label = f"#{b.get('id')} {b.get('gachaType', '?')}"
|
||||||
|
problems: list[tuple[str, str]] = [] # (status, text)
|
||||||
|
for name in b.get("rateups", []):
|
||||||
|
st = db.get_by_name(name, strict=True)
|
||||||
|
if not st:
|
||||||
|
problems.append((FAIL, f"{name}: not in roster, can't be pulled"))
|
||||||
|
elif st["Name"].lower() not in pooled:
|
||||||
|
problems.append((FAIL, f"{name}: marked welfare, excluded from pulls"))
|
||||||
|
else:
|
||||||
|
students.append(st)
|
||||||
|
if st["StarGrade"] < 3:
|
||||||
|
problems.append((WARN, f"{name}: {st['StarGrade']}★, rate-up boost only applies to 3★"))
|
||||||
|
if problems:
|
||||||
|
status = FAIL if any(p[0] == FAIL for p in problems) else WARN
|
||||||
|
rows.append((status, s, f"{label}: " + "; ".join(p[1] for p in problems)))
|
||||||
|
else:
|
||||||
|
names = ", ".join(b.get("rateups", [])) or "no rate-ups"
|
||||||
|
rows.append((OK, s, f"{label}: all pullable ({names})"))
|
||||||
|
if len(live_banners) > 6:
|
||||||
|
rows.append((INFO, s, f"{len(live_banners) - 6} more live banners not checked"))
|
||||||
|
return rows, students
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_portraits(students: list[dict]) -> list[Row]:
|
||||||
|
"""Probe each art source separately for rate-up students."""
|
||||||
|
s = "Portraits"
|
||||||
|
if not students:
|
||||||
|
return [(INFO, s, "No live rate-up students to probe")]
|
||||||
|
seen, sample = set(), []
|
||||||
|
for st in students:
|
||||||
|
if st["Name"] not in seen:
|
||||||
|
seen.add(st["Name"])
|
||||||
|
sample.append(st)
|
||||||
|
sample = sample[:MAX_PORTRAIT_PROBES]
|
||||||
|
|
||||||
|
rows: 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_collection(st)),
|
||||||
|
_probe_image(session, db.cdn_sprite(st)),
|
||||||
|
)
|
||||||
|
for st in sample
|
||||||
|
))
|
||||||
|
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+collection failed, sprite fallback OK"))
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_roster() -> list[Row]:
|
||||||
|
s = "Roster"
|
||||||
|
std = db.build_pool(None) # exactly what Regular Recruitment pulls from
|
||||||
|
welfare = sum(1 for st in db.students if (st.get("IsLimited") or 0) >= 2)
|
||||||
|
limited = sum(1 for st in db.students if (st.get("IsLimited") or 0) == 1)
|
||||||
|
rows: list[Row] = [(OK if db.students else FAIL, s,
|
||||||
|
f"{len(db.students)} students loaded — standard pool 3★ {len(std[3])}, "
|
||||||
|
f"2★ {len(std[2])}, 1★ {len(std[1])}; {limited} limited (Fes/own banner only); "
|
||||||
|
f"{welfare} welfare (never pulled)")]
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(CHARACTER_API, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
rows.append((FAIL, s, f"Character API check now: HTTP {resp.status}"))
|
||||||
|
return rows
|
||||||
|
chars = await resp.json()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
rows.append((FAIL, s, "Character API check now: timed out after 15s"))
|
||||||
|
return rows
|
||||||
|
except Exception as e:
|
||||||
|
rows.append((FAIL, s, f"Character API check now: {type(e).__name__}"))
|
||||||
|
return rows
|
||||||
|
missing = [c.get("name", "?") for c in chars if c.get("id") not in db.by_id]
|
||||||
|
if missing:
|
||||||
|
shown = ", ".join(missing[:5]) + (f" +{len(missing) - 5} more" if len(missing) > 5 else "")
|
||||||
|
rows.append((WARN, s, f"Character API check now: {len(missing)} not loaded yet ({shown}); "
|
||||||
|
"added on next refresh"))
|
||||||
|
else:
|
||||||
|
rows.append((OK, s, f"Character API check now: reachable, all {len(chars)} loaded"))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _check_refresh_task(cog) -> list[Row]:
|
||||||
|
s = "Background refresh"
|
||||||
|
t = getattr(cog, "_refresh_task", None)
|
||||||
|
if t is None:
|
||||||
|
return [(FAIL, s, "Refresh task was never started — banners will never update")]
|
||||||
|
if t.cancelled():
|
||||||
|
return [(FAIL, s, "Refresh task was cancelled — banners will not update")]
|
||||||
|
if t.done():
|
||||||
|
return [(FAIL, s, f"Refresh task crashed: {t.exception()!r} — banners will not update")]
|
||||||
|
due = (cog._last_refresh_at or 0) + REFRESH_INTERVAL
|
||||||
|
return [(OK, s, f"Running, next refresh <t:{int(due)}:R>")]
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_storage(user_id: int) -> list[Row]:
|
||||||
|
s = "Storage & render"
|
||||||
|
rows: list[Row] = []
|
||||||
|
try:
|
||||||
|
await gacha_db.get_inventory_stats(user_id)
|
||||||
|
rows.append((OK, s, "Inventory database: read OK (writes not tested)"))
|
||||||
|
except Exception as e:
|
||||||
|
rows.append((FAIL, s, f"Inventory database read failed: {type(e).__name__}"))
|
||||||
|
|
||||||
|
if any(Path(fp).exists() for fp in FONT_PATHS):
|
||||||
|
rows.append((OK, s, "Font found"))
|
||||||
|
else:
|
||||||
|
rows.append((WARN, s, "No font found — render uses tiny default font"))
|
||||||
|
rows.append((OK, s, "Background image found") if BG_PATH.exists()
|
||||||
|
else (WARN, s, "Background image missing — plain colour used"))
|
||||||
|
rows.append((OK, s, "Pull animation found") if GACHA_ANIM_PATH.exists()
|
||||||
|
else (WARN, s, "Pull animation missing — text teaser used"))
|
||||||
|
|
||||||
|
sample = next(iter(db.by_rarity.get(3) or db.students), None)
|
||||||
|
if sample is None:
|
||||||
|
rows.append((FAIL, s, "Render test skipped: no students loaded"))
|
||||||
|
return rows
|
||||||
|
try:
|
||||||
|
start = time.monotonic()
|
||||||
|
await asyncio.wait_for(render_pull([sample], "Health check", 0), timeout=30)
|
||||||
|
rows.append((OK, s, f"Render test OK in {time.monotonic() - start:.1f}s (art checked under Portraits)"))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
rows.append((FAIL, s, "Render test timed out after 30s"))
|
||||||
|
except Exception as e:
|
||||||
|
rows.append((FAIL, s, f"Render test failed: {type(e).__name__}: {e}"))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _check_user_banner(cog, banner_id: Optional[str]) -> list[Row]:
|
||||||
|
s = "Your banner"
|
||||||
|
if not banner_id or banner_id == "regular":
|
||||||
|
return [(OK, s, "Regular Recruitment")]
|
||||||
|
for b in cog._all_banners():
|
||||||
|
if str(b.get("id")) == banner_id:
|
||||||
|
reason = banner_unavailable_reason(b)
|
||||||
|
if reason:
|
||||||
|
return [(WARN, s, f"#{banner_id}: {reason} Pick another with `!gacha pick`")]
|
||||||
|
return [(OK, s, f"#{banner_id} {b.get('gachaType', '?')} is live")]
|
||||||
|
return [(WARN, s, f"#{banner_id} is no longer listed — `!pull` will ask you to pick another")]
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_health(cog, user_id: int, banner_id: Optional[str]) -> list[Row]:
|
||||||
|
"""Run every check. Network checks run concurrently."""
|
||||||
|
(banner_rows, live_banners), roster_rows, storage_rows = await asyncio.gather(
|
||||||
|
_check_banners(cog), _check_roster(), _check_storage(user_id),
|
||||||
|
)
|
||||||
|
rateup_rows, students = _check_rateups(live_banners)
|
||||||
|
portrait_rows = await _check_portraits(students)
|
||||||
|
return (
|
||||||
|
banner_rows + rateup_rows + portrait_rows + roster_rows
|
||||||
|
+ _check_refresh_task(cog) + storage_rows + _check_user_banner(cog, banner_id)
|
||||||
|
)
|
||||||
@@ -251,6 +251,13 @@ class Inventory(commands.Cog):
|
|||||||
view = InventoryView(uid, ctx.author.id)
|
view = InventoryView(uid, ctx.author.id)
|
||||||
await ctx.reply(embed=await view._build_dashboard(), view=view)
|
await ctx.reply(embed=await view._build_dashboard(), view=view)
|
||||||
|
|
||||||
|
@commands.command(name="eligma")
|
||||||
|
async def eligma(self, ctx: commands.Context) -> None:
|
||||||
|
"""Show your Eligma balance (advertised in !gacha help)."""
|
||||||
|
view = InventoryView(ctx.author.id, ctx.author.id)
|
||||||
|
view.stop() # only used to build the embed, no buttons attached
|
||||||
|
await ctx.reply(embed=await view._build_eligma_page())
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot: commands.Bot) -> None:
|
async def setup(bot: commands.Bot) -> None:
|
||||||
await bot.add_cog(Inventory(bot))
|
await bot.add_cog(Inventory(bot))
|
||||||
+14
-18
@@ -3,6 +3,7 @@ import time
|
|||||||
import json
|
import json
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import discord
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
# load aliases from json
|
# load aliases from json
|
||||||
@@ -47,37 +48,32 @@ class CurrencyConverter(commands.Cog):
|
|||||||
_RATE_CACHE[base] = (now, rates)
|
_RATE_CACHE[base] = (now, rates)
|
||||||
return rates
|
return rates
|
||||||
|
|
||||||
@commands.command(name='currency')
|
@app_commands.command(name='currency')
|
||||||
async def currency(self, ctx, amount: float = 1, from_cur: str = None, to_cur: str = None):
|
@app_commands.rename(from_cur='from', to_cur='to')
|
||||||
"""Convert *amount* from *from_cur* to *to_cur*.
|
@app_commands.describe(from_cur='Currency to convert from, e.g. usd, euro, lira',
|
||||||
If *amount* is omitted the command is parsed as ``!currency <from> <to>``.
|
to_cur='Currency to convert to',
|
||||||
"""
|
amount='How much to convert (default 1)')
|
||||||
# discord.py passes arguments left-to-right; we need to support two signatures.
|
async def currency(self, interaction: discord.Interaction, from_cur: str, to_cur: str, amount: float = 1.0):
|
||||||
# When the user supplies only two strings, *amount* will be parsed as the first string.
|
"""Convert money between currencies."""
|
||||||
if from_cur is None and to_cur is None:
|
|
||||||
# only two arguments were given, treat them as from/to and default amount=1
|
|
||||||
to_cur = amount # type: ignore
|
|
||||||
from_cur = str(amount) # type: ignore
|
|
||||||
amount = 1.0
|
|
||||||
try:
|
try:
|
||||||
from_code = self._norm(str(from_cur))
|
from_code = self._norm(from_cur)
|
||||||
to_code = self._norm(str(to_cur))
|
to_code = self._norm(to_cur)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return await ctx.reply(f"❌ {e}")
|
return await interaction.response.send_message(f"❌ {e}", ephemeral=True)
|
||||||
try:
|
try:
|
||||||
rates = await self._get_rates(from_code)
|
rates = await self._get_rates(from_code)
|
||||||
except Exception:
|
except Exception:
|
||||||
return await ctx.reply('❌ could not fetch conversion rates, try again later')
|
return await interaction.response.send_message('❌ could not fetch conversion rates, try again later', ephemeral=True)
|
||||||
rate = rates.get(to_code)
|
rate = rates.get(to_code)
|
||||||
if rate is None:
|
if rate is None:
|
||||||
return await ctx.reply(f"❌ unsupported conversion: {from_code} → {to_code}")
|
return await interaction.response.send_message(f"❌ unsupported conversion: {from_code} → {to_code}", ephemeral=True)
|
||||||
result = amount * rate
|
result = amount * rate
|
||||||
embed = discord.Embed(title='Currency conversion', color=0x00ff00)
|
embed = discord.Embed(title='Currency conversion', color=0x00ff00)
|
||||||
embed.add_field(name='From', value=f"{amount:g} {from_code}", inline=True)
|
embed.add_field(name='From', value=f"{amount:g} {from_code}", inline=True)
|
||||||
embed.add_field(name='To', value=f"{result:g} {to_code}", inline=True)
|
embed.add_field(name='To', value=f"{result:g} {to_code}", inline=True)
|
||||||
embed.add_field(name='Rate', value=f"1 {from_code} = {rate:g} {to_code}", inline=False)
|
embed.add_field(name='Rate', value=f"1 {from_code} = {rate:g} {to_code}", inline=False)
|
||||||
embed.set_footer(text='Data from frankfurter.app')
|
embed.set_footer(text='Data from frankfurter.app')
|
||||||
await ctx.reply(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
await bot.add_cog(CurrencyConverter(bot))
|
await bot.add_cog(CurrencyConverter(bot))
|
||||||
+65
-79
@@ -1,9 +1,9 @@
|
|||||||
"""FFmpeg media conversion cog.
|
"""FFmpeg media conversion cog.
|
||||||
|
|
||||||
Commands operate on a message attachment, or the attachment of a replied-to
|
Slash commands that take an uploaded file: /togif, /caption <text>,
|
||||||
message: !togif, !caption <text>, !reverse, !speed <x>, !tomp4, !tomp3,
|
/reverse, /speed <x>, /tomp4, /tomp3, /toopus. All ffmpeg invocations use
|
||||||
!toopus. All ffmpeg invocations use argument lists (no shell) and captions
|
argument lists (no shell) and captions are passed via a textfile to avoid
|
||||||
are passed via a textfile to avoid drawtext injection.
|
drawtext injection.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
@@ -11,6 +11,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
MAX_UPLOAD = 50 * 1024 * 1024 # 50 MB input cap
|
MAX_UPLOAD = 50 * 1024 * 1024 # 50 MB input cap
|
||||||
@@ -29,36 +30,33 @@ class FfmpegThings(commands.Cog):
|
|||||||
def __init__(self, bot: commands.Bot) -> None:
|
def __init__(self, bot: commands.Bot) -> None:
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
async def _get_attachment(self, message: discord.Message):
|
async def _transcode(self, interaction: discord.Interaction, file: discord.Attachment,
|
||||||
if message.attachments:
|
args: list[str], ext: str, caption: str | None = None):
|
||||||
return message.attachments[0]
|
if file.size > MAX_UPLOAD:
|
||||||
ref = message.reference
|
await interaction.response.send_message(
|
||||||
if ref is not None:
|
f"File too big ({file.size // (1024*1024)} MB). Max {MAX_UPLOAD // (1024*1024)} MB.",
|
||||||
resolved = ref.resolved
|
ephemeral=True)
|
||||||
if isinstance(resolved, discord.Message) and resolved.attachments:
|
return
|
||||||
return resolved.attachments[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _transcode(self, ctx: commands.Context, args: list[str], ext: str):
|
# ffmpeg easily takes longer than the 3s interaction window
|
||||||
attachment = await self._get_attachment(ctx.message)
|
await interaction.response.defer(thinking=True)
|
||||||
if attachment is None:
|
|
||||||
await ctx.reply("Attach a file, or reply to a message that has one.")
|
|
||||||
return
|
|
||||||
if attachment.size > MAX_UPLOAD:
|
|
||||||
await ctx.reply(f"File too big ({attachment.size // (1024*1024)} MB). Max {MAX_UPLOAD // (1024*1024)} MB.")
|
|
||||||
return
|
|
||||||
|
|
||||||
tmpdir = tempfile.mkdtemp(prefix="ffmpeg_")
|
tmpdir = tempfile.mkdtemp(prefix="ffmpeg_")
|
||||||
inp = os.path.join(tmpdir, "in" + os.path.splitext(attachment.filename)[1])
|
inp = os.path.join(tmpdir, "in" + os.path.splitext(file.filename)[1])
|
||||||
out = os.path.join(tmpdir, "out" + ext)
|
out = os.path.join(tmpdir, "out" + ext)
|
||||||
try:
|
try:
|
||||||
await attachment.save(inp)
|
await file.save(inp)
|
||||||
|
if caption is not None:
|
||||||
|
caption_file = os.path.join(tmpdir, "caption.txt")
|
||||||
|
with open(caption_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(caption)
|
||||||
|
args = [a.replace("{caption_file}", caption_file) for a in args]
|
||||||
await self._run_ffmpeg(["-i", inp, *args, out], tmpdir)
|
await self._run_ffmpeg(["-i", inp, *args, out], tmpdir)
|
||||||
if not os.path.exists(out) or os.path.getsize(out) == 0:
|
if not os.path.exists(out) or os.path.getsize(out) == 0:
|
||||||
raise RuntimeError("ffmpeg produced no output")
|
raise RuntimeError("ffmpeg produced no output")
|
||||||
await ctx.reply(file=discord.File(out))
|
await interaction.followup.send(file=discord.File(out))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await ctx.reply(f"ffmpeg failed: {exc}"[:2000])
|
await interaction.followup.send(f"ffmpeg failed: {exc}"[:2000])
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
@@ -77,105 +75,93 @@ class FfmpegThings(commands.Cog):
|
|||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise RuntimeError(stderr.decode(errors="replace")[:500])
|
raise RuntimeError(stderr.decode(errors="replace")[:500])
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def togif(self, ctx: commands.Context):
|
@app_commands.describe(file="Video to convert")
|
||||||
|
async def togif(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||||
"""Convert a video to a GIF."""
|
"""Convert a video to a GIF."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-vf", "fps=10,scale='min(480,iw)':-2:flags=lanczos"],
|
["-vf", "fps=10,scale='min(480,iw)':-2:flags=lanczos"],
|
||||||
".gif",
|
".gif",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def tomp4(self, ctx: commands.Context):
|
@app_commands.describe(file="Video or audio to convert")
|
||||||
|
async def tomp4(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||||
"""Convert media to MP4 (h264/aac)."""
|
"""Convert media to MP4 (h264/aac)."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
["-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
||||||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart"],
|
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart"],
|
||||||
".mp4",
|
".mp4",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def tomp3(self, ctx: commands.Context):
|
@app_commands.describe(file="Video or audio to extract from")
|
||||||
|
async def tomp3(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||||
"""Extract audio to MP3."""
|
"""Extract audio to MP3."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-vn", "-c:a", "libmp3lame", "-q:a", "2"],
|
["-vn", "-c:a", "libmp3lame", "-q:a", "2"],
|
||||||
".mp3",
|
".mp3",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def toopus(self, ctx: commands.Context):
|
@app_commands.describe(file="Video or audio to extract from")
|
||||||
|
async def toopus(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||||
"""Extract audio to Opus."""
|
"""Extract audio to Opus."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-vn", "-c:a", "libopus", "-b:a", "128k"],
|
["-vn", "-c:a", "libopus", "-b:a", "128k"],
|
||||||
".opus",
|
".opus",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def reverse(self, ctx: commands.Context):
|
@app_commands.describe(file="Video to reverse")
|
||||||
|
async def reverse(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||||
"""Reverse a video/audio clip."""
|
"""Reverse a video/audio clip."""
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-vf", "reverse", "-af", "areverse"],
|
["-vf", "reverse", "-af", "areverse"],
|
||||||
".mp4",
|
".mp4",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def speed(self, ctx: commands.Context, factor: float = 1.0):
|
@app_commands.describe(file="Video to speed up or slow down",
|
||||||
|
factor="Playback speed, 0.5 to 2.0")
|
||||||
|
async def speed(self, interaction: discord.Interaction, file: discord.Attachment,
|
||||||
|
factor: app_commands.Range[float, 0.5, 2.0] = 1.0):
|
||||||
"""Change playback speed (0.5 to 2.0)."""
|
"""Change playback speed (0.5 to 2.0)."""
|
||||||
if not 0.5 <= factor <= 2.0:
|
|
||||||
await ctx.reply("Speed must be between 0.5 and 2.0.")
|
|
||||||
return
|
|
||||||
await self._transcode(
|
await self._transcode(
|
||||||
ctx,
|
interaction, file,
|
||||||
["-filter_complex",
|
["-filter_complex",
|
||||||
f"[0:v]setpts=PTS/{factor}[v];[0:a]atempo={factor}[a]",
|
f"[0:v]setpts=PTS/{factor}[v];[0:a]atempo={factor}[a]",
|
||||||
"-map", "[v]", "-map", "[a]"],
|
"-map", "[v]", "-map", "[a]"],
|
||||||
".mp4",
|
".mp4",
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@app_commands.command()
|
||||||
async def caption(self, ctx: commands.Context, *, text: str):
|
@app_commands.describe(file="Video to caption", text="Text to put on top")
|
||||||
|
async def caption(self, interaction: discord.Interaction, file: discord.Attachment,
|
||||||
|
text: str):
|
||||||
"""Add a caption to the top of a video."""
|
"""Add a caption to the top of a video."""
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
await ctx.reply("Provide a caption, e.g. `!caption hello world`.")
|
await interaction.response.send_message("Provide a caption.", ephemeral=True)
|
||||||
return
|
|
||||||
|
|
||||||
attachment = await self._get_attachment(ctx.message)
|
|
||||||
if attachment is None:
|
|
||||||
await ctx.reply("Attach a file, or reply to a message that has one.")
|
|
||||||
return
|
|
||||||
if attachment.size > MAX_UPLOAD:
|
|
||||||
await ctx.reply(f"File too big ({attachment.size // (1024*1024)} MB).")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
font = next((f for f in FONT_CANDIDATES if os.path.exists(f)), None)
|
font = next((f for f in FONT_CANDIDATES if os.path.exists(f)), None)
|
||||||
tmpdir = tempfile.mkdtemp(prefix="ffmpeg_cap_")
|
font_arg = f":fontfile={font}" if font else ""
|
||||||
inp = os.path.join(tmpdir, "in" + os.path.splitext(attachment.filename)[1])
|
vf = (
|
||||||
caption_file = os.path.join(tmpdir, "caption.txt")
|
"drawtext=textfile={caption_file}:fontsize=40:fontcolor=white"
|
||||||
out = os.path.join(tmpdir, "out.mp4")
|
f":box=1:[email protected]:boxborderw=10:x=(w-text_w)/2:y=20{font_arg}"
|
||||||
try:
|
)
|
||||||
await attachment.save(inp)
|
await self._transcode(
|
||||||
with open(caption_file, "w", encoding="utf-8") as f:
|
interaction, file,
|
||||||
f.write(text)
|
["-vf", vf, "-c:a", "copy"],
|
||||||
font_arg = f":fontfile={font}" if font else ""
|
".mp4",
|
||||||
vf = (
|
caption=text,
|
||||||
f"drawtext=textfile={caption_file}:fontsize=40:fontcolor=white"
|
)
|
||||||
f":box=1:[email protected]:boxborderw=10:x=(w-text_w)/2:y=20{font_arg}"
|
|
||||||
)
|
|
||||||
await self._run_ffmpeg(
|
|
||||||
["-i", inp, "-vf", vf, "-c:a", "copy", out],
|
|
||||||
tmpdir,
|
|
||||||
)
|
|
||||||
await ctx.reply(file=discord.File(out))
|
|
||||||
except Exception as exc:
|
|
||||||
await ctx.reply(f"ffmpeg failed: {exc}"[:2000])
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot: commands.Bot) -> None:
|
async def setup(bot: commands.Bot) -> None:
|
||||||
|
|||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
"""Help cog — one place that explains everything the bot can do.
|
||||||
|
|
||||||
|
!help → overview + a dropdown to open any category
|
||||||
|
!help <category> → jump straight to a category, e.g. !help music
|
||||||
|
|
||||||
|
The text lives in CATEGORIES below. GIF commands are generated from the
|
||||||
|
gifs/ folder, so that list is read from the Gifs cog at runtime.
|
||||||
|
"""
|
||||||
|
######################################################################
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
COLOR = 0x5BA0D0
|
||||||
|
|
||||||
|
INTRO = (
|
||||||
|
"Hi, I'm Yuuka! I play music and radio, convert videos, convert money, "
|
||||||
|
"run a Blue Archive gacha, chat with AI, and a bunch of small fun stuff.\n\n"
|
||||||
|
"Most commands start with `!`. The media tools and `/currency` are "
|
||||||
|
"slash commands: type `/` and pick them from the list.\n\n"
|
||||||
|
"Pick a category below, or type `!help <category>` (e.g. `!help music`). `!commands` works too."
|
||||||
|
)
|
||||||
|
|
||||||
|
# key: (emoji, title, one-line summary, full page text)
|
||||||
|
CATEGORIES: dict[str, tuple[str, str, str, str]] = {
|
||||||
|
"music": ("🎵", "Music & Radio", "Play songs from YouTube or live radio in voice", (
|
||||||
|
"Join a voice channel first.\n\n"
|
||||||
|
"**Anyone can use**\n"
|
||||||
|
"`!play <song or link>` (`!p`) — search YouTube or paste a video/playlist link. "
|
||||||
|
"Example: `!play blue archive ost`\n"
|
||||||
|
"`!radio` — pick a radio station from a menu (category, then station)\n"
|
||||||
|
"`!nowplaying` (`!np`) — what's playing right now\n"
|
||||||
|
"`!queue` (`!q`) — what's coming up next\n"
|
||||||
|
"`!history` (`!h`) — recently played songs\n"
|
||||||
|
"`!music` — short list of the music commands\n\n"
|
||||||
|
"**Needs the DJ role**\n"
|
||||||
|
"`!pause` · `!resume` · `!skip`\n"
|
||||||
|
"`!stop` (`!leave`, `!disconnect`) — stop music or radio, clear the queue and leave\n"
|
||||||
|
"`!seek <time>` — jump to a time, e.g. `!seek 1:30` or `!seek 90`\n"
|
||||||
|
"`!looptrack` — repeat the current song\n"
|
||||||
|
"`!loopqueue` — repeat the whole queue\n"
|
||||||
|
"`!autoplay` — keep playing similar songs when the queue runs out\n"
|
||||||
|
"`!remove <pos>` — remove a song from the queue\n"
|
||||||
|
"`!move <from> <to>` — move a song in the queue\n"
|
||||||
|
"`!shuffle` — shuffle the queue\n"
|
||||||
|
"`!clear` — empty the queue"
|
||||||
|
)),
|
||||||
|
"media": ("🎬", "Media tools", "Convert, reverse, speed up or caption videos", (
|
||||||
|
"Slash commands: type `/`, pick one, and upload your file in the `file` box.\n\n"
|
||||||
|
"`/togif` — turn a video into a GIF\n"
|
||||||
|
"`/tomp4` — convert any video/audio to MP4\n"
|
||||||
|
"`/tomp3` — pull the audio out as MP3\n"
|
||||||
|
"`/toopus` — pull the audio out as Opus\n"
|
||||||
|
"`/reverse` — play a clip backwards\n"
|
||||||
|
"`/speed` — speed up or slow down (`factor` 0.5 to 2.0)\n"
|
||||||
|
"`/caption` — put text on top of a video\n\n"
|
||||||
|
"Limits: files up to 50 MB, output up to 10 minutes."
|
||||||
|
)),
|
||||||
|
"utilities": ("🧰", "Utilities", "Currency, weather, timestamps, calendar, bucketlist", (
|
||||||
|
"`/currency` — convert money, e.g. from `usd` to `try`, amount `100`. "
|
||||||
|
"Understands names like `dollar`, `euro`, `lira`\n"
|
||||||
|
"`!weather [city]` — weather forecast (defaults to Kayseri)\n"
|
||||||
|
"`!time` — opens a form (day, time, country) and gives you a Discord timestamp "
|
||||||
|
"that shows up in everyone's own time zone\n"
|
||||||
|
"`!strinova` — how many people are playing Strinova on Steam right now\n\n"
|
||||||
|
"**Calendar** (members only) — events show on https://esefos.netlify.app/calendar\n"
|
||||||
|
"`!book` — book an event with a form\n"
|
||||||
|
"`!unbook` — remove an upcoming event\n\n"
|
||||||
|
"**Bucketlist** (approved users only) — shows on https://esefos.netlify.app/bucketlist\n"
|
||||||
|
"`!bucket` — add an item with a form\n"
|
||||||
|
"`!unbucket` — remove an item\n"
|
||||||
|
"`!bucketlist` — list the bucketlist commands"
|
||||||
|
)),
|
||||||
|
"fun": ("🎲", "Fun & games", "Dice, coin, 8ball, slots, ship and more", (
|
||||||
|
"`!coin` — flip a coin\n"
|
||||||
|
"`!dice` — roll a 6-sided die\n"
|
||||||
|
"`!d4` · `!d8` · `!d10` · `!d12` · `!d20` · `!d100` — roll that die\n"
|
||||||
|
"`!roll <dice>` — custom roll: `!roll 2d6`, `!roll d20` or `!roll 20`\n"
|
||||||
|
"`!rps <rock|paper|scissors>` — rock paper scissors\n"
|
||||||
|
"`!8ball <question>` — ask the magic 8 ball\n"
|
||||||
|
"`!slots` — spin the slot machine\n"
|
||||||
|
"`!roulette` — russian roulette (1 in 6)\n"
|
||||||
|
"`!ship <@user> [@user]` — love meter\n"
|
||||||
|
"`!pp` · `!rank` · `!aura` — very scientific measurements\n"
|
||||||
|
"`!yuuka` — kanpeki!\n"
|
||||||
|
"`!koharu` — shikei!\n"
|
||||||
|
"`!fun` — short list of these commands"
|
||||||
|
)),
|
||||||
|
"memes": ("📜", "Copypastas & GIFs", "Copypastas and reaction GIFs", (
|
||||||
|
"**Copypastas**\n"
|
||||||
|
"`!yuukapasta` — the Yuuka devotion pasta\n"
|
||||||
|
"`!yuyuko` — Yuyuko ASCII art\n"
|
||||||
|
"`!anlaki` — bro anlaki\n"
|
||||||
|
"`!copypasta` — list all copypastas\n\n"
|
||||||
|
"**Reaction GIFs**\n"
|
||||||
|
"{gifs}\n"
|
||||||
|
"`!gifs` — list all GIF commands"
|
||||||
|
)),
|
||||||
|
"bluearchive": ("🌸", "Blue Archive gacha", "Pull students, spark, show off your collection", (
|
||||||
|
"**Banners**\n"
|
||||||
|
"`!gacha` (`!banners`, `!g`) — current banners\n"
|
||||||
|
"`!gacha pick <n>` — choose banner #n (`!gacha pick regular` for the permanent pool)\n"
|
||||||
|
"`!gacha info` — your active banner, spark points and collection summary\n"
|
||||||
|
"`!gacha help` — gacha command list\n"
|
||||||
|
"`!gacha health` — check that everything a pull needs is working\n\n"
|
||||||
|
"**Pulling**\n"
|
||||||
|
"`!pull` — 10-pull (2★ or better guaranteed on the 10th)\n"
|
||||||
|
"`!pull single` — 1 pull\n"
|
||||||
|
"`!spark <name>` — claim a rate-up student with 200 recruitment points\n\n"
|
||||||
|
"**Collection**\n"
|
||||||
|
"`!inv` (`!collection`, `!i`) — browse your collection with buttons\n"
|
||||||
|
"`!inv @user` — look at someone else's\n"
|
||||||
|
"`!eligma` — your Eligma balance"
|
||||||
|
)),
|
||||||
|
"ai": ("🤖", "AI chat", "Talk to Yuuka (approved users only)", (
|
||||||
|
"**Ping me** (`@Yuuka ...`) to talk. I remember the last 15 messages in the channel.\n\n"
|
||||||
|
"`!activechat` — talk without pinging in this channel\n"
|
||||||
|
"`!stopchat` — turn that off again\n"
|
||||||
|
"`!setpersona <name>` — switch personality (wipes memory)\n"
|
||||||
|
"`!personas` — list personalities\n"
|
||||||
|
"`!clearchat` — wipe my memory of this channel\n"
|
||||||
|
"`!summary` — see my long-term summary of this channel\n"
|
||||||
|
"`!clearsummary` — wipe that summary\n"
|
||||||
|
"`!think` — thinking mode menu (off / high / max)\n"
|
||||||
|
"`!thinkstatus` — is thinking on here?\n"
|
||||||
|
"`!turbo` — switch between the fast and the smart model\n"
|
||||||
|
"`!current` — which model I'm using\n"
|
||||||
|
"`!temperature <0-2>` — lower = stricter, higher = more chaotic\n"
|
||||||
|
"`!aicost` — tokens used and cost so far\n"
|
||||||
|
"`!ai` — list the AI commands"
|
||||||
|
)),
|
||||||
|
"stats": ("🖥️", "PC stats", "Live stats of the machine I run on", (
|
||||||
|
"`!stats` — CPU, RAM and disk at a glance\n"
|
||||||
|
"`!fetch` — full system info (fastfetch)\n"
|
||||||
|
"`!cpu` · `!mem` · `!load` — CPU usage, memory, load average\n"
|
||||||
|
"`!temps` — temperature sensors\n"
|
||||||
|
"`!gpu` — current graphics mode\n"
|
||||||
|
"`!top` — top 5 processes by CPU\n"
|
||||||
|
"`!processes` — how many processes are running\n"
|
||||||
|
"`!net` — network traffic since boot\n"
|
||||||
|
"`!uptime` — how long the PC has been on\n"
|
||||||
|
"`!battery` — battery level\n"
|
||||||
|
"`!sysinfo` — list these commands"
|
||||||
|
)),
|
||||||
|
"automatic": ("⚙️", "Automatic stuff", "Things I do without a command", (
|
||||||
|
"**Telegram bridge** — messages in linked channels are mirrored to Telegram groups "
|
||||||
|
"and back, including replies, edits, stickers and media. "
|
||||||
|
"`!bridge` shows its status.\n\n"
|
||||||
|
"**\"my time\"** — when certain friends write *my time* next to a time, I reply "
|
||||||
|
"with a timestamp that shows in everyone's own time zone.\n\n"
|
||||||
|
"**Strinova player count** — a channel name that shows how many people are "
|
||||||
|
"playing Strinova, updated every 10 minutes.\n\n"
|
||||||
|
"**Member count** — a channel name that shows how many members the server has, "
|
||||||
|
"updated every 10 minutes.\n\n"
|
||||||
|
"**Welcome & goodbye cards** — I post a card when someone joins the server, "
|
||||||
|
"and a black & white one when someone leaves.\n\n"
|
||||||
|
"**Custom roles** — in the roles channel, press *Get my role* and I'll DM you "
|
||||||
|
"to make your own colored role.\n\n"
|
||||||
|
"**Website** — the PC's live stats are shown on https://esefos.netlify.app"
|
||||||
|
)),
|
||||||
|
"admin": ("🔒", "Admin", "Owner-only commands", (
|
||||||
|
"These only work for the bot owner (`!ferox` only for one specific member).\n\n"
|
||||||
|
"`!purge <n>` — delete the last n messages\n"
|
||||||
|
"`!ban @users/@roles [reason]` — ban everyone mentioned (asks to confirm)\n"
|
||||||
|
"`!ferox <10m|2h|1d|1w|off>` — time out ferox\n"
|
||||||
|
"`!rolepanel` — re-post the custom role panel\n"
|
||||||
|
"`!gitpull` — pull the latest code\n"
|
||||||
|
"`!update` — pull the latest code and restart if anything changed\n"
|
||||||
|
"`!restart` — restart the bot\n"
|
||||||
|
"`!status` — bot service status\n"
|
||||||
|
"`!logs [n]` — last n log lines (default 40)\n"
|
||||||
|
"`!gitlog [n]` — last n commits (default 5)"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
|
||||||
|
ALIASES = {
|
||||||
|
"radio": "music", "ffmpeg": "media", "video": "media", "currency": "utilities",
|
||||||
|
"util": "utilities", "games": "fun", "gifs": "memes", "copypasta": "memes",
|
||||||
|
"gacha": "bluearchive", "ba": "bluearchive", "sysinfo": "stats", "owner": "admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HelpSelect(discord.ui.Select):
|
||||||
|
def __init__(self, cog: "Help", author_id: int) -> None:
|
||||||
|
self._cog = cog
|
||||||
|
self._author_id = author_id
|
||||||
|
super().__init__(
|
||||||
|
placeholder="Pick a category…",
|
||||||
|
options=[
|
||||||
|
discord.SelectOption(label=title, value=key, emoji=emoji, description=summary[:100])
|
||||||
|
for key, (emoji, title, summary, _) in CATEGORIES.items()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction) -> None:
|
||||||
|
embed = self._cog.page(self.values[0])
|
||||||
|
# someone else clicking gets their own private copy instead of flipping ours
|
||||||
|
if interaction.user.id != self._author_id:
|
||||||
|
return await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
|
await interaction.response.edit_message(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
class HelpView(discord.ui.View):
|
||||||
|
def __init__(self, cog: "Help", author_id: int) -> None:
|
||||||
|
super().__init__(timeout=300)
|
||||||
|
self.add_item(HelpSelect(cog, author_id))
|
||||||
|
|
||||||
|
|
||||||
|
class Help(commands.Cog):
|
||||||
|
"""The !help command."""
|
||||||
|
|
||||||
|
def __init__(self, bot: commands.Bot) -> None:
|
||||||
|
self.bot = bot
|
||||||
|
|
||||||
|
def overview(self) -> discord.Embed:
|
||||||
|
embed = discord.Embed(title="What can Yuuka do?", description=INTRO, color=COLOR)
|
||||||
|
for emoji, title, summary, _ in CATEGORIES.values():
|
||||||
|
embed.add_field(name=f"{emoji} {title}", value=summary, inline=True)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
def page(self, key: str) -> discord.Embed:
|
||||||
|
emoji, title, _, text = CATEGORIES[key]
|
||||||
|
if key == "memes":
|
||||||
|
gifs_cog = self.bot.get_cog("Gifs")
|
||||||
|
names = sorted(getattr(gifs_cog, "_gif_names", []))
|
||||||
|
text = text.replace("{gifs}", " · ".join(f"`!{n}`" for n in names) or "none loaded")
|
||||||
|
embed = discord.Embed(title=f"{emoji} {title}", description=text, color=COLOR)
|
||||||
|
embed.set_footer(text="!help — back to the overview")
|
||||||
|
return embed
|
||||||
|
|
||||||
|
@commands.command(name="help", aliases=["commands"])
|
||||||
|
async def help_cmd(self, ctx: commands.Context, category: str | None = None) -> None:
|
||||||
|
"""Show everything the bot can do."""
|
||||||
|
if category:
|
||||||
|
key = category.lower().replace(" ", "")
|
||||||
|
key = ALIASES.get(key, key)
|
||||||
|
if key in CATEGORIES:
|
||||||
|
return await ctx.reply(embed=self.page(key), view=HelpView(self, ctx.author.id))
|
||||||
|
await ctx.reply(f"No category called `{category}`, here's everything:")
|
||||||
|
await ctx.reply(embed=self.overview(), view=HelpView(self, ctx.author.id))
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot: commands.Bot) -> None:
|
||||||
|
await bot.add_cog(Help(bot))
|
||||||
@@ -12,14 +12,18 @@ class StatChannel(commands.Cog):
|
|||||||
|
|
||||||
self.channel_id = _parse_id('STRINOVA_STAT_CHANNEL_ID')
|
self.channel_id = _parse_id('STRINOVA_STAT_CHANNEL_ID')
|
||||||
self.channel_id_mao = _parse_id('STRINOVA_STAT_CHANNEL_ID_MAO')
|
self.channel_id_mao = _parse_id('STRINOVA_STAT_CHANNEL_ID_MAO')
|
||||||
|
self.member_channel_id = _parse_id('MEMBER_COUNT_CHANNEL_ID')
|
||||||
|
|
||||||
self.update_stat.start()
|
self.update_stat.start()
|
||||||
if self.channel_id_mao is not None:
|
if self.channel_id_mao is not None:
|
||||||
self.update_stat_mao.start()
|
self.update_stat_mao.start()
|
||||||
|
if self.member_channel_id is not None:
|
||||||
|
self.update_members.start()
|
||||||
|
|
||||||
def cog_unload(self):
|
def cog_unload(self):
|
||||||
self.update_stat.cancel()
|
self.update_stat.cancel()
|
||||||
self.update_stat_mao.cancel()
|
self.update_stat_mao.cancel()
|
||||||
|
self.update_members.cancel()
|
||||||
|
|
||||||
async def _fetch_and_update(self, channel_id):
|
async def _fetch_and_update(self, channel_id):
|
||||||
try:
|
try:
|
||||||
@@ -27,6 +31,9 @@ class StatChannel(commands.Cog):
|
|||||||
async with session.get('https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=1282270') as r:
|
async with session.get('https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=1282270') as r:
|
||||||
data = await r.json()
|
data = await r.json()
|
||||||
count = data['response']['player_count']
|
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)
|
channel = self.bot.get_channel(channel_id)
|
||||||
if channel:
|
if channel:
|
||||||
await channel.edit(name=f'Strinova Players From Steam: {count:,}')
|
await channel.edit(name=f'Strinova Players From Steam: {count:,}')
|
||||||
@@ -41,6 +48,15 @@ class StatChannel(commands.Cog):
|
|||||||
async def update_stat_mao(self):
|
async def update_stat_mao(self):
|
||||||
await self._fetch_and_update(self.channel_id_mao)
|
await self._fetch_and_update(self.channel_id_mao)
|
||||||
|
|
||||||
|
@tasks.loop(minutes=10)
|
||||||
|
async def update_members(self):
|
||||||
|
try:
|
||||||
|
channel = self.bot.get_channel(self.member_channel_id)
|
||||||
|
if channel:
|
||||||
|
await channel.edit(name=f'Member Count: {channel.guild.member_count:,}')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'member count update failed (channel {self.member_channel_id}): {e}')
|
||||||
|
|
||||||
@update_stat.before_loop
|
@update_stat.before_loop
|
||||||
async def before_update_stat(self):
|
async def before_update_stat(self):
|
||||||
await self.bot.wait_until_ready()
|
await self.bot.wait_until_ready()
|
||||||
@@ -49,5 +65,9 @@ class StatChannel(commands.Cog):
|
|||||||
async def before_update_stat_mao(self):
|
async def before_update_stat_mao(self):
|
||||||
await self.bot.wait_until_ready()
|
await self.bot.wait_until_ready()
|
||||||
|
|
||||||
|
@update_members.before_loop
|
||||||
|
async def before_update_members(self):
|
||||||
|
await self.bot.wait_until_ready()
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
await bot.add_cog(StatChannel(bot))
|
await bot.add_cog(StatChannel(bot))
|
||||||
@@ -117,6 +117,9 @@ class SysInfo(commands.Cog):
|
|||||||
async with session.get('https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=1282270') as r:
|
async with session.get('https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=1282270') as r:
|
||||||
data = await r.json()
|
data = await r.json()
|
||||||
count = data['response']['player_count']
|
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')
|
await ctx.reply(f'🎮 Strinova — {count:,} players online right now')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -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(
|
||||||
|
|||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
cogs/welcome.py
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Welcome / goodbye cards.
|
||||||
|
|
||||||
|
• When someone joins, posts an "Aurora" card in WELCOME_CHANNEL_ID: their
|
||||||
|
avatar blurred into the background, the avatar itself in a ring on the left,
|
||||||
|
and their name + join date beside it. The new member is pinged.
|
||||||
|
• When someone leaves, posts the same card in black & white with how long
|
||||||
|
they were here. No ping.
|
||||||
|
• Bots are skipped, and only joins/leaves of the channel's own server count.
|
||||||
|
• Fonts: Inter (SIL OFL), bundled in welcome_asset/fonts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageOps
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FONT_DIR = Path(__file__).parent.parent / "welcome_asset" / "fonts"
|
||||||
|
W, H = 1200, 400
|
||||||
|
SS = 2 # draw at 2x and downscale for smooth edges
|
||||||
|
|
||||||
|
|
||||||
|
def _font(weight: str, size: int) -> ImageFont.FreeTypeFont:
|
||||||
|
return ImageFont.truetype(str(FONT_DIR / f"Inter-{weight}.ttf"), size * SS)
|
||||||
|
|
||||||
|
|
||||||
|
def _renderable(text: str) -> bool:
|
||||||
|
"""True if Inter has a glyph for every character (no tofu boxes)."""
|
||||||
|
f = _font("Bold", 20)
|
||||||
|
|
||||||
|
def glyph(c: str) -> bytes:
|
||||||
|
img = Image.new("L", (100, 100))
|
||||||
|
ImageDraw.Draw(img).text((10, 10), c, font=f, fill=255)
|
||||||
|
return img.tobytes()
|
||||||
|
|
||||||
|
tofu = glyph("")
|
||||||
|
return all(c.isspace() or glyph(c) != tofu for c in text)
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_font(draw: ImageDraw.ImageDraw, text: str, weight: str, size: int, max_w: int):
|
||||||
|
while size > 20:
|
||||||
|
f = _font(weight, size)
|
||||||
|
if draw.textlength(text, font=f) <= max_w * SS:
|
||||||
|
return f
|
||||||
|
size -= 2
|
||||||
|
return _font(weight, size)
|
||||||
|
|
||||||
|
|
||||||
|
def _tracked(draw: ImageDraw.ImageDraw, xy, text: str, f, fill, tracking: int) -> None:
|
||||||
|
"""Draw text with letter-spacing (tracking in px at 1x)."""
|
||||||
|
x, y = xy
|
||||||
|
for ch in text:
|
||||||
|
draw.text((x, y), ch, font=f, fill=fill)
|
||||||
|
x += draw.textlength(ch, font=f) + tracking * SS
|
||||||
|
|
||||||
|
|
||||||
|
def _circle(img: Image.Image, d: int) -> Image.Image:
|
||||||
|
img = ImageOps.fit(img, (d, d), Image.LANCZOS)
|
||||||
|
mask = Image.new("L", (d, d), 0)
|
||||||
|
ImageDraw.Draw(mask).ellipse((0, 0, d - 1, d - 1), fill=255)
|
||||||
|
out = Image.new("RGBA", (d, d))
|
||||||
|
out.paste(img, (0, 0), mask)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def render_card(avatar: bytes, name: str, label: str, line1: str, line2: str, leaving: bool) -> io.BytesIO:
|
||||||
|
"""Render the Aurora card; goodbye cards are greyscale."""
|
||||||
|
av = Image.open(io.BytesIO(avatar)).convert("RGBA")
|
||||||
|
cw, ch = W * SS, H * SS
|
||||||
|
|
||||||
|
bg = ImageOps.fit(av.convert("RGB"), (cw, ch), Image.LANCZOS, centering=(0.5, 0.35))
|
||||||
|
bg = bg.filter(ImageFilter.GaussianBlur(60 * SS))
|
||||||
|
bg = ImageEnhance.Color(bg).enhance(1.8)
|
||||||
|
bg = ImageEnhance.Brightness(bg).enhance(0.5)
|
||||||
|
canvas = bg.convert("RGBA")
|
||||||
|
# soft left-to-right darkening so the text stays readable
|
||||||
|
shade = Image.linear_gradient("L").rotate(90).resize((cw, ch))
|
||||||
|
canvas = Image.composite(Image.new("RGBA", (cw, ch), (0, 0, 0, 255)), canvas, shade.point(lambda v: v * 0.35))
|
||||||
|
d = ImageDraw.Draw(canvas)
|
||||||
|
|
||||||
|
ad = 220 * SS
|
||||||
|
ax, ay = 90 * SS, (ch - ad) // 2
|
||||||
|
ring = 6 * SS
|
||||||
|
d.ellipse((ax - ring, ay - ring, ax + ad + ring, ay + ad + ring), fill=(255, 255, 255, 230))
|
||||||
|
canvas.alpha_composite(_circle(av, ad), (ax, ay))
|
||||||
|
|
||||||
|
tx = ax + ad + 70 * SS
|
||||||
|
max_w = W - tx // SS - 70
|
||||||
|
_tracked(d, (tx, 92 * SS), label, _font("SemiBold", 22), (255, 255, 255, 170), 8)
|
||||||
|
d.text((tx, 130 * SS), name, font=_fit_font(d, name, "Bold", 76, max_w), fill=(255, 255, 255))
|
||||||
|
d.text((tx, 238 * SS), line1, font=_fit_font(d, line1, "Regular", 28, max_w), fill=(255, 255, 255, 215))
|
||||||
|
d.text((tx, 282 * SS), line2, font=_font("Light", 24), fill=(255, 255, 255, 150))
|
||||||
|
|
||||||
|
if leaving:
|
||||||
|
canvas = ImageOps.grayscale(canvas.convert("RGB")).convert("RGBA")
|
||||||
|
|
||||||
|
card = canvas.resize((W, H), Image.LANCZOS)
|
||||||
|
mask = Image.new("L", (W, H), 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle((0, 0, W, H), 28, fill=255)
|
||||||
|
card.putalpha(mask)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
card.save(buf, "PNG")
|
||||||
|
buf.seek(0)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def _stayed(joined: datetime | None) -> str:
|
||||||
|
"""'Was here for 4 months' style duration."""
|
||||||
|
if joined is None:
|
||||||
|
return ""
|
||||||
|
secs = (datetime.now(timezone.utc) - joined).total_seconds()
|
||||||
|
for unit, size in (("year", 365 * 86400), ("month", 30 * 86400), ("day", 86400),
|
||||||
|
("hour", 3600), ("minute", 60)):
|
||||||
|
n = int(secs // size)
|
||||||
|
if n >= 1:
|
||||||
|
return f"Was here for {n} {unit}{'s' if n != 1 else ''}"
|
||||||
|
return "Was here for less than a minute"
|
||||||
|
|
||||||
|
|
||||||
|
class Welcome(commands.Cog):
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
val = os.getenv("WELCOME_CHANNEL_ID", "")
|
||||||
|
self.channel_id = int(val) if val.isdigit() else None
|
||||||
|
|
||||||
|
def _channel(self, guild: discord.Guild) -> discord.TextChannel | None:
|
||||||
|
if self.channel_id is None:
|
||||||
|
return None
|
||||||
|
channel = self.bot.get_channel(self.channel_id)
|
||||||
|
return channel if channel and channel.guild.id == guild.id else None
|
||||||
|
|
||||||
|
def _display(self, member: discord.Member) -> str:
|
||||||
|
name = member.display_name
|
||||||
|
return name if _renderable(name) else member.name
|
||||||
|
|
||||||
|
async def _post(self, member, channel, label, line1, line2, leaving, content):
|
||||||
|
try:
|
||||||
|
avatar = await member.display_avatar.replace(size=512, format="png").read()
|
||||||
|
img = await asyncio.to_thread(
|
||||||
|
render_card, avatar, self._display(member), label, line1, line2, leaving)
|
||||||
|
await channel.send(
|
||||||
|
content,
|
||||||
|
file=discord.File(img, filename="goodbye.png" if leaving else "welcome.png"),
|
||||||
|
allowed_mentions=discord.AllowedMentions(users=True),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception("welcome card failed for %s", member)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_member_join(self, member: discord.Member):
|
||||||
|
channel = self._channel(member.guild)
|
||||||
|
if member.bot or channel is None:
|
||||||
|
return
|
||||||
|
joined = member.joined_at or datetime.now(timezone.utc)
|
||||||
|
await self._post(member, channel, "WELCOME", f"Welcome to {member.guild.name}",
|
||||||
|
f"Joined {joined:%b} {joined.day}, {joined.year}", False, member.mention)
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_member_remove(self, member: discord.Member):
|
||||||
|
channel = self._channel(member.guild)
|
||||||
|
if member.bot or channel is None:
|
||||||
|
return
|
||||||
|
await self._post(member, channel, "GOODBYE", f"Left {member.guild.name}",
|
||||||
|
_stayed(member.joined_at), True, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot):
|
||||||
|
await bot.add_cog(Welcome(bot))
|
||||||
@@ -17,6 +17,10 @@ TWITTER_DISCORD_WEBHOOK_MAO="yo discord twitter channel here for mao server"
|
|||||||
#strinova stats voice channel
|
#strinova stats voice channel
|
||||||
STRINOVA_STAT_CHANNEL_ID="yo strinova stat channel here"
|
STRINOVA_STAT_CHANNEL_ID="yo strinova stat channel here"
|
||||||
STRINOVA_STAT_CHANNEL_ID_MAO="yo strinova stat channel here for mao server"
|
STRINOVA_STAT_CHANNEL_ID_MAO="yo strinova stat channel here for mao server"
|
||||||
|
#member count voice channel
|
||||||
|
MEMBER_COUNT_CHANNEL_ID="yo member count channel here"
|
||||||
|
#welcome / goodbye cards channel
|
||||||
|
WELCOME_CHANNEL_ID="yo welcome channel here"
|
||||||
|
|
||||||
#me (for purging and other owner only things)
|
#me (for purging and other owner only things)
|
||||||
OWNER_ID="your discord servers owner id here"
|
OWNER_ID="your discord servers owner id here"
|
||||||
|
|||||||
@@ -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.
|
Requires GOOGLE_APPLICATION_CREDENTIALS env var pointing to a service-account JSON.
|
||||||
|
|
||||||
This package is loaded as a Discord extension purely for lifecycle hooks
|
Loaded as a Discord extension only so missing credentials fail at startup.
|
||||||
(setup / teardown). It does not register a Cog — it starts a background
|
The calendar and bucketlist cogs use get_db().
|
||||||
SysInfoSync task instead.
|
|
||||||
|
|
||||||
Usage from main.py:
|
Usage from main.py:
|
||||||
await bot.load_extension("firebase_website")
|
await bot.load_extension("firebase_website")
|
||||||
"""
|
"""
|
||||||
######################################################################
|
######################################################################
|
||||||
import logging
|
|
||||||
|
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from .config import get_db # noqa: re‑export for calendar, bucketlist, etc.
|
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:
|
async def setup(bot: commands.Bot) -> None:
|
||||||
global _sync
|
|
||||||
get_db() # fail fast if credentials are missing
|
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:
|
|
||||||
_sync.stop()
|
|
||||||
@@ -8,7 +8,6 @@ from google.cloud.firestore import AsyncClient
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROJECT_ID = "esef-514bf"
|
PROJECT_ID = "esef-514bf"
|
||||||
SYSINFO_INTERVAL = 10 # seconds between pushes
|
|
||||||
|
|
||||||
_db: AsyncClient | None = None
|
_db: AsyncClient | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,147 +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 .config import get_db, SYSINFO_INTERVAL
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
FASTFETCH_CACHE_SECS = 300 # refresh fastfetch every 5 minutes
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
if self._task is None:
|
|
||||||
return
|
|
||||||
self._task.cancel()
|
|
||||||
self._task = None
|
|
||||||
log.info("SysInfo → Firestore sync stopped")
|
|
||||||
|
|
||||||
# ── 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"],
|
|
||||||
"load1": stats["load1"],
|
|
||||||
"load5": stats["load5"],
|
|
||||||
"load15": stats["load15"],
|
|
||||||
"netSent": stats["net_sent"],
|
|
||||||
"netRecv": stats["net_recv"],
|
|
||||||
"fetch": fetch,
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
net = psutil.net_io_counters()
|
|
||||||
uptime_seconds = int(time.time() - psutil.boot_time())
|
|
||||||
|
|
||||||
days, r = divmod(uptime_seconds, 86400)
|
|
||||||
hours, r = divmod(r, 3600)
|
|
||||||
minutes, _ = divmod(r, 60)
|
|
||||||
uptime_str = f"{days}d {hours}h {minutes}m" if days else f"{hours}h {minutes}m"
|
|
||||||
|
|
||||||
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": uptime_str,
|
|
||||||
"load1": round(load1, 1),
|
|
||||||
"load5": round(load5, 1),
|
|
||||||
"load15": round(load15, 1),
|
|
||||||
"net_sent": _fmt_size(net.bytes_sent),
|
|
||||||
"net_recv": _fmt_size(net.bytes_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
|
|
||||||
@@ -5,7 +5,7 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
# from twitterbridge.rss import TwitterRSSForwarder
|
from twitterbridge.rss import TwitterRSSForwarder
|
||||||
|
|
||||||
load_dotenv('credentials.env')
|
load_dotenv('credentials.env')
|
||||||
|
|
||||||
@@ -18,20 +18,25 @@ bot = commands.Bot(command_prefix='!', intents=intents, help_command=None)
|
|||||||
# The Telegram bridge discovers its own routes from credentials.env and
|
# The Telegram bridge discovers its own routes from credentials.env and
|
||||||
# starts polling when the extension loads — see tgbridge/config.py.
|
# starts polling when the extension loads — see tgbridge/config.py.
|
||||||
|
|
||||||
# twitter = TwitterRSSForwarder(
|
async def alert_owner(text: str):
|
||||||
# webhook_url=os.getenv('TWITTER_DISCORD_WEBHOOK'),
|
owner = await bot.fetch_user(int(os.getenv('OWNER_ID', '0')))
|
||||||
# )
|
await owner.send(text)
|
||||||
|
|
||||||
# twitter_mao = TwitterRSSForwarder(
|
# one poller for the @CalabiyauLeaks feed, posting to both servers
|
||||||
# webhook_url=os.getenv('TWITTER_DISCORD_WEBHOOK_MAO'),
|
twitter = TwitterRSSForwarder(
|
||||||
# guid_file='data/last_tweet_mao.txt',
|
webhooks={
|
||||||
# )
|
'main': os.getenv('TWITTER_DISCORD_WEBHOOK'),
|
||||||
|
'mao': os.getenv('TWITTER_DISCORD_WEBHOOK_MAO'),
|
||||||
|
},
|
||||||
|
alert=alert_owner,
|
||||||
|
)
|
||||||
|
|
||||||
@bot.event
|
@bot.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
print(f'[+] {bot.user} is online!')
|
print(f'[+] {bot.user} is online!')
|
||||||
# asyncio.create_task(twitter.start())
|
# on_ready fires again after reconnects; don't start a duplicate poller
|
||||||
# asyncio.create_task(twitter_mao.start())
|
if not twitter._running:
|
||||||
|
asyncio.create_task(twitter.start())
|
||||||
# Clear stale global slash commands
|
# Clear stale global slash commands
|
||||||
try:
|
try:
|
||||||
await bot.tree.sync()
|
await bot.tree.sync()
|
||||||
@@ -42,18 +47,20 @@ async def main():
|
|||||||
async with bot:
|
async with bot:
|
||||||
await bot.load_extension('tgbridge')
|
await bot.load_extension('tgbridge')
|
||||||
await bot.load_extension('cogs.general')
|
await bot.load_extension('cogs.general')
|
||||||
|
await bot.load_extension('cogs.help')
|
||||||
await bot.load_extension('cogs.chance')
|
await bot.load_extension('cogs.chance')
|
||||||
await bot.load_extension('cogs.gifs')
|
await bot.load_extension('cogs.gifs')
|
||||||
await bot.load_extension('cogs.copypasta')
|
await bot.load_extension('cogs.copypasta')
|
||||||
await bot.load_extension('cogs.sysinfo')
|
await bot.load_extension('cogs.sysinfo')
|
||||||
await bot.load_extension('music.music_wrapper')
|
await bot.load_extension('music.music_wrapper')
|
||||||
await bot.load_extension('cogs.statchannel')
|
await bot.load_extension('cogs.statchannel')
|
||||||
|
await bot.load_extension('cogs.welcome')
|
||||||
await bot.load_extension('cogs.ffmpeg_things')
|
await bot.load_extension('cogs.ffmpeg_things')
|
||||||
await bot.load_extension('cogs.roles')
|
await bot.load_extension('cogs.roles')
|
||||||
await bot.load_extension('cogs.gitpull')
|
await bot.load_extension('cogs.gitpull')
|
||||||
await bot.load_extension('cogs.currency_converter')
|
await bot.load_extension('cogs.currency_converter')
|
||||||
await bot.load_extension('ai.ai_roleplay')
|
await bot.load_extension('ai.ai_roleplay')
|
||||||
# await bot.load_extension('twitterbridge.leaks_cmd')
|
await bot.load_extension('twitterbridge.leaks_cmd')
|
||||||
await bot.load_extension('blue_archive.gacha')
|
await bot.load_extension('blue_archive.gacha')
|
||||||
await bot.load_extension('blue_archive.inventory')
|
await bot.load_extension('blue_archive.inventory')
|
||||||
await bot.load_extension('firebase_website')
|
await bot.load_extension('firebase_website')
|
||||||
@@ -67,11 +74,8 @@ async def main():
|
|||||||
finally:
|
finally:
|
||||||
# bot.close() unloads extensions, so tgbridge.teardown stops the
|
# bot.close() unloads extensions, so tgbridge.teardown stops the
|
||||||
# Telegram poller and closes its aiohttp session for us
|
# Telegram poller and closes its aiohttp session for us
|
||||||
pass
|
twitter.stop()
|
||||||
# twitter.stop()
|
await twitter.close()
|
||||||
# twitter_mao.stop()
|
|
||||||
# await twitter.close()
|
|
||||||
# await twitter_mao.close()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logging.getLogger('httpx').setLevel(logging.WARNING)
|
logging.getLogger('httpx').setLevel(logging.WARNING)
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import aiohttp
|
|||||||
import feedparser
|
import feedparser
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from twitterbridge.rss import embed_link, tweet_id
|
||||||
|
|
||||||
REQUEST_TIMEOUT = 15
|
REQUEST_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
@@ -49,11 +51,10 @@ class TwitterLeaks(commands.Cog):
|
|||||||
return await ctx.reply(msg[:2000])
|
return await ctx.reply(msg[:2000])
|
||||||
|
|
||||||
response = "**Latest 5 Leaks:**\n"
|
response = "**Latest 5 Leaks:**\n"
|
||||||
|
# the feed isn't chronological; status ids are
|
||||||
|
entries = sorted(entries, key=lambda e: tweet_id(e.get('id') or e.get('link', '')) or 0, reverse=True)
|
||||||
for entry in entries[:5]:
|
for entry in entries[:5]:
|
||||||
link = entry.get('link', '')
|
response += f"\n{embed_link(entry.get('link', ''))}\n"
|
||||||
if 'nitter.net' in link:
|
|
||||||
link = link.replace('nitter.net', 'twitter.com').replace('#m', '')
|
|
||||||
response += f'\n{link}\n'
|
|
||||||
|
|
||||||
await ctx.reply(response[:2000])
|
await ctx.reply(response[:2000])
|
||||||
|
|
||||||
|
|||||||
+192
-134
@@ -1,29 +1,66 @@
|
|||||||
# Twitter/X RSS-to-Discord forwarding via Nitter RSS feeds.
|
# Twitter/X RSS-to-Discord forwarding (feed: self-hosted RSSHub, see esefrss).
|
||||||
# Polls RSS endpoints on a timer, auto-rotates through fallback URLs
|
# One poller fetches the feed and fans new entries out to every webhook.
|
||||||
# when an instance returns empty or fails, and posts new entries
|
# Each webhook remembers which tweets it has already posted, so a reordered,
|
||||||
# to a Discord webhook.
|
# partial or briefly broken feed never skips or reposts anything.
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import random
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import feedparser
|
import feedparser
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
POLL_INTERVAL = 300 # 5 minutes
|
# The feed comes from RSSHub on yuuka using a real X account's cookie, so
|
||||||
REQUEST_TIMEOUT = 30 # seconds per HTTP call
|
# polls are jittered and much slower at night to avoid a robotic 24/7 pattern.
|
||||||
MAX_EMPTY_STRIKES = 3 # consecutive empty polls before rotating URL
|
POLL_INTERVAL = (240, 480) # daytime: random 4-8 minutes
|
||||||
|
NIGHT_POLL_INTERVAL = (3600, 7200) # night: random 1-2 hours
|
||||||
|
NIGHT_HOURS = range(1, 6) # 01:00-05:59 Istanbul; the account posts ~06:00-08:00
|
||||||
|
LOCAL_TZ = ZoneInfo('Europe/Istanbul')
|
||||||
|
REQUEST_TIMEOUT = 30 # seconds per HTTP call
|
||||||
|
SEEN_LIMIT = 500 # remembered guids per webhook (the feed holds 20)
|
||||||
|
POST_GAP = 1.5 # seconds between webhook posts
|
||||||
|
POST_ATTEMPTS = 3
|
||||||
|
ALERT_AFTER = 90 * 60 # feed broken this long -> DM the owner
|
||||||
|
|
||||||
|
EMBED_HOST = 'fxtwitter.com' # proper Discord embeds; X never sees these
|
||||||
|
|
||||||
|
|
||||||
|
def embed_link(link: str) -> str:
|
||||||
|
"""Point a tweet link at fxtwitter so Discord shows a real embed."""
|
||||||
|
parts = urlsplit(link.replace('#m', ''))
|
||||||
|
if parts.netloc in ('x.com', 'twitter.com', 'www.twitter.com', 'nitter.net'):
|
||||||
|
parts = parts._replace(netloc=EMBED_HOST)
|
||||||
|
return urlunsplit(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _guid(entry) -> str:
|
||||||
|
return entry.get('id') or entry.get('link', '')
|
||||||
|
|
||||||
|
|
||||||
|
def tweet_id(guid: str) -> int | None:
|
||||||
|
"""Status id from a guid. RSSHub uses the timeline entry's own id (a retweet's
|
||||||
|
id, not the original's), so ids grow in the order the account posted them,
|
||||||
|
unlike the feed order and pubDate (the original tweet's date)."""
|
||||||
|
tail = guid.rstrip('/').rsplit('/', 1)[-1]
|
||||||
|
return int(tail) if tail.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
class TwitterRSSForwarder:
|
class TwitterRSSForwarder:
|
||||||
"""Polls a Twitter RSS feed and forwards new items to a Discord webhook.
|
"""Polls the Twitter RSS feed and forwards new items to Discord webhooks.
|
||||||
|
|
||||||
Reads TWITTER_RSS_URL and optional TWITTER_RSS_FALLBACKS from the
|
Reads TWITTER_RSS_URL and optional comma-separated TWITTER_RSS_FALLBACKS.
|
||||||
environment. Falls back through URLs when the current one fails or
|
`webhooks` maps a name (used in the state file) to a webhook URL.
|
||||||
returns empty entries.
|
`alert` is an optional coroutine taking a message, called when the feed
|
||||||
|
has been broken for ALERT_AFTER and again when it recovers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, webhook_url: str, guid_file: str = 'data/last_tweet.txt'):
|
def __init__(self, webhooks: dict[str, str], state_file: str = 'data/twitter_seen.json', alert=None):
|
||||||
primary = os.getenv('TWITTER_RSS_URL', '')
|
primary = os.getenv('TWITTER_RSS_URL', '')
|
||||||
fallbacks_raw = os.getenv('TWITTER_RSS_FALLBACKS', '')
|
fallbacks_raw = os.getenv('TWITTER_RSS_FALLBACKS', '')
|
||||||
|
|
||||||
@@ -35,15 +72,16 @@ class TwitterRSSForwarder:
|
|||||||
raise ValueError('TWITTER_RSS_URL is not set')
|
raise ValueError('TWITTER_RSS_URL is not set')
|
||||||
|
|
||||||
self.rss_urls = urls
|
self.rss_urls = urls
|
||||||
self.webhook_url = webhook_url
|
self.webhooks = {name: url for name, url in webhooks.items() if url}
|
||||||
self.guid_file = guid_file
|
self.state_file = state_file
|
||||||
|
self.alert = alert
|
||||||
|
|
||||||
self.last_guid: str | None = None
|
self.seen: dict[str, list[str]] = {}
|
||||||
self._running = False
|
self._running = False
|
||||||
self._session: aiohttp.ClientSession | None = None
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
self._current_idx = 0
|
self._bad_since: float | None = None
|
||||||
self._empty_strikes = 0
|
self._alerted = False
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public lifecycle
|
# Public lifecycle
|
||||||
@@ -51,16 +89,13 @@ class TwitterRSSForwarder:
|
|||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
self._running = True
|
self._running = True
|
||||||
self._load_last_guid()
|
self._load_state()
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT)
|
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT)
|
||||||
self._session = aiohttp.ClientSession(
|
self._session = aiohttp.ClientSession(timeout=timeout)
|
||||||
timeout=timeout,
|
|
||||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info('twitter rss forwarder started — %d URLs, current: %s',
|
logger.info('twitter rss forwarder started — %s -> %s',
|
||||||
len(self.rss_urls), self.rss_urls[self._current_idx])
|
self.rss_urls[0], ', '.join(self.webhooks) or 'no webhooks')
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
@@ -72,11 +107,16 @@ class TwitterRSSForwarder:
|
|||||||
logger.exception('unhandled error in twitter rss poll loop — will retry next cycle')
|
logger.exception('unhandled error in twitter rss poll loop — will retry next cycle')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(POLL_INTERVAL)
|
await asyncio.sleep(self._next_delay())
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self._running = False
|
self._running = False
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _next_delay() -> float:
|
||||||
|
night = datetime.now(LOCAL_TZ).hour in NIGHT_HOURS
|
||||||
|
return random.uniform(*(NIGHT_POLL_INTERVAL if night else POLL_INTERVAL))
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
logger.info('twitter rss forwarder stop requested')
|
logger.info('twitter rss forwarder stop requested')
|
||||||
@@ -92,143 +132,161 @@ class TwitterRSSForwarder:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _poll_cycle(self):
|
async def _poll_cycle(self):
|
||||||
start_idx = self._current_idx
|
entries, problem = await self._fetch_any()
|
||||||
errors: list[str] = []
|
if not entries:
|
||||||
|
await self._note_bad(problem or 'feed returned no items')
|
||||||
|
return
|
||||||
|
await self._note_good()
|
||||||
|
|
||||||
for offset in range(len(self.rss_urls)):
|
for name, webhook in self.webhooks.items():
|
||||||
idx = (start_idx + offset) % len(self.rss_urls)
|
await self._forward(name, webhook, entries)
|
||||||
url = self.rss_urls[idx]
|
|
||||||
|
|
||||||
|
async def _fetch_any(self) -> tuple[list[dict], str | None]:
|
||||||
|
problems = []
|
||||||
|
for url in self.rss_urls:
|
||||||
try:
|
try:
|
||||||
entries = await self._fetch_feed(url)
|
async with self._session.get(url) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
text = await resp.text()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f'{url}: {exc}')
|
problems.append(f'{url}: {exc}')
|
||||||
logger.warning('twitter rss url failed: %s — %s', url, exc)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not entries:
|
feed = feedparser.parse(text)
|
||||||
logger.debug('twitter rss url returned empty: %s', url)
|
if feed.entries:
|
||||||
self._empty_strikes += 1
|
return feed.entries, None
|
||||||
if self._empty_strikes >= MAX_EMPTY_STRIKES:
|
problems.append(f'{url}: no items' + (f' ({feed.bozo_exception})' if feed.bozo else ''))
|
||||||
logger.warning('twitter rss %d consecutive empty polls — rotating away from %s',
|
return [], '; '.join(problems)
|
||||||
self._empty_strikes, url)
|
|
||||||
self._rotate()
|
|
||||||
else:
|
|
||||||
logger.debug('twitter rss empty strike %d/%d on %s',
|
|
||||||
self._empty_strikes, MAX_EMPTY_STRIKES, url)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._empty_strikes = 0
|
async def _forward(self, name: str, webhook: str, entries: list[dict]):
|
||||||
if idx != self._current_idx:
|
ids = [_guid(e) for e in entries]
|
||||||
logger.info('twitter rss switched to %s', url)
|
seen = self.seen.get(name)
|
||||||
self._current_idx = idx
|
|
||||||
|
|
||||||
await self._process_entries(entries)
|
if seen is None:
|
||||||
|
# first run for this webhook: remember the current feed, post nothing
|
||||||
|
self.seen[name] = ids[::-1]
|
||||||
|
self._save_state()
|
||||||
|
logger.info('twitter rss [%s] first run — remembered %d tweets', name, len(ids))
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.error('twitter rss all %d URLs failed: %s', len(self.rss_urls), '; '.join(errors))
|
seen_set = set(seen)
|
||||||
self._rotate()
|
known = [g for g in ids if g in seen_set]
|
||||||
|
if not known:
|
||||||
async def _fetch_feed(self, url: str) -> list[dict] | None:
|
# nothing in the feed is familiar (long outage or a feed change):
|
||||||
if self._session is None:
|
# re-baseline instead of flooding the channel with the whole page
|
||||||
raise RuntimeError('start() must be called before fetching')
|
logger.warning('twitter rss [%s] no known tweets in feed — re-baselining without posting', name)
|
||||||
|
seen.extend(ids[::-1])
|
||||||
async with self._session.get(url) as resp:
|
del seen[:-SEEN_LIMIT]
|
||||||
resp.raise_for_status()
|
self._save_state()
|
||||||
text = await resp.text()
|
|
||||||
|
|
||||||
feed = feedparser.parse(text)
|
|
||||||
|
|
||||||
if feed.bozo and not feed.entries:
|
|
||||||
logger.warning('twitter rss parse error from %s: %s', url, feed.bozo_exception)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return feed.entries
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Entry processing
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _process_entries(self, entries: list[dict]):
|
|
||||||
if self.last_guid is None:
|
|
||||||
self.last_guid = entries[0].get('id', '')
|
|
||||||
self._save_last_guid()
|
|
||||||
logger.info('twitter rss first run — recorded guid: %s', self.last_guid)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find the index of the last known GUID in the current feed.
|
# Unseen entries newer than the oldest known one in this feed are new, wherever
|
||||||
last_idx = None
|
# the feed puts them. Unseen entries older than every known one are old tweets
|
||||||
for i, entry in enumerate(entries):
|
# that slid in when something was deleted, so they are only remembered.
|
||||||
if entry.get('id', '') == self.last_guid:
|
known_ids = [i for i in map(tweet_id, known) if i is not None]
|
||||||
last_idx = i
|
oldest_known = min(known_ids) if known_ids else None
|
||||||
break
|
new, slid_in = [], []
|
||||||
|
for e in entries:
|
||||||
|
g = _guid(e)
|
||||||
|
if g in seen_set:
|
||||||
|
continue
|
||||||
|
tid = tweet_id(g)
|
||||||
|
if tid is not None and oldest_known is not None and tid < oldest_known:
|
||||||
|
slid_in.append(g)
|
||||||
|
else:
|
||||||
|
new.append(e)
|
||||||
|
if slid_in:
|
||||||
|
seen.extend(slid_in)
|
||||||
|
del seen[:-SEEN_LIMIT]
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
if last_idx is None:
|
new.sort(key=lambda e: tweet_id(_guid(e)) or 0)
|
||||||
# Stored GUID not in this feed — nitter returned a stale/partial
|
for entry in new: # oldest first
|
||||||
# snapshot. Update to the newest GUID *without* posting, so we
|
if not await self._post(name, webhook, entry):
|
||||||
# don't spam the channel with duplicates when the full feed returns.
|
break # leave the rest unseen; retried next poll
|
||||||
logger.warning(
|
seen.append(_guid(entry))
|
||||||
'stored guid %s not found in feed (%d entries) — '
|
del seen[:-SEEN_LIMIT]
|
||||||
'updating to %s without posting',
|
self._save_state()
|
||||||
self.last_guid, len(entries), entries[0].get('id', '?'),
|
await asyncio.sleep(POST_GAP)
|
||||||
)
|
|
||||||
self.last_guid = entries[0].get('id', '')
|
|
||||||
self._save_last_guid()
|
|
||||||
return
|
|
||||||
|
|
||||||
new_entries = entries[:last_idx] # everything newer than last known
|
|
||||||
if not new_entries:
|
|
||||||
return
|
|
||||||
|
|
||||||
for entry in reversed(new_entries):
|
|
||||||
await self._post_to_discord(entry)
|
|
||||||
|
|
||||||
self.last_guid = new_entries[0].get('id', '')
|
|
||||||
self._save_last_guid()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Discord webhook
|
# Discord webhook
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _post_to_discord(self, entry):
|
async def _post(self, name: str, webhook: str, entry) -> bool:
|
||||||
link = entry.get('link', '')
|
link = embed_link(entry.get('link', ''))
|
||||||
if 'nitter.net' in link:
|
payload = {'username': '@CalabiyauLeaks', 'content': link}
|
||||||
link = link.replace('nitter.net', 'twitter.com').replace('#m', '')
|
|
||||||
|
|
||||||
payload = {
|
for _ in range(POST_ATTEMPTS):
|
||||||
'username': '@CalabiyauLeaks',
|
try:
|
||||||
'content': link,
|
async with self._session.post(webhook, json=payload) as resp:
|
||||||
}
|
if resp.status in (200, 204):
|
||||||
|
logger.info('twitter rss [%s] posted: %s', name, link)
|
||||||
async with self._session.post(self.webhook_url, json=payload) as resp:
|
return True
|
||||||
if resp.status not in (200, 204):
|
if resp.status == 429:
|
||||||
body = await resp.text()
|
data = await resp.json(content_type=None)
|
||||||
logger.warning('twitter webhook post failed: %s %s', resp.status, body[:200])
|
wait = float(data.get('retry_after', 2))
|
||||||
else:
|
logger.info('twitter rss [%s] rate limited, waiting %.1fs', name, wait)
|
||||||
logger.info('twitter rss posted: %s', link)
|
await asyncio.sleep(min(wait, 60))
|
||||||
|
continue
|
||||||
|
body = await resp.text()
|
||||||
|
logger.warning('twitter rss [%s] webhook post failed: %s %s', name, resp.status, body[:200])
|
||||||
|
if resp.status < 500:
|
||||||
|
return False
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('twitter rss [%s] webhook post error: %s', name, type(exc).__name__)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
return False
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Rotation
|
# Outage alerts
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _rotate(self):
|
async def _note_bad(self, problem: str):
|
||||||
self._current_idx = (self._current_idx + 1) % len(self.rss_urls)
|
now = time.monotonic()
|
||||||
self._empty_strikes = 0
|
if self._bad_since is None:
|
||||||
logger.info('twitter rss rotated to URL %d/%d: %s',
|
self._bad_since = now
|
||||||
self._current_idx + 1, len(self.rss_urls), self.rss_urls[self._current_idx])
|
logger.warning('twitter rss feed problem: %s', problem)
|
||||||
|
|
||||||
|
broken_for = now - self._bad_since
|
||||||
|
if not self._alerted and broken_for >= ALERT_AFTER:
|
||||||
|
self._alerted = True
|
||||||
|
await self._send_alert(
|
||||||
|
f'⚠️ esefrss: the @CalabiyauLeaks feed has been broken for {broken_for / 60:.0f} min '
|
||||||
|
f'({problem[:300]}).\nIf X locked @esefos2: log in on arona, pass the check, then put a fresh '
|
||||||
|
f'auth_token in ~/esefrss/.env and `docker compose up -d --force-recreate` (see esefrss README).'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _note_good(self):
|
||||||
|
if self._alerted:
|
||||||
|
await self._send_alert('✅ esefrss: the @CalabiyauLeaks feed is working again.')
|
||||||
|
self._bad_since = None
|
||||||
|
self._alerted = False
|
||||||
|
|
||||||
|
async def _send_alert(self, text: str):
|
||||||
|
if self.alert is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.alert(text)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('twitter rss alert failed')
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Persistence
|
# Persistence
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _load_last_guid(self):
|
def _load_state(self):
|
||||||
if os.path.exists(self.guid_file):
|
if os.path.exists(self.state_file):
|
||||||
with open(self.guid_file, 'r') as f:
|
with open(self.state_file, 'r') as f:
|
||||||
self.last_guid = f.read().strip() or None
|
self.seen = {k: list(v) for k, v in json.load(f).items()}
|
||||||
|
|
||||||
def _save_last_guid(self):
|
def _save_state(self):
|
||||||
os.makedirs('data', exist_ok=True)
|
os.makedirs(os.path.dirname(self.state_file) or '.', exist_ok=True)
|
||||||
with open(self.guid_file, 'w') as f:
|
tmp = self.state_file + '.tmp'
|
||||||
f.write(self.last_guid or '')
|
with open(tmp, 'w') as f:
|
||||||
|
json.dump(self.seen, f)
|
||||||
|
os.replace(tmp, self.state_file)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Reference in new issue
Block a user