Compare commits

..
10 Commits
Author SHA1 Message Date
esefandClaude Opus 5.5 1b074d4b58 welcome: aurora welcome/goodbye cards
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:48:46 +03:00
esefandClaude Opus 5.5 5adcf3478e statchannel: member count channel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:33:58 +03:00
esefandClaude Opus 5.5 c51b2ff3f3 README: twitter bridge is live again (esefrss)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 21:32:52 +03:00
esefandClaude Opus 5.5 2af2adc780 twitterbridge: decide new vs old by status id, not feed position
RSSHub's guid is the timeline entry's own id (a retweet's id), so ids follow
the order the account posted, while feed order and pubDate follow the
original tweet. A fresh retweet of an old post can sort to the bottom; it is
now posted, and only ids older than every known one are treated as deletion
slide-ins. !leaks sorts by id too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 21:29:08 +03:00
esefandClaude Opus 5.5 f8b3501637 twitterbridge: one poller, per-webhook seen sets, fxtwitter links, outage DM
The RSSHub feed isn't strictly chronological, so 'post everything above the
last guid' could skip tweets. Now a single poller fans out to both webhooks,
each remembering posted guids (data/twitter_seen.json, atomic writes): new
tweets anywhere above the oldest known one are posted oldest-first, old tweets
sliding in after a deletion are ignored, failed posts retry next poll and
Discord 429s are honoured. Links go out as fxtwitter.com for real embeds.
The owner gets a DM after 90 min of broken feed and again on recovery.
Night polling is now 1-2 h.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 21:09:44 +03:00
esefandClaude Opus 5.5 234fbe88aa twitterbridge: jitter polls and slow down at night
The feed is fetched with a real X account's cookie, so avoid a fixed
24/7 cadence: random 4-8 min by day, 20-35 min 01:00-06:00 Istanbul.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 20:34:26 +03:00
esefandClaude Opus 5.5 2358679904 Re-enable twitter bridge via self-hosted RSSHub (esefrss)
Both forwarders (main + mao server) read TWITTER_RSS_URL, now pointed at
RSSHub on yuuka. Guard on_ready so reconnects don't start duplicate pollers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 20:24:08 +03:00
esef 2691806453 holy shit esef still hasnt forgotten about this bot 2026-10-03 17:59:11 +03:00
esef 4fa7d5ca50 website patch 2026-09-23 16:27:26 +03:00
esef b1ff45794a gacha patch 2 2026-09-23 14:47:52 +03:00
22 changed files with 1190 additions and 260 deletions

No files matched your search

+3 -3
View File
@@ -6,10 +6,10 @@ it started as a simple music bot and became a massive mess of features i sometim
## 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.
- **bridges** — two-way telegram <-> discord message forwarding. twitter/x leaks via rss is implemented but currently disabled (Nitter RSS instances are dead).
- **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).
- **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 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.
- **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.
- **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.
+2
View File
@@ -89,4 +89,6 @@ FILTER_LABELS: dict[str, int | None] = {"All": None, "3★": 3, "2★": 2, "1★
FONT_PATHS = [
"/usr/share/fonts/TTF/DejaVuSans.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",
]
+18 -1
View File
@@ -412,6 +412,22 @@ async def fetch_banners(session: Optional[aiohttp.ClientSession] = None) -> Opti
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:
"""Format a single banner as an embed field value string."""
gtype = banner.get("gachaType", "PickupGacha")
@@ -427,8 +443,9 @@ def format_banner_embed(banner: dict, index: int) -> str:
dates = "Permanent"
is_fes = " [FES - 6% 3*!]" if gtype == "FesGacha" else ""
upcoming = " [UPCOMING]" if started and started > datetime.now(timezone.utc).timestamp() * 1000 else ""
return (
f"**#{index + 1} — {gtype}{is_fes}**\n"
f"**#{index + 1} — {gtype}{is_fes}{upcoming}**\n"
f"Rate-up: {rateups}\n"
f"{dates}"
)
+51 -1
View File
@@ -4,6 +4,7 @@
import asyncio
import json
import logging
import time
from pathlib import Path
from typing import Optional
@@ -15,10 +16,12 @@ from .data import (
db,
fetch_banners,
format_banner_embed,
banner_unavailable_reason,
roll_rarity,
get_rates_for_banner,
)
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
log = logging.getLogger(__name__)
@@ -69,6 +72,10 @@ class BlueArchiveGacha(commands.Cog):
self.bot = bot
self._banner_cache: dict = {"current": [], "upcoming": [], "ended": []}
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 ───────────────────────────────────────────────────────
@@ -87,14 +94,17 @@ class BlueArchiveGacha(commands.Cog):
"""Refresh banners, then merge any new students they might feature."""
try:
banners = await fetch_banners()
self._last_refresh_ok = banners is not None
if banners is None:
log.warning("Banner refresh failed; keeping previous banner data")
else:
self._banner_cache = banners
self._last_good_at = time.time()
await db.merge_ennead(self._banner_cache)
except Exception:
log.exception("Banner refresh failed")
finally:
self._last_refresh_at = time.time()
self._cache_ready.set()
async def _periodic_refresh(self) -> None:
@@ -167,7 +177,8 @@ class BlueArchiveGacha(commands.Cog):
"`!gacha` — list current banners\n"
"`!gacha pick <n>` — select banner #n\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,
)
@@ -229,6 +240,10 @@ class BlueArchiveGacha(commands.Cog):
return
banner = banners[idx]
reason = banner_unavailable_reason(banner)
if reason:
await ctx.reply(reason)
return
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
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`."
)
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"
gtype = banner.get("gachaType", "PickupGacha")
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."
)
return
reason = banner_unavailable_reason(banner)
if reason:
await ctx.reply(reason)
return
rateups = [n.lower() for n in banner.get("rateups", [])]
if student["Name"].lower() not in db.resolve_rateups(rateups):
rateup_str = ", ".join(banner.get("rateups", []))
@@ -445,6 +471,30 @@ class BlueArchiveGacha(commands.Cog):
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")
async def gacha_info(self, ctx: commands.Context) -> None:
"""Show your active banner, spark count, and collection summary."""
+253
View File
@@ -0,0 +1,253 @@
#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
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 wiki art and the sprite fallback 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_sprite(st)))
for st in sample
))
for st, (wiki, sprite) in zip(sample, results):
if wiki is None:
rows.append((OK, s, f"{st['Name']}: wiki art OK"))
elif sprite is None:
rows.append((WARN, s, f"{st['Name']}: wiki art failed ({wiki}), sprite fallback OK"))
else:
rows.append((FAIL, s, f"{st['Name']}: no art (wiki {wiki}, sprite {sprite}) — card renders blank"))
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)
)
+7
View File
@@ -251,6 +251,13 @@ class Inventory(commands.Cog):
view = InventoryView(uid, ctx.author.id)
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:
await bot.add_cog(Inventory(bot))
+14 -18
View File
@@ -3,6 +3,7 @@ import time
import json
import aiohttp
import discord
from discord import app_commands
from discord.ext import commands
# load aliases from json
@@ -47,37 +48,32 @@ class CurrencyConverter(commands.Cog):
_RATE_CACHE[base] = (now, rates)
return rates
@commands.command(name='currency')
async def currency(self, ctx, amount: float = 1, from_cur: str = None, to_cur: str = None):
"""Convert *amount* from *from_cur* to *to_cur*.
If *amount* is omitted the command is parsed as ``!currency <from> <to>``.
"""
# discord.py passes arguments left-to-right; we need to support two signatures.
# When the user supplies only two strings, *amount* will be parsed as the first string.
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
@app_commands.command(name='currency')
@app_commands.rename(from_cur='from', to_cur='to')
@app_commands.describe(from_cur='Currency to convert from, e.g. usd, euro, lira',
to_cur='Currency to convert to',
amount='How much to convert (default 1)')
async def currency(self, interaction: discord.Interaction, from_cur: str, to_cur: str, amount: float = 1.0):
"""Convert money between currencies."""
try:
from_code = self._norm(str(from_cur))
to_code = self._norm(str(to_cur))
from_code = self._norm(from_cur)
to_code = self._norm(to_cur)
except ValueError as e:
return await ctx.reply(f"❌ {e}")
return await interaction.response.send_message(f"❌ {e}", ephemeral=True)
try:
rates = await self._get_rates(from_code)
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)
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
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='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.set_footer(text='Data from frankfurter.app')
await ctx.reply(embed=embed)
await interaction.response.send_message(embed=embed)
async def setup(bot):
await bot.add_cog(CurrencyConverter(bot))
+60 -74
View File
@@ -1,9 +1,9 @@
"""FFmpeg media conversion cog.
Commands operate on a message attachment, or the attachment of a replied-to
message: !togif, !caption <text>, !reverse, !speed <x>, !tomp4, !tomp3,
!toopus. All ffmpeg invocations use argument lists (no shell) and captions
are passed via a textfile to avoid drawtext injection.
Slash commands that take an uploaded file: /togif, /caption <text>,
/reverse, /speed <x>, /tomp4, /tomp3, /toopus. All ffmpeg invocations use
argument lists (no shell) and captions are passed via a textfile to avoid
drawtext injection.
"""
import asyncio
import os
@@ -11,6 +11,7 @@ import shutil
import tempfile
import discord
from discord import app_commands
from discord.ext import commands
MAX_UPLOAD = 50 * 1024 * 1024 # 50 MB input cap
@@ -29,36 +30,33 @@ class FfmpegThings(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
async def _get_attachment(self, message: discord.Message):
if message.attachments:
return message.attachments[0]
ref = message.reference
if ref is not None:
resolved = ref.resolved
if isinstance(resolved, discord.Message) and resolved.attachments:
return resolved.attachments[0]
return None
async def _transcode(self, interaction: discord.Interaction, file: discord.Attachment,
args: list[str], ext: str, caption: str | None = None):
if file.size > MAX_UPLOAD:
await interaction.response.send_message(
f"File too big ({file.size // (1024*1024)} MB). Max {MAX_UPLOAD // (1024*1024)} MB.",
ephemeral=True)
return
async def _transcode(self, ctx: commands.Context, args: list[str], ext: str):
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). Max {MAX_UPLOAD // (1024*1024)} MB.")
return
# ffmpeg easily takes longer than the 3s interaction window
await interaction.response.defer(thinking=True)
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)
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)
if not os.path.exists(out) or os.path.getsize(out) == 0:
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:
await ctx.reply(f"ffmpeg failed: {exc}"[:2000])
await interaction.followup.send(f"ffmpeg failed: {exc}"[:2000])
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
@@ -77,105 +75,93 @@ class FfmpegThings(commands.Cog):
if proc.returncode != 0:
raise RuntimeError(stderr.decode(errors="replace")[:500])
@commands.command()
async def togif(self, ctx: commands.Context):
@app_commands.command()
@app_commands.describe(file="Video to convert")
async def togif(self, interaction: discord.Interaction, file: discord.Attachment):
"""Convert a video to a GIF."""
await self._transcode(
ctx,
interaction, file,
["-vf", "fps=10,scale='min(480,iw)':-2:flags=lanczos"],
".gif",
)
@commands.command()
async def tomp4(self, ctx: commands.Context):
@app_commands.command()
@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)."""
await self._transcode(
ctx,
interaction, file,
["-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart"],
".mp4",
)
@commands.command()
async def tomp3(self, ctx: commands.Context):
@app_commands.command()
@app_commands.describe(file="Video or audio to extract from")
async def tomp3(self, interaction: discord.Interaction, file: discord.Attachment):
"""Extract audio to MP3."""
await self._transcode(
ctx,
interaction, file,
["-vn", "-c:a", "libmp3lame", "-q:a", "2"],
".mp3",
)
@commands.command()
async def toopus(self, ctx: commands.Context):
@app_commands.command()
@app_commands.describe(file="Video or audio to extract from")
async def toopus(self, interaction: discord.Interaction, file: discord.Attachment):
"""Extract audio to Opus."""
await self._transcode(
ctx,
interaction, file,
["-vn", "-c:a", "libopus", "-b:a", "128k"],
".opus",
)
@commands.command()
async def reverse(self, ctx: commands.Context):
@app_commands.command()
@app_commands.describe(file="Video to reverse")
async def reverse(self, interaction: discord.Interaction, file: discord.Attachment):
"""Reverse a video/audio clip."""
await self._transcode(
ctx,
interaction, file,
["-vf", "reverse", "-af", "areverse"],
".mp4",
)
@commands.command()
async def speed(self, ctx: commands.Context, factor: float = 1.0):
@app_commands.command()
@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)."""
if not 0.5 <= factor <= 2.0:
await ctx.reply("Speed must be between 0.5 and 2.0.")
return
await self._transcode(
ctx,
interaction, file,
["-filter_complex",
f"[0:v]setpts=PTS/{factor}[v];[0:a]atempo={factor}[a]",
"-map", "[v]", "-map", "[a]"],
".mp4",
)
@commands.command()
async def caption(self, ctx: commands.Context, *, text: str):
@app_commands.command()
@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."""
text = text.strip()
if not text:
await ctx.reply("Provide a caption, e.g. `!caption hello world`.")
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).")
await interaction.response.send_message("Provide a caption.", ephemeral=True)
return
font = next((f for f in FONT_CANDIDATES if os.path.exists(f)), None)
tmpdir = tempfile.mkdtemp(prefix="ffmpeg_cap_")
inp = os.path.join(tmpdir, "in" + os.path.splitext(attachment.filename)[1])
caption_file = os.path.join(tmpdir, "caption.txt")
out = os.path.join(tmpdir, "out.mp4")
try:
await attachment.save(inp)
with open(caption_file, "w", encoding="utf-8") as f:
f.write(text)
font_arg = f":fontfile={font}" if font else ""
vf = (
f"drawtext=textfile={caption_file}:fontsize=40:fontcolor=white"
"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 self._transcode(
interaction, file,
["-vf", vf, "-c:a", "copy"],
".mp4",
caption=text,
)
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:
+243
View File
@@ -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))
+17
View File
@@ -12,14 +12,18 @@ class StatChannel(commands.Cog):
self.channel_id = _parse_id('STRINOVA_STAT_CHANNEL_ID')
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()
if self.channel_id_mao is not None:
self.update_stat_mao.start()
if self.member_channel_id is not None:
self.update_members.start()
def cog_unload(self):
self.update_stat.cancel()
self.update_stat_mao.cancel()
self.update_members.cancel()
async def _fetch_and_update(self, channel_id):
try:
@@ -41,6 +45,15 @@ class StatChannel(commands.Cog):
async def update_stat_mao(self):
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
async def before_update_stat(self):
await self.bot.wait_until_ready()
@@ -49,5 +62,9 @@ class StatChannel(commands.Cog):
async def before_update_stat_mao(self):
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):
await bot.add_cog(StatChannel(bot))
+179
View File
@@ -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))
+4
View File
@@ -17,6 +17,10 @@ TWITTER_DISCORD_WEBHOOK_MAO="yo discord twitter channel here for mao server"
#strinova stats voice channel
STRINOVA_STAT_CHANNEL_ID="yo strinova stat channel here"
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)
OWNER_ID="your discord servers owner id here"
+1 -1
View File
@@ -32,4 +32,4 @@ async def setup(bot: commands.Bot) -> None:
async def teardown(bot: commands.Bot) -> None:
global _sync
if _sync is not None:
_sync.stop()
await _sync.stop()
+32 -11
View File
@@ -6,6 +6,7 @@ import re
import time
import psutil
from google.cloud import firestore
from .config import get_db, SYSINFO_INTERVAL
@@ -13,6 +14,8 @@ log = logging.getLogger(__name__)
FASTFETCH_CACHE_SECS = 300 # refresh fastfetch every 5 minutes
VIRTUAL_NIC_PREFIXES = ("lo", "docker", "br-", "veth", "tailscale", "tun", "wg", "virbr")
def _fmt_size(b: int) -> str:
"""Bytes → human-readable string (auto-scales B → TB)."""
@@ -23,6 +26,13 @@ def _fmt_size(b: int) -> str:
return f"{b:.1f} PB"
def _fmt_duration(seconds: int) -> str:
days, r = divmod(seconds, 86400)
hours, r = divmod(r, 3600)
minutes, _ = divmod(r, 60)
return f"{days}d {hours}h {minutes}m" if days else f"{hours}h {minutes}m"
class SysInfoSync:
"""Background task: collect system stats and push to Firestore."""
@@ -39,12 +49,18 @@ class SysInfoSync:
self._task = asyncio.create_task(self._run())
log.info("SysInfo → Firestore sync started (every %ds)", SYSINFO_INTERVAL)
def stop(self) -> None:
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
self._task = None
log.info("SysInfo → Firestore sync stopped")
# turns the dot on the website red
try:
await get_db().collection("sysinfo").document("server").set(
{"online": False, "updatedAt": firestore.SERVER_TIMESTAMP}, merge=True)
except Exception:
log.warning("Could not mark sysinfo offline", exc_info=True)
# ── loop ────────────────────────────────────────────────────────────
@@ -77,12 +93,15 @@ class SysInfoSync:
"swapUsed": stats["swap_used"],
"swapTotal": stats["swap_total"],
"uptime": stats["uptime"],
"botUptime": stats["bot_uptime"],
"processes": stats["processes"],
"load1": stats["load1"],
"load5": stats["load5"],
"load15": stats["load15"],
"netSent": stats["net_sent"],
"netRecv": stats["net_recv"],
"fetch": fetch,
"updatedAt": firestore.SERVER_TIMESTAMP,
}
db = get_db()
@@ -97,13 +116,13 @@ class SysInfoSync:
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"
nics = psutil.net_io_counters(pernic=True)
physical = [c for name, c in nics.items() if not name.startswith(VIRTUAL_NIC_PREFIXES)]
net_sent = sum(c.bytes_sent for c in physical)
net_recv = sum(c.bytes_recv for c in physical)
now = time.time()
uptime_seconds = int(now - psutil.boot_time())
bot_uptime_seconds = int(now - psutil.Process().create_time())
return {
"cpu": round(cpu, 1),
@@ -116,12 +135,14 @@ class SysInfoSync:
"swap": round(swap.percent, 1),
"swap_used": _fmt_size(swap.used),
"swap_total": _fmt_size(swap.total),
"uptime": uptime_str,
"uptime": _fmt_duration(uptime_seconds),
"bot_uptime": _fmt_duration(bot_uptime_seconds),
"processes": len(psutil.pids()),
"load1": round(load1, 1),
"load5": round(load5, 1),
"load15": round(load15, 1),
"net_sent": _fmt_size(net.bytes_sent),
"net_recv": _fmt_size(net.bytes_recv),
"net_sent": _fmt_size(net_sent),
"net_recv": _fmt_size(net_recv),
}
async def _get_fastfetch(self) -> str:
+20 -16
View File
@@ -5,7 +5,7 @@ import os
import logging
import asyncio
# from twitterbridge.rss import TwitterRSSForwarder
from twitterbridge.rss import TwitterRSSForwarder
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
# starts polling when the extension loads — see tgbridge/config.py.
# twitter = TwitterRSSForwarder(
# webhook_url=os.getenv('TWITTER_DISCORD_WEBHOOK'),
# )
async def alert_owner(text: str):
owner = await bot.fetch_user(int(os.getenv('OWNER_ID', '0')))
await owner.send(text)
# twitter_mao = TwitterRSSForwarder(
# webhook_url=os.getenv('TWITTER_DISCORD_WEBHOOK_MAO'),
# guid_file='data/last_tweet_mao.txt',
# )
# one poller for the @CalabiyauLeaks feed, posting to both servers
twitter = TwitterRSSForwarder(
webhooks={
'main': os.getenv('TWITTER_DISCORD_WEBHOOK'),
'mao': os.getenv('TWITTER_DISCORD_WEBHOOK_MAO'),
},
alert=alert_owner,
)
@bot.event
async def on_ready():
print(f'[+] {bot.user} is online!')
# asyncio.create_task(twitter.start())
# asyncio.create_task(twitter_mao.start())
# on_ready fires again after reconnects; don't start a duplicate poller
if not twitter._running:
asyncio.create_task(twitter.start())
# Clear stale global slash commands
try:
await bot.tree.sync()
@@ -42,18 +47,20 @@ async def main():
async with bot:
await bot.load_extension('tgbridge')
await bot.load_extension('cogs.general')
await bot.load_extension('cogs.help')
await bot.load_extension('cogs.chance')
await bot.load_extension('cogs.gifs')
await bot.load_extension('cogs.copypasta')
await bot.load_extension('cogs.sysinfo')
await bot.load_extension('music.music_wrapper')
await bot.load_extension('cogs.statchannel')
await bot.load_extension('cogs.welcome')
await bot.load_extension('cogs.ffmpeg_things')
await bot.load_extension('cogs.roles')
await bot.load_extension('cogs.gitpull')
await bot.load_extension('cogs.currency_converter')
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.inventory')
await bot.load_extension('firebase_website')
@@ -67,11 +74,8 @@ async def main():
finally:
# bot.close() unloads extensions, so tgbridge.teardown stops the
# Telegram poller and closes its aiohttp session for us
pass
# twitter.stop()
# twitter_mao.stop()
# await twitter.close()
# await twitter_mao.close()
twitter.stop()
await twitter.close()
logging.basicConfig(level=logging.INFO)
logging.getLogger('httpx').setLevel(logging.WARNING)
+5 -4
View File
@@ -3,6 +3,8 @@ import aiohttp
import feedparser
from discord.ext import commands
from twitterbridge.rss import embed_link, tweet_id
REQUEST_TIMEOUT = 15
@@ -49,11 +51,10 @@ class TwitterLeaks(commands.Cog):
return await ctx.reply(msg[:2000])
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]:
link = entry.get('link', '')
if 'nitter.net' in link:
link = link.replace('nitter.net', 'twitter.com').replace('#m', '')
response += f'\n{link}\n'
response += f"\n{embed_link(entry.get('link', ''))}\n"
await ctx.reply(response[:2000])
+189 -131
View File
@@ -1,29 +1,66 @@
# Twitter/X RSS-to-Discord forwarding via Nitter RSS feeds.
# Polls RSS endpoints on a timer, auto-rotates through fallback URLs
# when an instance returns empty or fails, and posts new entries
# to a Discord webhook.
# Twitter/X RSS-to-Discord forwarding (feed: self-hosted RSSHub, see esefrss).
# One poller fetches the feed and fans new entries out to every webhook.
# Each webhook remembers which tweets it has already posted, so a reordered,
# partial or briefly broken feed never skips or reposts anything.
import os
import json
import time
import random
import asyncio
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from urllib.parse import urlsplit, urlunsplit
import aiohttp
import feedparser
logger = logging.getLogger(__name__)
POLL_INTERVAL = 300 # 5 minutes
# The feed comes from RSSHub on yuuka using a real X account's cookie, so
# polls are jittered and much slower at night to avoid a robotic 24/7 pattern.
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
MAX_EMPTY_STRIKES = 3 # consecutive empty polls before rotating URL
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:
"""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
environment. Falls back through URLs when the current one fails or
returns empty entries.
Reads TWITTER_RSS_URL and optional comma-separated TWITTER_RSS_FALLBACKS.
`webhooks` maps a name (used in the state file) to a webhook URL.
`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', '')
fallbacks_raw = os.getenv('TWITTER_RSS_FALLBACKS', '')
@@ -35,15 +72,16 @@ class TwitterRSSForwarder:
raise ValueError('TWITTER_RSS_URL is not set')
self.rss_urls = urls
self.webhook_url = webhook_url
self.guid_file = guid_file
self.webhooks = {name: url for name, url in webhooks.items() if url}
self.state_file = state_file
self.alert = alert
self.last_guid: str | None = None
self.seen: dict[str, list[str]] = {}
self._running = False
self._session: aiohttp.ClientSession | None = None
self._current_idx = 0
self._empty_strikes = 0
self._bad_since: float | None = None
self._alerted = False
# ------------------------------------------------------------------
# Public lifecycle
@@ -51,16 +89,13 @@ class TwitterRSSForwarder:
async def start(self):
self._running = True
self._load_last_guid()
self._load_state()
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT)
self._session = aiohttp.ClientSession(
timeout=timeout,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'},
)
self._session = aiohttp.ClientSession(timeout=timeout)
logger.info('twitter rss forwarder started — %d URLs, current: %s',
len(self.rss_urls), self.rss_urls[self._current_idx])
logger.info('twitter rss forwarder started — %s -> %s',
self.rss_urls[0], ', '.join(self.webhooks) or 'no webhooks')
while self._running:
try:
@@ -72,11 +107,16 @@ class TwitterRSSForwarder:
logger.exception('unhandled error in twitter rss poll loop — will retry next cycle')
try:
await asyncio.sleep(POLL_INTERVAL)
await asyncio.sleep(self._next_delay())
except asyncio.CancelledError:
self._running = False
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):
self._running = False
logger.info('twitter rss forwarder stop requested')
@@ -92,143 +132,161 @@ class TwitterRSSForwarder:
# ------------------------------------------------------------------
async def _poll_cycle(self):
start_idx = self._current_idx
errors: list[str] = []
for offset in range(len(self.rss_urls)):
idx = (start_idx + offset) % len(self.rss_urls)
url = self.rss_urls[idx]
try:
entries = await self._fetch_feed(url)
except asyncio.CancelledError:
raise
except Exception as exc:
errors.append(f'{url}: {exc}')
logger.warning('twitter rss url failed: %s — %s', url, exc)
continue
entries, problem = await self._fetch_any()
if not entries:
logger.debug('twitter rss url returned empty: %s', url)
self._empty_strikes += 1
if self._empty_strikes >= MAX_EMPTY_STRIKES:
logger.warning('twitter rss %d consecutive empty polls — rotating away from %s',
self._empty_strikes, url)
self._rotate()
else:
logger.debug('twitter rss empty strike %d/%d on %s',
self._empty_strikes, MAX_EMPTY_STRIKES, url)
await self._note_bad(problem or 'feed returned no items')
return
await self._note_good()
self._empty_strikes = 0
if idx != self._current_idx:
logger.info('twitter rss switched to %s', url)
self._current_idx = idx
await self._process_entries(entries)
return
logger.error('twitter rss all %d URLs failed: %s', len(self.rss_urls), '; '.join(errors))
self._rotate()
async def _fetch_feed(self, url: str) -> list[dict] | None:
if self._session is None:
raise RuntimeError('start() must be called before fetching')
for name, webhook in self.webhooks.items():
await self._forward(name, webhook, entries)
async def _fetch_any(self) -> tuple[list[dict], str | None]:
problems = []
for url in self.rss_urls:
try:
async with self._session.get(url) as resp:
resp.raise_for_status()
text = await resp.text()
except asyncio.CancelledError:
raise
except Exception as exc:
problems.append(f'{url}: {exc}')
continue
feed = feedparser.parse(text)
if feed.entries:
return feed.entries, None
problems.append(f'{url}: no items' + (f' ({feed.bozo_exception})' if feed.bozo else ''))
return [], '; '.join(problems)
if feed.bozo and not feed.entries:
logger.warning('twitter rss parse error from %s: %s', url, feed.bozo_exception)
return None
async def _forward(self, name: str, webhook: str, entries: list[dict]):
ids = [_guid(e) for e in entries]
seen = self.seen.get(name)
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)
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
# Find the index of the last known GUID in the current feed.
last_idx = None
for i, entry in enumerate(entries):
if entry.get('id', '') == self.last_guid:
last_idx = i
break
if last_idx is None:
# Stored GUID not in this feed — nitter returned a stale/partial
# snapshot. Update to the newest GUID *without* posting, so we
# don't spam the channel with duplicates when the full feed returns.
logger.warning(
'stored guid %s not found in feed (%d entries) — '
'updating to %s without posting',
self.last_guid, len(entries), entries[0].get('id', '?'),
)
self.last_guid = entries[0].get('id', '')
self._save_last_guid()
seen_set = set(seen)
known = [g for g in ids if g in seen_set]
if not known:
# nothing in the feed is familiar (long outage or a feed change):
# re-baseline instead of flooding the channel with the whole page
logger.warning('twitter rss [%s] no known tweets in feed — re-baselining without posting', name)
seen.extend(ids[::-1])
del seen[:-SEEN_LIMIT]
self._save_state()
return
new_entries = entries[:last_idx] # everything newer than last known
if not new_entries:
return
# Unseen entries newer than the oldest known one in this feed are new, wherever
# the feed puts them. Unseen entries older than every known one are old tweets
# that slid in when something was deleted, so they are only remembered.
known_ids = [i for i in map(tweet_id, known) if i is not None]
oldest_known = min(known_ids) if known_ids else None
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()
for entry in reversed(new_entries):
await self._post_to_discord(entry)
self.last_guid = new_entries[0].get('id', '')
self._save_last_guid()
new.sort(key=lambda e: tweet_id(_guid(e)) or 0)
for entry in new: # oldest first
if not await self._post(name, webhook, entry):
break # leave the rest unseen; retried next poll
seen.append(_guid(entry))
del seen[:-SEEN_LIMIT]
self._save_state()
await asyncio.sleep(POST_GAP)
# ------------------------------------------------------------------
# Discord webhook
# ------------------------------------------------------------------
async def _post_to_discord(self, entry):
link = entry.get('link', '')
if 'nitter.net' in link:
link = link.replace('nitter.net', 'twitter.com').replace('#m', '')
async def _post(self, name: str, webhook: str, entry) -> bool:
link = embed_link(entry.get('link', ''))
payload = {'username': '@CalabiyauLeaks', 'content': link}
payload = {
'username': '@CalabiyauLeaks',
'content': link,
}
async with self._session.post(self.webhook_url, json=payload) as resp:
if resp.status not in (200, 204):
for _ in range(POST_ATTEMPTS):
try:
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)
return True
if resp.status == 429:
data = await resp.json(content_type=None)
wait = float(data.get('retry_after', 2))
logger.info('twitter rss [%s] rate limited, waiting %.1fs', name, wait)
await asyncio.sleep(min(wait, 60))
continue
body = await resp.text()
logger.warning('twitter webhook post failed: %s %s', resp.status, body[:200])
else:
logger.info('twitter rss posted: %s', link)
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):
self._current_idx = (self._current_idx + 1) % len(self.rss_urls)
self._empty_strikes = 0
logger.info('twitter rss rotated to URL %d/%d: %s',
self._current_idx + 1, len(self.rss_urls), self.rss_urls[self._current_idx])
async def _note_bad(self, problem: str):
now = time.monotonic()
if self._bad_since is None:
self._bad_since = now
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
# ------------------------------------------------------------------
def _load_last_guid(self):
if os.path.exists(self.guid_file):
with open(self.guid_file, 'r') as f:
self.last_guid = f.read().strip() or None
def _load_state(self):
if os.path.exists(self.state_file):
with open(self.state_file, 'r') as f:
self.seen = {k: list(v) for k, v in json.load(f).items()}
def _save_last_guid(self):
os.makedirs('data', exist_ok=True)
with open(self.guid_file, 'w') as f:
f.write(self.last_guid or '')
def _save_state(self):
os.makedirs(os.path.dirname(self.state_file) or '.', exist_ok=True)
tmp = self.state_file + '.tmp'
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.
+92
View File
@@ -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.