Skip to content
28 changes: 14 additions & 14 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,39 +1,39 @@
# Discord
DISCORD_TOKEN=
# Optional: comma-separated guild IDs for fast command registration in dev
GUILD_IDS=
# GUILD_IDS=

# Twitch (Android first-party tokens)
TWITCH_ACCESS_TOKEN=
TWITCH_REFRESH_TOKEN=
# Optional: alternate Android refresh token (used if primary fails)
TWITCH_REFRESH_TOKEN_ANDROID=
# TWITCH_REFRESH_TOKEN_ANDROID=

# Monitor + paths
TWITCH_REFRESH_MINUTES=30
TWITCH_STATE_PATH=data/campaigns_state.json
TWITCH_GUILD_STORE_PATH=data/guild_config.json
TWITCH_NOTIFY_ON_BOOT=false
# TWITCH_REFRESH_MINUTES=30
# TWITCH_STATE_PATH=data/campaigns_state.json
# TWITCH_GUILD_STORE_PATH=data/guild_config.json
# TWITCH_NOTIFY_ON_BOOT=false

# Environment (single toggle; defaults to prod)
# Set to 'dev' to enable dev-only commands like /drops_notify_random.
# Any value other than 'dev' (or unset) is treated as production.
EVIRONMENT=prod
# EVIRONMENT=prod

# Networking (optional overrides)
# Custom UA if needed; otherwise Android UA is used by default
TWITCH_USER_AGENT=
# TWITCH_USER_AGENT=

# Collage + sending behavior
# Cache TTL for fetched campaigns (seconds)
DROPS_FETCH_TTL_SECONDS=120
# DROPS_FETCH_TTL_SECONDS=120
# Delay between messages when attachments are present (ms)
DROPS_SEND_DELAY_MS=350
# DROPS_SEND_DELAY_MS=350
# Max collages per command (0 = unlimited)
DROPS_MAX_ATTACHMENTS_PER_CMD=0
# DROPS_MAX_ATTACHMENTS_PER_CMD=0
# Optional: cap attachments for notifier specifically (defaults to same as above)
# DROPS_MAX_ATTACHMENTS_PER_NOTIFY=0
# Collage composition
DROPS_ICON_LIMIT=9
DROPS_ICON_COLUMNS=3
DROPS_ICON_SIZE=96
# DROPS_ICON_LIMIT=9
# DROPS_ICON_COLUMNS=3
# DROPS_ICON_SIZE=96
62 changes: 62 additions & 0 deletions DropScout.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import os
import asyncio
from datetime import datetime, timedelta, timezone
import hikari
import lightbulb
from dotenv import load_dotenv
Expand All @@ -23,6 +24,11 @@

from functionality.twitch_drops import DropsMonitor, GuildConfigStore
from functionality.twitch_drops.commands import register_commands
from functionality.twitch_drops.game_catalog import (
ensure_game_catalog_ready_hook,
register_game_catalog_handlers,
warm_game_catalog,
)

# Load .env file and read token
load_dotenv()
Expand Down Expand Up @@ -55,6 +61,7 @@
client = lightbulb.client_from_app(
bot,
default_enabled_guilds=tuple(default_enabled_guilds),
hooks=(ensure_game_catalog_ready_hook,),
)

# Start/stop Lightbulb with the Hikari app lifecycle
Expand All @@ -73,9 +80,11 @@ async def on_started(_: hikari.StartedEvent) -> None:

_monitor: DropsMonitor | None = None
_guild_store = GuildConfigStore(GUILD_STORE_PATH)
_catalog_refresh_task: asyncio.Task | None = None

# Register commands (kept separate for maintainability)
register_commands(client)
register_game_catalog_handlers(client)


@bot.listen(hikari.StartedEvent)
Expand All @@ -88,18 +97,71 @@ async def _note_started(_: hikari.StartedEvent) -> None:
interval_minutes=REFRESH_MINUTES,
state_path=os.getenv("TWITCH_STATE_PATH", "data/campaigns_state.json"),
guild_store_path=GUILD_STORE_PATH,
favorites_store_path=os.getenv("TWITCH_FAVORITES_STORE_PATH", "data/favorites.json"),
notify_on_boot=(os.getenv("TWITCH_NOTIFY_ON_BOOT", "false").lower() == "true"),
)
_monitor.start()
print("DropScout bot ready. Monitoring for campaign changes...")


@bot.listen(hikari.StartedEvent)
async def _prime_game_catalog(_: hikari.StartedEvent) -> None:
async def runner() -> None:
print("📦 Preparing Twitch game cache refresh…")
try:
await warm_game_catalog(state_path=os.getenv("TWITCH_STATE_PATH", "data/campaigns_state.json"))
except Exception as exc:
print(f"Game catalog warm-up failed: {exc}")
asyncio.create_task(runner(), name="twitch-top-games-cache")


def _seconds_until_next_noon_utc() -> float:
now = datetime.now(timezone.utc)
target = now.replace(hour=12, minute=0, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return max((target - now).total_seconds(), 0.0)


@bot.listen(hikari.StartedEvent)
async def _schedule_daily_catalog_refresh(_: hikari.StartedEvent) -> None:
global _catalog_refresh_task
if _catalog_refresh_task and not _catalog_refresh_task.done():
return

state_path = os.getenv("TWITCH_STATE_PATH", "data/campaigns_state.json")

async def scheduler() -> None:
print("📦 Daily Twitch game cache refresh scheduled for 12:00 UTC.")
while True:
delay = _seconds_until_next_noon_utc()
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
break
print("📦 Daily Twitch game cache refresh starting…")
try:
await warm_game_catalog(state_path=state_path)
except Exception as exc:
print(f"⚠️ Daily game cache refresh failed: {exc}")

_catalog_refresh_task = asyncio.create_task(scheduler(), name="twitch-game-cache-daily")


@bot.listen(hikari.StoppingEvent)
async def _note_stopping(_: hikari.StoppingEvent) -> None:
"""Stop the background monitor when the app is shutting down."""
global _monitor
if _monitor:
await _monitor.stop()
global _catalog_refresh_task
if _catalog_refresh_task:
_catalog_refresh_task.cancel()
try:
await _catalog_refresh_task
except asyncio.CancelledError:
pass
_catalog_refresh_task = None


@bot.listen(hikari.GuildJoinEvent)
Expand Down
3 changes: 2 additions & 1 deletion functionality/twitch_drops/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .differ import DropsDiff, DropsDiffer
from .embeds import build_campaign_embed
from .config import GuildConfigStore
from .favorites import FavoritesStore
from .notifier import DropsNotifier
from .monitor import DropsMonitor

Expand All @@ -16,7 +17,7 @@
"DropsDiffer",
"build_campaign_embed",
"GuildConfigStore",
"FavoritesStore",
"DropsNotifier",
"DropsMonitor",
]

8 changes: 8 additions & 0 deletions functionality/twitch_drops/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import lightbulb

from ..config import GuildConfigStore
from ..favorites import FavoritesStore
from ..game_catalog import get_game_catalog
from .common import SharedContext


Expand All @@ -31,6 +33,8 @@ def register_commands(client: lightbulb.Client) -> List[str]:
"""Register all DropScout commands on a Lightbulb client and return names."""
GUILD_STORE_PATH = os.getenv("TWITCH_GUILD_STORE_PATH", "data/guild_config.json")
guild_store = GuildConfigStore(GUILD_STORE_PATH)
FAVORITES_STORE_PATH = os.getenv("TWITCH_FAVORITES_STORE_PATH", "data/favorites.json")
favorites_store = FavoritesStore(FAVORITES_STORE_PATH)

ICON_LIMIT = int(os.getenv("DROPS_ICON_LIMIT", "9") or 9)
ICON_SIZE = int(os.getenv("DROPS_ICON_SIZE", "96") or 96)
Expand All @@ -47,6 +51,8 @@ def register_commands(client: lightbulb.Client) -> List[str]:
MAX_ATTACH_PER_CMD=MAX_ATTACH_PER_CMD,
SEND_DELAY_MS=SEND_DELAY_MS,
FETCH_TTL=FETCH_TTL,
game_catalog=get_game_catalog(),
favorites_store=favorites_store,
)

names: List[str] = []
Expand All @@ -59,6 +65,7 @@ def register_commands(client: lightbulb.Client) -> List[str]:
from .active import register as reg_active
from .this_week import register as reg_this_week
from .search_game import register as reg_search_game
from .favorites import register as reg_favorites

names.append(reg_hello(client, shared))
names.append(reg_help(client, shared))
Expand All @@ -67,6 +74,7 @@ def register_commands(client: lightbulb.Client) -> List[str]:
names.append(reg_set_channel(client, shared))
names.append(reg_channel(client, shared))
names.append(reg_search_game(client, shared))
names.append(reg_favorites(client, shared))

# Dev-only commands
if not _is_production():
Expand Down
2 changes: 1 addition & 1 deletion functionality/twitch_drops/commands/active.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class DropsActive(
async def invoke(self, ctx: lightbulb.Context) -> None:
try:
await ctx.defer()
setattr(ctx, "_dropscout_deferred", True)
except Exception:
pass
recs = await shared.get_campaigns_cached()
Expand Down Expand Up @@ -55,4 +56,3 @@ async def invoke(self, ctx: lightbulb.Context) -> None:
await shared.finalize_interaction(ctx)

return "drops_active"

101 changes: 77 additions & 24 deletions functionality/twitch_drops/commands/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from hikari.files import Bytes, Resourceish

from ..config import GuildConfigStore
from ..favorites import FavoritesStore
from ..game_catalog import GameCatalog
from ..models import CampaignRecord


Expand All @@ -25,6 +27,8 @@ class SharedContext:
MAX_ATTACH_PER_CMD: int
SEND_DELAY_MS: int
FETCH_TTL: int
game_catalog: GameCatalog
favorites_store: FavoritesStore

_cache_data: list[CampaignRecord] = field(default_factory=list)
_cache_exp: float = 0.0
Expand All @@ -39,6 +43,10 @@ async def get_campaigns_cached(self) -> list[CampaignRecord]:
data = await fetcher.fetch_condensed()
self._cache_data = data
self._cache_exp = now_ts + self.FETCH_TTL
try:
self.game_catalog.merge_from_campaign_records(data)
except Exception:
pass
return data

def _get_async(self, ctx: Any, name: str) -> Optional[Callable[..., Awaitable[Any]]]:
Expand All @@ -47,39 +55,84 @@ def _get_async(self, ctx: Any, name: str) -> Optional[Callable[..., Awaitable[An
return None
return cast(Callable[..., Awaitable[Any]], fn)

def _was_deferred(self, ctx: Any) -> bool:
"""Best-effort detection that an interaction was previously deferred."""
for attr in ("_dropscout_deferred", "deferred", "_deferred"):
val = getattr(ctx, attr, None)
if isinstance(val, bool):
if val:
return True
elif val is not None and not callable(val):
try:
if bool(val):
return True
except Exception:
continue
for attr in ("is_deferred",):
val = getattr(ctx, attr, None)
if callable(val):
try:
result = val()
except Exception:
continue
else:
if isinstance(result, bool) and result:
return True
elif isinstance(val, bool) and val:
return True
interaction = getattr(ctx, "interaction", None)
if interaction is None:
return False
for attr in ("is_deferred", "has_responded"):
val = getattr(interaction, attr, None)
if callable(val):
try:
result = val()
except Exception:
continue
else:
if isinstance(result, bool) and result:
return True
elif isinstance(val, bool) and val:
return True
return False

async def finalize_interaction(self, ctx: Any, *, message: Optional[str] = None) -> None:
"""Clear or update the deferred 'thinking…' placeholder if present."""
# Try delete last/initial response first to avoid clutter
fn = self._get_async(ctx, "delete_last_response")
if fn is not None:
content = message if (message is not None and message != "") else "Done."
notify = bool(message not in (None, "")) or self._was_deferred(ctx)

async def _run(name: str, **kwargs: Any) -> bool:
fn = self._get_async(ctx, name)
if fn is None:
return False
try:
await fn()
return
await fn(**kwargs)
except Exception:
pass
fn = self._get_async(ctx, "delete_initial_response")
if fn is not None:
try:
await fn()
return False
return True

if notify:
if await _run("edit_last_response", content=content):
return
except Exception:
pass
# Fall back to editing the placeholder
content = message if (message is not None and message != "") else "Done."
fn = self._get_async(ctx, "edit_last_response")
if fn is not None:
try:
await fn(content=content)
if await _run("edit_initial_response", content=content):
return
except Exception:
pass
fn = self._get_async(ctx, "edit_initial_response")
if fn is not None:
await _run("delete_last_response")
await _run("delete_initial_response")
try:
await fn(content=content)
return
await ctx.respond(content, ephemeral=True)
except Exception:
pass
return

if await _run("delete_last_response"):
return
if await _run("delete_initial_response"):
return
if await _run("edit_last_response", content=content):
return
if await _run("edit_initial_response", content=content):
return
# Absolute last resort: ephemeral follow-up note
try:
await ctx.respond(content, ephemeral=True)
Expand Down
Loading