Gate chat interaction on Twitch live status
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"""Agent Orchestrator - Routes messages and manages agent modes."""
|
"""Agent Orchestrator - Routes messages and manages agent modes."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from app.agent.policies import (
|
from app.agent.policies import (
|
||||||
@@ -54,9 +55,27 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
# Track active sessions
|
# Track active sessions
|
||||||
self.active_sessions: dict[str, dict] = {}
|
self.active_sessions: dict[str, dict] = {}
|
||||||
|
self.chat_interaction_gate: Callable[[str], Awaitable[bool]] | None = None
|
||||||
|
|
||||||
logger.info("AgentOrchestrator initialized with all modes and policies")
|
logger.info("AgentOrchestrator initialized with all modes and policies")
|
||||||
|
|
||||||
|
def set_chat_interaction_gate(
|
||||||
|
self,
|
||||||
|
gate: Callable[[str], Awaitable[bool]] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Set an async gate that must pass before the agent can post to chat."""
|
||||||
|
self.chat_interaction_gate = gate
|
||||||
|
|
||||||
|
async def can_interact_with_chat(self, channel_name: str) -> bool:
|
||||||
|
"""Return whether outbound chat interaction is currently allowed."""
|
||||||
|
if not self.chat_interaction_gate:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return await self.chat_interaction_gate(channel_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Chat interaction gate failed closed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
async def start_session(self, channel_name: str) -> str:
|
async def start_session(self, channel_name: str) -> str:
|
||||||
"""
|
"""
|
||||||
Start a new stream session.
|
Start a new stream session.
|
||||||
@@ -287,6 +306,13 @@ class AgentOrchestrator:
|
|||||||
return {"sent": False, "reason": "session_not_found"}
|
return {"sent": False, "reason": "session_not_found"}
|
||||||
|
|
||||||
channel_name = session_info["channel_name"]
|
channel_name = session_info["channel_name"]
|
||||||
|
if not await self.can_interact_with_chat(channel_name):
|
||||||
|
logger.info(
|
||||||
|
"Agent response suppressed because stream is not live. Session: %s",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
return {"sent": False, "reason": "stream_not_live"}
|
||||||
|
|
||||||
sent = await send_chat_message(channel_name=channel_name, message=message)
|
sent = await send_chat_message(channel_name=channel_name, message=message)
|
||||||
bot_username = settings.TWITCH_BOT_USERNAME or "sanctum_chronicler"
|
bot_username = settings.TWITCH_BOT_USERNAME or "sanctum_chronicler"
|
||||||
|
|
||||||
@@ -495,6 +521,9 @@ class AgentOrchestrator:
|
|||||||
if not self.chat_activity.should_hearthkeeper_prompt(session_id):
|
if not self.chat_activity.should_hearthkeeper_prompt(session_id):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if not await self.can_interact_with_chat(session_info["channel_name"]):
|
||||||
|
return None
|
||||||
|
|
||||||
last_activity_at = self.chat_activity.last_activity_at(session_id)
|
last_activity_at = self.chat_activity.last_activity_at(session_id)
|
||||||
last_prompt_at = session_info.get("last_hearthkeeper_prompt_at")
|
last_prompt_at = session_info.get("last_hearthkeeper_prompt_at")
|
||||||
if last_activity_at and last_prompt_at and last_activity_at > last_prompt_at:
|
if last_activity_at and last_prompt_at and last_activity_at > last_prompt_at:
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class Settings(BaseSettings):
|
|||||||
TWITCH_CHANNEL_NAME: Optional[str] = None
|
TWITCH_CHANNEL_NAME: Optional[str] = None
|
||||||
TWITCH_ACCESS_TOKEN: Optional[str] = None
|
TWITCH_ACCESS_TOKEN: Optional[str] = None
|
||||||
TWITCH_CHAT_ENABLED: bool = True
|
TWITCH_CHAT_ENABLED: bool = True
|
||||||
|
TWITCH_REQUIRE_LIVE_STREAM: bool = True
|
||||||
|
TWITCH_LIVE_STATUS_CACHE_SECONDS: int = 60
|
||||||
|
|
||||||
# LLM
|
# LLM
|
||||||
LLM_PROVIDER: Optional[str] = None # "openai", "ollama", "lm_studio", or None
|
LLM_PROVIDER: Optional[str] = None # "openai", "ollama", "lm_studio", or None
|
||||||
|
|||||||
40
app/main.py
40
app/main.py
@@ -16,6 +16,7 @@ from app.memory.models import AgentActionType
|
|||||||
from app.memory.repository import Repository
|
from app.memory.repository import Repository
|
||||||
from app.exports.markdown import MarkdownExporter
|
from app.exports.markdown import MarkdownExporter
|
||||||
from app.twitch.chat import TwitchChatMessage, TwitchIRCClient, set_active_client
|
from app.twitch.chat import TwitchChatMessage, TwitchIRCClient, set_active_client
|
||||||
|
from app.twitch.live import TwitchLiveStatusService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ agent_loop_task: asyncio.Task | None = None
|
|||||||
twitch_chat_client: TwitchIRCClient | None = None
|
twitch_chat_client: TwitchIRCClient | None = None
|
||||||
twitch_chat_task: asyncio.Task | None = None
|
twitch_chat_task: asyncio.Task | None = None
|
||||||
twitch_session_id: str | None = None
|
twitch_session_id: str | None = None
|
||||||
|
twitch_live_status: TwitchLiveStatusService | None = None
|
||||||
|
|
||||||
|
|
||||||
async def require_admin(
|
async def require_admin(
|
||||||
@@ -88,6 +90,16 @@ def twitch_configured() -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def channel_is_live_for_chat(channel_name: str) -> bool:
|
||||||
|
"""Return whether the agent may interact with chat for a channel."""
|
||||||
|
if not settings.TWITCH_REQUIRE_LIVE_STREAM:
|
||||||
|
return True
|
||||||
|
if not twitch_live_status:
|
||||||
|
return False
|
||||||
|
status = await twitch_live_status.get_status(channel_name)
|
||||||
|
return status.is_live
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_twitch_session(channel_name: str) -> str:
|
async def get_or_create_twitch_session(channel_name: str) -> str:
|
||||||
"""Use an active session for the Twitch channel, or create one."""
|
"""Use an active session for the Twitch channel, or create one."""
|
||||||
if not orchestrator:
|
if not orchestrator:
|
||||||
@@ -140,12 +152,16 @@ async def start_twitch_chat() -> None:
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
"""Initialize database and services on startup."""
|
"""Initialize database and services on startup."""
|
||||||
global orchestrator, agent_loop_task
|
global orchestrator, agent_loop_task, twitch_live_status
|
||||||
try:
|
try:
|
||||||
await init_db()
|
await init_db()
|
||||||
|
twitch_live_status = TwitchLiveStatusService(
|
||||||
|
cache_seconds=settings.TWITCH_LIVE_STATUS_CACHE_SECONDS
|
||||||
|
)
|
||||||
orchestrator = AgentOrchestrator(
|
orchestrator = AgentOrchestrator(
|
||||||
loop_interval_seconds=settings.AGENT_LOOP_INTERVAL_SECONDS
|
loop_interval_seconds=settings.AGENT_LOOP_INTERVAL_SECONDS
|
||||||
)
|
)
|
||||||
|
orchestrator.set_chat_interaction_gate(channel_is_live_for_chat)
|
||||||
await orchestrator.restore_active_sessions()
|
await orchestrator.restore_active_sessions()
|
||||||
await start_twitch_chat()
|
await start_twitch_chat()
|
||||||
agent_loop_task = asyncio.create_task(agent_loop())
|
agent_loop_task = asyncio.create_task(agent_loop())
|
||||||
@@ -158,7 +174,7 @@ async def startup_event():
|
|||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
"""Clean up resources on shutdown."""
|
"""Clean up resources on shutdown."""
|
||||||
global twitch_chat_client
|
global twitch_chat_client, twitch_live_status
|
||||||
|
|
||||||
if twitch_chat_task:
|
if twitch_chat_task:
|
||||||
twitch_chat_task.cancel()
|
twitch_chat_task.cancel()
|
||||||
@@ -168,6 +184,7 @@ async def shutdown_event():
|
|||||||
await twitch_chat_client.disconnect()
|
await twitch_chat_client.disconnect()
|
||||||
set_active_client(None)
|
set_active_client(None)
|
||||||
twitch_chat_client = None
|
twitch_chat_client = None
|
||||||
|
twitch_live_status = None
|
||||||
if agent_loop_task:
|
if agent_loop_task:
|
||||||
agent_loop_task.cancel()
|
agent_loop_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
@@ -470,9 +487,16 @@ async def get_twitch_status() -> dict:
|
|||||||
"""Get Twitch chat connection status."""
|
"""Get Twitch chat connection status."""
|
||||||
configured = twitch_configured()
|
configured = twitch_configured()
|
||||||
client_status = twitch_chat_client.status() if twitch_chat_client else {}
|
client_status = twitch_chat_client.status() if twitch_chat_client else {}
|
||||||
|
live_status = None
|
||||||
|
if twitch_live_status and settings.TWITCH_CHANNEL_NAME:
|
||||||
|
live_status = (
|
||||||
|
await twitch_live_status.get_status(settings.TWITCH_CHANNEL_NAME)
|
||||||
|
).to_dict()
|
||||||
return {
|
return {
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
"running": bool(twitch_chat_task and not twitch_chat_task.done()),
|
"running": bool(twitch_chat_task and not twitch_chat_task.done()),
|
||||||
|
"require_live_stream": settings.TWITCH_REQUIRE_LIVE_STREAM,
|
||||||
|
"live_status": live_status,
|
||||||
"session_id": twitch_session_id,
|
"session_id": twitch_session_id,
|
||||||
**client_status,
|
**client_status,
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
@@ -508,6 +532,15 @@ async def get_current_stream_status() -> dict:
|
|||||||
dashboard_data = serialize_dashboard(dashboard)
|
dashboard_data = serialize_dashboard(dashboard)
|
||||||
runtime = orchestrator.get_hearthkeeper_runtime_status(session_id)
|
runtime = orchestrator.get_hearthkeeper_runtime_status(session_id)
|
||||||
twitch_status = twitch_chat_client.status() if twitch_chat_client else {}
|
twitch_status = twitch_chat_client.status() if twitch_chat_client else {}
|
||||||
|
live_status = None
|
||||||
|
if twitch_live_status:
|
||||||
|
live_status = (
|
||||||
|
await twitch_live_status.get_status(session_info["channel_name"])
|
||||||
|
).to_dict()
|
||||||
|
chat_interaction_allowed = (
|
||||||
|
not settings.TWITCH_REQUIRE_LIVE_STREAM
|
||||||
|
or bool(live_status and live_status["is_live"])
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"session": {
|
"session": {
|
||||||
@@ -523,6 +556,8 @@ async def get_current_stream_status() -> dict:
|
|||||||
"twitch": {
|
"twitch": {
|
||||||
"configured": twitch_configured(),
|
"configured": twitch_configured(),
|
||||||
"running": bool(twitch_chat_task and not twitch_chat_task.done()),
|
"running": bool(twitch_chat_task and not twitch_chat_task.done()),
|
||||||
|
"require_live_stream": settings.TWITCH_REQUIRE_LIVE_STREAM,
|
||||||
|
"live_status": live_status,
|
||||||
"session_id": twitch_session_id,
|
"session_id": twitch_session_id,
|
||||||
**twitch_status,
|
**twitch_status,
|
||||||
},
|
},
|
||||||
@@ -534,6 +569,7 @@ async def get_current_stream_status() -> dict:
|
|||||||
},
|
},
|
||||||
"hearthkeeper": {
|
"hearthkeeper": {
|
||||||
**runtime,
|
**runtime,
|
||||||
|
"chat_interaction_allowed": chat_interaction_allowed,
|
||||||
"last_human_chat_at": (
|
"last_human_chat_at": (
|
||||||
latest_human_message.timestamp.isoformat()
|
latest_human_message.timestamp.isoformat()
|
||||||
if latest_human_message
|
if latest_human_message
|
||||||
|
|||||||
184
app/twitch/live.py
Normal file
184
app/twitch/live.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""Twitch live-stream status checks."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TWITCH_STREAMS_URL = "https://api.twitch.tv/helix/streams"
|
||||||
|
TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TwitchLiveStatus:
|
||||||
|
"""Cached Twitch live status for a channel."""
|
||||||
|
|
||||||
|
channel_name: str
|
||||||
|
is_live: bool
|
||||||
|
checked_at: datetime
|
||||||
|
title: str | None = None
|
||||||
|
game_name: str | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
viewer_count: int | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Return a response-safe representation."""
|
||||||
|
return {
|
||||||
|
"channel": self.channel_name,
|
||||||
|
"is_live": self.is_live,
|
||||||
|
"checked_at": self.checked_at.isoformat(),
|
||||||
|
"title": self.title,
|
||||||
|
"game_name": self.game_name,
|
||||||
|
"started_at": self.started_at,
|
||||||
|
"viewer_count": self.viewer_count,
|
||||||
|
"reason": self.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer_token(access_token: str) -> str:
|
||||||
|
"""Normalize a Twitch token for Helix Authorization."""
|
||||||
|
token = access_token.strip()
|
||||||
|
if token.startswith("oauth:"):
|
||||||
|
return token.removeprefix("oauth:")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
class TwitchLiveStatusService:
|
||||||
|
"""Small cached client for Twitch Helix stream status."""
|
||||||
|
|
||||||
|
def __init__(self, cache_seconds: int = 60):
|
||||||
|
self.cache_duration = timedelta(seconds=max(1, cache_seconds))
|
||||||
|
self._cache: dict[str, TwitchLiveStatus] = {}
|
||||||
|
self._app_access_token: str | None = None
|
||||||
|
self._app_access_token_expires_at: datetime | None = None
|
||||||
|
|
||||||
|
async def get_status(
|
||||||
|
self,
|
||||||
|
channel_name: str,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> TwitchLiveStatus:
|
||||||
|
"""Return current live status for a channel, using a short cache."""
|
||||||
|
normalized_channel = channel_name.strip().lower().lstrip("#")
|
||||||
|
now = datetime.utcnow()
|
||||||
|
cached = self._cache.get(normalized_channel)
|
||||||
|
if (
|
||||||
|
cached
|
||||||
|
and not force_refresh
|
||||||
|
and now - cached.checked_at < self.cache_duration
|
||||||
|
):
|
||||||
|
return cached
|
||||||
|
|
||||||
|
status = await self._fetch_status(normalized_channel, now)
|
||||||
|
self._cache[normalized_channel] = status
|
||||||
|
return status
|
||||||
|
|
||||||
|
async def _fetch_status(
|
||||||
|
self,
|
||||||
|
channel_name: str,
|
||||||
|
checked_at: datetime,
|
||||||
|
) -> TwitchLiveStatus:
|
||||||
|
"""Fetch live status from Twitch Helix."""
|
||||||
|
if not settings.TWITCH_CLIENT_ID or not settings.TWITCH_ACCESS_TOKEN:
|
||||||
|
return TwitchLiveStatus(
|
||||||
|
channel_name=channel_name,
|
||||||
|
is_live=False,
|
||||||
|
checked_at=checked_at,
|
||||||
|
reason="twitch_api_not_configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Client-ID": settings.TWITCH_CLIENT_ID,
|
||||||
|
"Authorization": f"Bearer {_bearer_token(settings.TWITCH_ACCESS_TOKEN)}",
|
||||||
|
}
|
||||||
|
params = {"user_login": channel_name}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get(
|
||||||
|
TWITCH_STREAMS_URL,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
if response.status_code == 401:
|
||||||
|
app_access_token = await self._get_app_access_token(client)
|
||||||
|
if app_access_token:
|
||||||
|
headers["Authorization"] = f"Bearer {app_access_token}"
|
||||||
|
response = await client.get(
|
||||||
|
TWITCH_STREAMS_URL,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to check Twitch live status: %s", e)
|
||||||
|
return TwitchLiveStatus(
|
||||||
|
channel_name=channel_name,
|
||||||
|
is_live=False,
|
||||||
|
checked_at=checked_at,
|
||||||
|
reason="twitch_api_error",
|
||||||
|
)
|
||||||
|
|
||||||
|
streams = response.json().get("data", [])
|
||||||
|
if not streams:
|
||||||
|
return TwitchLiveStatus(
|
||||||
|
channel_name=channel_name,
|
||||||
|
is_live=False,
|
||||||
|
checked_at=checked_at,
|
||||||
|
reason="stream_offline",
|
||||||
|
)
|
||||||
|
|
||||||
|
stream = streams[0]
|
||||||
|
return TwitchLiveStatus(
|
||||||
|
channel_name=channel_name,
|
||||||
|
is_live=True,
|
||||||
|
checked_at=checked_at,
|
||||||
|
title=stream.get("title"),
|
||||||
|
game_name=stream.get("game_name"),
|
||||||
|
started_at=stream.get("started_at"),
|
||||||
|
viewer_count=stream.get("viewer_count"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_app_access_token(self, client: httpx.AsyncClient) -> str | None:
|
||||||
|
"""Get a Twitch app access token for Helix reads."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if (
|
||||||
|
self._app_access_token
|
||||||
|
and self._app_access_token_expires_at
|
||||||
|
and now < self._app_access_token_expires_at
|
||||||
|
):
|
||||||
|
return self._app_access_token
|
||||||
|
|
||||||
|
if not settings.TWITCH_CLIENT_ID or not settings.TWITCH_CLIENT_SECRET:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
TWITCH_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"client_id": settings.TWITCH_CLIENT_ID,
|
||||||
|
"client_secret": settings.TWITCH_CLIENT_SECRET,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to get Twitch app access token: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
access_token = payload.get("access_token")
|
||||||
|
expires_in = int(payload.get("expires_in", 0))
|
||||||
|
if not access_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._app_access_token = access_token
|
||||||
|
self._app_access_token_expires_at = now + timedelta(
|
||||||
|
seconds=max(60, expires_in - 60)
|
||||||
|
)
|
||||||
|
return access_token
|
||||||
@@ -40,6 +40,8 @@ services:
|
|||||||
TWITCH_CHANNEL_NAME: ${TWITCH_CHANNEL_NAME:-}
|
TWITCH_CHANNEL_NAME: ${TWITCH_CHANNEL_NAME:-}
|
||||||
TWITCH_ACCESS_TOKEN: ${TWITCH_ACCESS_TOKEN:-}
|
TWITCH_ACCESS_TOKEN: ${TWITCH_ACCESS_TOKEN:-}
|
||||||
TWITCH_CHAT_ENABLED: ${TWITCH_CHAT_ENABLED:-true}
|
TWITCH_CHAT_ENABLED: ${TWITCH_CHAT_ENABLED:-true}
|
||||||
|
TWITCH_REQUIRE_LIVE_STREAM: ${TWITCH_REQUIRE_LIVE_STREAM:-true}
|
||||||
|
TWITCH_LIVE_STATUS_CACHE_SECONDS: ${TWITCH_LIVE_STATUS_CACHE_SECONDS:-60}
|
||||||
LLM_PROVIDER: ${LLM_PROVIDER:-}
|
LLM_PROVIDER: ${LLM_PROVIDER:-}
|
||||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||||
|
|||||||
Reference in New Issue
Block a user