twitch-byte-bot / bot /irc_state.py
JuanCS-Dev's picture
Upload folder using huggingface_hub
37ee895 verified
Raw History Blame Contribute Delete
10.2 kB
import asyncio
from typing import TYPE_CHECKING, Any
from bot.irc_protocol import flatten_chat_text
from bot.logic import context_manager
from bot.observability import observability
from bot.prompt_runtime import format_chat_reply
from bot.runtime_config import TWITCH_IRC_CHANNEL_ACTION_TIMEOUT_SECONDS, logger
from bot.status_runtime import build_status_line, normalize_channel_login
class IrcChannelStateMixin:
channel_logins: list[str]
joined_channels: set[str]
primary_channel_login: str
reader: asyncio.StreamReader | None
_line_reader_running: bool
_line_reader_task: asyncio.Task[Any] | None
_pending_join_events: dict[str, asyncio.Event]
_pending_part_events: dict[str, asyncio.Event]
if TYPE_CHECKING:
async def _send_raw(self, line: str) -> None: ...
async def build_status_line(self) -> str:
return await build_status_line(channel_logins=self.channel_logins)
@property
def channel_action_timeout_seconds(self) -> float:
return max(0.1, float(TWITCH_IRC_CHANNEL_ACTION_TIMEOUT_SECONDS))
def _prepare_reply_text(self, text: str) -> str:
return flatten_chat_text(format_chat_reply(text))
async def send_reply(self, text: str, channel_login: str | None = None) -> None:
target_channel = normalize_channel_login(channel_login or self.primary_channel_login)
if not target_channel:
target_channel = self.primary_channel_login
if target_channel not in self.joined_channels:
target_channel = self.primary_channel_login
safe_text = self._prepare_reply_text(text)
if not safe_text:
return
await self._send_raw(f"PRIVMSG #{target_channel} :{safe_text}")
async def _send_tracked_channel_reply(self, channel_login: str, text: str) -> None:
target_channel = normalize_channel_login(channel_login)
if not target_channel or target_channel not in self.joined_channels:
return
safe_text = self._prepare_reply_text(text)
if not safe_text:
return
ctx = context_manager.get(target_channel)
ctx.remember_bot_reply(safe_text)
observability.record_reply(text=safe_text, channel_id=target_channel)
await self._send_raw(f"PRIVMSG #{target_channel} :{safe_text}")
def _mark_channel_joined(self, channel_login: str) -> bool:
target_channel = normalize_channel_login(channel_login)
if not target_channel:
return False
changed = False
if target_channel not in self.joined_channels:
self.joined_channels.add(target_channel)
changed = True
if target_channel not in self.channel_logins:
self.channel_logins.append(target_channel)
changed = True
if not self.primary_channel_login or self.primary_channel_login not in self.joined_channels:
self.primary_channel_login = self.channel_logins[0]
changed = True
return changed
def _mark_channel_parted(self, channel_login: str) -> bool:
target_channel = normalize_channel_login(channel_login)
if not target_channel:
return False
changed = False
if target_channel in self.joined_channels:
self.joined_channels.remove(target_channel)
changed = True
filtered_channels = [
channel for channel in self.channel_logins if channel != target_channel
]
if len(filtered_channels) != len(self.channel_logins):
self.channel_logins = filtered_channels
changed = True
if not self.channel_logins and self.joined_channels:
self.channel_logins = sorted(self.joined_channels)
changed = True
if self.primary_channel_login == target_channel or (
self.primary_channel_login and self.primary_channel_login not in self.joined_channels
):
self.primary_channel_login = self.channel_logins[0] if self.channel_logins else ""
changed = True
elif not self.primary_channel_login and self.channel_logins:
self.primary_channel_login = self.channel_logins[0] if self.channel_logins else ""
changed = True
return changed
def _signal_pending_channel_action(
self, pending_map: dict[str, asyncio.Event], channel_login: str
) -> None:
event = pending_map.get(channel_login)
if event is not None and not event.is_set():
event.set()
def _can_wait_for_channel_confirmation(self) -> bool:
if self.reader is None or not self._line_reader_running:
return False
current_task = asyncio.current_task()
if self._line_reader_task is not None and current_task is self._line_reader_task:
return False
return True
async def _wait_for_channel_confirmation(
self,
*,
channel_login: str,
action: str,
pending_map: dict[str, asyncio.Event],
) -> bool:
event = pending_map.get(channel_login)
if event is None:
return False
try:
await asyncio.wait_for(event.wait(), timeout=self.channel_action_timeout_seconds)
return True
except TimeoutError:
logger.warning(
"Timeout aguardando confirmacao de %s no IRC para #%s.",
action,
channel_login,
)
observability.record_error(
category="irc_channel_control",
details=f"timeout aguardando {action} #{channel_login}",
channel_id=channel_login,
)
return False
finally:
current_event = pending_map.get(channel_login)
if current_event is event:
pending_map.pop(channel_login, None)
async def _join_channel(self, channel_login: str, force: bool = False) -> bool:
target_channel = normalize_channel_login(channel_login)
if not target_channel:
return False
if target_channel in self.joined_channels and not force:
return False
should_wait_confirmation = self._can_wait_for_channel_confirmation()
if should_wait_confirmation and target_channel not in self._pending_join_events:
self._pending_join_events[target_channel] = asyncio.Event()
logger.info("IRC SEND: JOIN #%s", target_channel)
await self._send_raw(f"JOIN #{target_channel}")
if should_wait_confirmation:
return await self._wait_for_channel_confirmation(
channel_login=target_channel,
action="JOIN",
pending_map=self._pending_join_events,
)
logger.info(
"JOIN enviado para #%s sem espera de confirmacao imediata.",
target_channel,
)
return True
async def _part_channel(self, channel_login: str, force: bool = False) -> bool:
target_channel = normalize_channel_login(channel_login)
if not target_channel:
return False
if target_channel not in self.joined_channels and not force:
return False
if len(self.channel_logins) <= 1 and not force:
return False
should_wait_confirmation = self._can_wait_for_channel_confirmation()
if should_wait_confirmation and target_channel not in self._pending_part_events:
self._pending_part_events[target_channel] = asyncio.Event()
await self._send_raw(f"PART #{target_channel}")
if should_wait_confirmation:
return await self._wait_for_channel_confirmation(
channel_login=target_channel,
action="PART",
pending_map=self._pending_part_events,
)
logger.info(
"PART enviado para #%s sem espera de confirmacao imediata.",
target_channel,
)
return True
async def admin_list_channels(self) -> list[str]:
return list(self.channel_logins)
async def admin_join_channel(self, channel_login: str) -> tuple[bool, str, list[str]]:
target_channel = normalize_channel_login(channel_login)
if not target_channel:
return (
False,
"Invalid channel login. Use Twitch login format (no #).",
list(self.channel_logins),
)
if target_channel in self.joined_channels:
return (
True,
f"Already connected to #{target_channel}.",
list(self.channel_logins),
)
# Optimistic Update: Mark as joined in memory immediately for instant UX
self._mark_channel_joined(target_channel)
# Trigger the actual IRC join (fire and forget via background logic)
asyncio.create_task(self._join_channel(target_channel, force=True))
return (True, f"Joined #{target_channel}.", list(self.channel_logins))
async def admin_part_channel(self, channel_login: str) -> tuple[bool, str, list[str]]:
target_channel = normalize_channel_login(channel_login)
if not target_channel:
return (
False,
"Invalid channel login. Use Twitch login format (no #).",
list(self.channel_logins),
)
if target_channel not in self.joined_channels:
return (
False,
f"Channel not connected: #{target_channel}.",
list(self.channel_logins),
)
if len(self.channel_logins) <= 1:
return (
False,
"Cannot leave the last active channel. Join another one first.",
list(self.channel_logins),
)
# Optimistic Update: Remove from memory immediately
self._mark_channel_parted(target_channel)
# Trigger actual IRC part
asyncio.create_task(self._part_channel(target_channel, force=True))
return (True, f"Left #{target_channel}.", list(self.channel_logins))