"""IRC Platform Adapter for Hermes Agent — stdlib asyncio only, zero external dependencies. Configuration in config.yaml:: gateway: platforms: irc: enabled: true extra: server: irc.libera.chat port: 6697 nickname: hermes-bot channel: "#hermes" use_tls: true server_password: "" # optional server password nickserv_password: "" # optional NickServ identification allowed_users: [] # empty = allow all, or list of nicks max_message_length: 450 # IRC line limit (safe default) Or via environment variables (overrides config.yaml): IRC_SERVER, IRC_PORT, IRC_NICKNAME, IRC_CHANNEL, IRC_USE_TLS, IRC_SERVER_PASSWORD, IRC_NICKSERV_PASSWORD """ import asyncio import datetime import contextlib import logging import re import ssl import time from typing import Any, Dict, List, Optional from gateway.platforms._shared import coerce_port, get_scoped_secret as _get_scoped_secret from gateway.platforms.base import BasePlatformAdapter, SendResult, MessageEvent, MessageType from gateway.config import Platform logger = logging.getLogger(__name__) _TRUTHY = {"1", "true", "yes"} _EOF = object() # _standalone_send: server closed the connection # ── IRC protocol helpers ───────────────────────────────────────────────────── def _parse_irc_message(raw: str) -> dict: """Parse a raw IRC line into ``{"prefix", "command", "params"}``.""" prefix, trailing = "", "" if raw.startswith(":"): prefix, _, raw = raw[1:].partition(" ") if " :" in raw: raw, trailing = raw.split(" :", 1) parts = raw.split() params = parts[1:] if len(parts) > 1 else [] if trailing: params.append(trailing) return {"prefix": prefix, "command": parts[0] if parts else "", "params": params} def _extract_nick(prefix: str) -> str: """Extract nickname from IRC prefix (nick!user@host).""" return prefix.split("!")[0] if "!" in prefix else prefix def _ms_id() -> str: return str(int(time.time() * 1000)) def _env_or_extra(extra: dict, env: str, key: str, default: Any = "") -> Any: """Env var overrides config.yaml ``extra``.""" return _get_scoped_secret(env) or extra.get(key, default) def _server_channel(config) -> tuple: extra = getattr(config, "extra", {}) or {} return _env_or_extra(extra, "IRC_SERVER", "server"), _env_or_extra(extra, "IRC_CHANNEL", "channel") def _chunk_paragraph(paragraph: str, limit: int) -> List[str]: """Split one line into UTF-8 chunks of at most ``limit`` bytes, preferring space boundaries.""" chunks: List[str] = [] while paragraph: if len(paragraph.encode("utf-8")) <= limit: chunks.append(paragraph) break # Binary search for the largest character prefix that fits within limit low, high, best = 1, len(paragraph), 0 while low <= high: mid = (low + high) // 2 if len(paragraph[:mid].encode("utf-8")) <= limit: best = mid low = mid + 1 else: high = mid - 1 split_at = best space = paragraph.rfind(" ", 0, split_at) if space > split_at // 3: split_at = space chunks.append(paragraph[:split_at].rstrip()) paragraph = paragraph[split_at:].lstrip() return chunks def _privmsg_budget(target: str) -> int: """Payload bytes left in a 510-byte line after ``PRIVMSG :`` and CRLF.""" return 510 - (len(f"PRIVMSG {target} :".encode("utf-8")) + 2) def _split_lines(paragraphs, limit: int) -> List[str]: return [chunk for paragraph in paragraphs for chunk in _chunk_paragraph(paragraph, limit)] # ── IRC Adapter ────────────────────────────────────────────────────────────── class IRCAdapter(BasePlatformAdapter): """Async IRC adapter implementing the BasePlatformAdapter interface.""" def __init__(self, config, **kwargs): super().__init__(config=config, platform=Platform("irc")) extra = getattr(config, "extra", {}) or {} self.server = _env_or_extra(extra, "IRC_SERVER", "server") self.port = coerce_port(_env_or_extra(extra, "IRC_PORT", "port", 6697), 6697) self.nickname = _env_or_extra(extra, "IRC_NICKNAME", "nickname", "hermes-bot") self.channel = _env_or_extra(extra, "IRC_CHANNEL", "channel") _use_tls_raw = _get_scoped_secret("IRC_USE_TLS") self.use_tls = _use_tls_raw.lower() in _TRUTHY if _use_tls_raw else extra.get("use_tls", True) self.server_password = _env_or_extra(extra, "IRC_SERVER_PASSWORD", "server_password") self.nickserv_password = _env_or_extra(extra, "IRC_NICKSERV_PASSWORD", "nickserv_password") self.allowed_users: list = extra.get("allowed_users", []) # IRC nicks are case-insensitive — normalise for lookups self._allowed_users_lower: set = {u.lower() for u in self.allowed_users if isinstance(u, str)} max_msg = extra.get("max_message_length") if max_msg is None: with contextlib.suppress(Exception): from gateway.platform_registry import platform_registry entry = platform_registry.get("irc") if entry and entry.max_message_length: max_msg = entry.max_message_length self.max_message_length = int(max_msg or 450) self._reader: Optional[asyncio.StreamReader] = None self._writer: Optional[asyncio.StreamWriter] = None self._recv_task: Optional[asyncio.Task] = None self._current_nick = self.nickname self._registered = False # IRC registration complete self._registration_event = asyncio.Event() @property def name(self) -> str: return "IRC" async def connect(self, *, is_reconnect: bool = False) -> bool: """Connect to the IRC server, register, and join the channel.""" if not self.server or not self.channel: logger.error("IRC: server and channel must be configured") self._set_fatal_error("config_missing", "IRC_SERVER and IRC_CHANNEL must be set", retryable=False) return False # Prevent two profiles from using the same IRC identity try: from gateway.status import acquire_scoped_lock lock_key = f"{self.server}:{self.nickname}" if not acquire_scoped_lock("irc", lock_key): logger.error("IRC: %s@%s already in use by another profile", self.nickname, self.server) self._set_fatal_error("lock_conflict", "IRC identity in use by another profile", retryable=False) return False self._lock_key = lock_key except ImportError: self._lock_key = None # status module not available (e.g. tests) try: ssl_ctx = ssl.create_default_context() if self.use_tls else None self._reader, self._writer = await asyncio.wait_for( asyncio.open_connection(self.server, self.port, ssl=ssl_ctx), timeout=30.0, ) except Exception as e: logger.error("IRC: failed to connect to %s:%s — %s", self.server, self.port, e) self._set_fatal_error("connect_failed", str(e), retryable=True) return False if self.server_password: await self._send_raw(f"PASS {self.server_password}") await self._send_raw(f"NICK {self.nickname}") await self._send_raw(f"USER {self.nickname} 0 * :Hermes Agent") self._recv_task = asyncio.create_task(self._receive_loop()) try: # wait for registration (001 RPL_WELCOME) await asyncio.wait_for(self._registration_event.wait(), timeout=30.0) except asyncio.TimeoutError: logger.error("IRC: registration timed out") await self.disconnect() self._set_fatal_error("registration_timeout", "IRC server did not send RPL_WELCOME", retryable=True) return False if self.nickserv_password: await self._send_raw(f"PRIVMSG NickServ :IDENTIFY {self.nickserv_password}") await asyncio.sleep(2) # Give NickServ time to process await self._send_raw(f"JOIN {self.channel}") self._mark_connected() logger.info("IRC: connected to %s:%s as %s, joined %s", self.server, self.port, self._current_nick, self.channel) self._wire_plugin_handlers(None) return True async def disconnect(self) -> None: """Quit and close the connection.""" if getattr(self, "_lock_key", None): with contextlib.suppress(Exception): from gateway.status import release_scoped_lock release_scoped_lock("irc", self._lock_key) self._mark_disconnected() if self._writer and not self._writer.is_closing(): with contextlib.suppress(Exception): await self._send_raw("QUIT :Hermes Agent shutting down") await asyncio.sleep(0.5) with contextlib.suppress(Exception): self._writer.close() await self._writer.wait_closed() if self._recv_task and not self._recv_task.done(): self._recv_task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._recv_task self._reader = None self._writer = None self._registered = False self._registration_event.clear() async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None): if not self._writer or self._writer.is_closing(): return SendResult(success=False, error="Not connected") for line in self._split_message(content, chat_id): try: await self._send_raw(f"PRIVMSG {chat_id} :{line}") await asyncio.sleep(0.3) # Basic flood protection except Exception as e: return SendResult(success=False, error=str(e)) return SendResult(success=True, message_id=_ms_id()) async def send_typing(self, chat_id: str, metadata=None) -> None: """IRC has no typing indicator — no-op.""" async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: return {"name": chat_id, "type": "group" if chat_id.startswith(("#", "&")) else "dm"} def _split_message(self, content: str, target: str) -> List[str]: """Split a long message into IRC-safe chunks (510-byte line limit minus PRIVMSG overhead).""" paragraphs = [p for p in self._strip_markdown(content).split("\n") if p.strip()] return _split_lines(paragraphs, min(self.max_message_length, _privmsg_budget(target))) or [""] @staticmethod def _strip_markdown(text: str) -> str: """Convert basic markdown to plain text for IRC.""" text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) # bold text = re.sub(r"__(.+?)__", r"\1", text) text = re.sub(r"\*(.+?)\*", r"\1", text) # italic text = re.sub(r"(? None: """Send a raw IRC protocol line.""" if not self._writer or self._writer.is_closing(): return self._writer.write((line + "\r\n").encode("utf-8")) await self._writer.drain() async def _receive_loop(self) -> None: """Main receive loop — reads lines and dispatches them.""" buffer = b"" try: while self._reader and not self._reader.at_eof(): data = await self._reader.read(4096) if not data: break buffer += data while b"\r\n" in buffer: line, buffer = buffer.split(b"\r\n", 1) try: await self._handle_line(line.decode("utf-8", errors="replace")) except Exception as e: logger.warning("IRC: error handling line: %s", e) except asyncio.CancelledError: raise except Exception as e: logger.error("IRC: receive loop error: %s", e) finally: if self.is_connected: logger.warning("IRC: connection lost, marking disconnected") self._set_fatal_error("connection_lost", "IRC connection closed unexpectedly", retryable=True) await self._notify_fatal_error() async def _handle_line(self, raw: str) -> None: """Dispatch a single IRC protocol line.""" msg = _parse_irc_message(raw) command, params = msg["command"], msg["params"] if command == "PING": await self._send_raw(f"PONG :{params[0] if params else ''}") return if command == "001": # RPL_WELCOME — registration complete self._registered = True self._registration_event.set() if params: self._current_nick = params[0] # server may confirm our nick return if command == "433": # ERR_NICKNAMEINUSE — retry: hermes_, hermes_1, hermes_2... base = self.nickname.rstrip("_0123456789") suffix_match = re.search(r"_(\d+)$", self._current_nick) if suffix_match: self._current_nick = f"{base}_{int(suffix_match.group(1)) + 1}" elif self._current_nick == self.nickname: self._current_nick = self.nickname + "_" else: self._current_nick = self.nickname + "_1" await self._send_raw(f"NICK {self._current_nick}") return if command == "PRIVMSG" and len(params) >= 2: sender_nick = _extract_nick(msg["prefix"]) target, text = params[0], params[1] if sender_nick.lower() == self._current_nick.lower(): return # our own message if text.startswith("\x01ACTION ") and text.endswith("\x01"): text = f"* {sender_nick} {text[8:-1]}" # CTCP ACTION (/me) if text.startswith("\x01"): return # other CTCP is_channel = target.startswith(("#", "&")) # In channels, only respond if addressed (nick: / nick, / nick ) if is_channel: for prefix in (f"{self._current_nick}:", f"{self._current_nick},", f"{self._current_nick} "): if text.lower().startswith(prefix.lower()): text = text[len(prefix):].strip() break else: return if self._allowed_users_lower and sender_nick.lower() not in self._allowed_users_lower: logger.debug("IRC: ignoring message from unauthorized user %s", sender_nick) return await self._dispatch_message(text=text, chat_id=target if is_channel else sender_nick, chat_type="group" if is_channel else "dm", user_id=sender_nick, user_name=sender_nick) # NICK — track our own nick changes if command == "NICK" and params and _extract_nick(msg["prefix"]).lower() == self._current_nick.lower(): self._current_nick = params[0] async def _dispatch_message(self, text: str, chat_id: str, chat_type: str, user_id: str, user_name: str) -> None: """Build a MessageEvent and hand it to the base class handler.""" if not self._message_handler: return source = self.build_source(chat_id=chat_id, chat_name=chat_id, chat_type=chat_type, user_id=user_id, user_name=user_name) await self.handle_message(MessageEvent(text=text, message_type=MessageType.TEXT, source=source, message_id=_ms_id(), timestamp=datetime.datetime.now())) # ── Plugin registration ────────────────────────────────────────────────────── def check_requirements() -> bool: """Check if IRC is configured via env (server + channel; no pip packages needed).""" return bool(_get_scoped_secret("IRC_SERVER", "") and _get_scoped_secret("IRC_CHANNEL", "")) def validate_config(config) -> bool: """Validate that the platform config (env or config.yaml) has enough info to connect.""" server, channel = _server_channel(config) return bool(server and channel) def interactive_setup() -> None: """Interactive `hermes gateway setup` flow (lazy-imports hermes_cli so the plugin imports in non-CLI contexts).""" from hermes_cli.setup import ( prompt, prompt_yes_no, save_env_value, get_env_value, print_header, print_info, print_warning, print_success, ) print_header("IRC") existing_server = get_env_value("IRC_SERVER") if existing_server: print_info(f"IRC: already configured (server: {existing_server})") if not prompt_yes_no("Reconfigure IRC?", False): return print_info("Connect Hermes to an IRC network. Uses Python stdlib — no extra packages needed.") print_info(" Works with Libera.Chat, OFTC, your own ZNC/InspIRCd, etc.") print() def _required(label: str, env: str, default: str, what: str) -> bool: value = prompt(label, default=default) if not value: print_warning(f"{what} is required — skipping IRC setup") return False save_env_value(env, value.strip()) return True if not _required("IRC server hostname (e.g. irc.libera.chat)", "IRC_SERVER", existing_server or "", "Server"): return use_tls = prompt_yes_no("Use TLS (recommended)?", True) save_env_value("IRC_USE_TLS", "true" if use_tls else "false") default_port = "6697" if use_tls else "6667" port = prompt(f"Port (default {default_port})", default=get_env_value("IRC_PORT") or "") if port: try: save_env_value("IRC_PORT", str(int(port))) except ValueError: print_warning(f"Invalid port — using default {default_port}") elif get_env_value("IRC_PORT"): save_env_value("IRC_PORT", "") # user cleared the prompt; drop the override if not _required("Bot nickname (e.g. hermes-bot)", "IRC_NICKNAME", get_env_value("IRC_NICKNAME") or "", "Nickname"): return if not _required("Channel to join (e.g. #hermes — comma-separate for multiple)", "IRC_CHANNEL", get_env_value("IRC_CHANNEL") or "", "Channel"): return print() print_info("🔑 Optional authentication") print_info(" Leave blank to skip.") for question, label, env in ( ("Configure a server password (PASS command)?", "Server password", "IRC_SERVER_PASSWORD"), ("Identify with NickServ on connect?", "NickServ password", "IRC_NICKSERV_PASSWORD"), ): if prompt_yes_no(question, False): secret = prompt(label, password=True) if secret: save_env_value(env, secret) print() print_info("🔒 Access control: restrict who can message the bot") print_info(" IRC nicks are not authenticated — anyone can claim any nick.") print_info(" For public channels, pair with NickServ-only mode on your network") print_info(" if you want stronger identity guarantees.") if prompt_yes_no("Allow all users in the channel to talk to the bot?", False): save_env_value("IRC_ALLOW_ALL_USERS", "true") save_env_value("IRC_ALLOWED_USERS", "") print_warning("⚠️ Open access — any nick in the channel can command the bot.") else: save_env_value("IRC_ALLOW_ALL_USERS", "false") allowed = prompt("Allowed nicks (comma-separated, leave empty to deny everyone)", default=get_env_value("IRC_ALLOWED_USERS") or "") if allowed: save_env_value("IRC_ALLOWED_USERS", allowed.replace(" ", "")) print_success("Allowlist configured") else: save_env_value("IRC_ALLOWED_USERS", "") print_info("No nicks allowed — the bot will ignore all messages until you add nicks.") print() print_success("IRC configuration saved to ~/.hermes/.env") print_info("Restart the gateway for changes to take effect: hermes gateway restart") def is_connected(config) -> bool: """Check whether IRC is configured (env or config.yaml).""" return validate_config(config) def _env_enablement() -> dict | None: """Seed ``PlatformConfig.extra`` from env vars BEFORE adapter construction. Returns ``None`` when IRC isn't minimally configured (caller skips auto-enabling). The special ``home_channel`` key becomes a ``HomeChannel`` on the PlatformConfig. """ server = _get_scoped_secret("IRC_SERVER", "").strip() channel = _get_scoped_secret("IRC_CHANNEL", "").strip() if not (server and channel): return None seed: dict = {"server": server, "channel": channel} if port := _get_scoped_secret("IRC_PORT", "").strip(): with contextlib.suppress(ValueError): seed["port"] = int(port) if nickname := _get_scoped_secret("IRC_NICKNAME", "").strip(): seed["nickname"] = nickname if use_tls := _get_scoped_secret("IRC_USE_TLS", "").strip().lower(): seed["use_tls"] = use_tls in _TRUTHY # Passwords also live in extra for back-compat with config.yaml users; env wins at construct time. for env, key in (("IRC_SERVER_PASSWORD", "server_password"), ("IRC_NICKSERV_PASSWORD", "nickserv_password")): if _get_scoped_secret(env): seed[key] = _get_scoped_secret(env) # Home channel defaults to IRC_CHANNEL so cron ``deliver=irc`` has a target without extra config. if home := _get_scoped_secret("IRC_HOME_CHANNEL") or channel: seed["home_channel"] = {"chat_id": home, "name": _get_scoped_secret("IRC_HOME_CHANNEL_NAME", home)} return seed def _strip_irc_control_chars(text: str) -> str: """Neutralise CR/LF (IRC command injection vector) and the protocol-illegal NUL byte.""" return text.replace("\r", " ").replace("\n", " ").replace("\x00", "") def _is_irc_channel(target: str) -> bool: return bool(target) and target[0] in "#&+!" async def _standalone_send(pconfig, chat_id: str, message: str, *, thread_id: Optional[str] = None, media_files: Optional[List[str]] = None, force_document: bool = False) -> Dict[str, Any]: """Open an ephemeral IRC connection, send a PRIVMSG, and quit (out-of-process cron delivery). Used by ``tools/send_message_tool._send_via_adapter`` when the gateway isn't in this process. Uses a distinct ``-cron`` nick so it never collides with the live gateway adapter. ``thread_id``/``media_files`` are accepted for signature parity only. """ extra = getattr(pconfig, "extra", {}) or {} server, channel = _server_channel(pconfig) if not server or not channel: return {"error": "IRC standalone send: IRC_SERVER and IRC_CHANNEL must be configured"} port_value = _env_or_extra(extra, "IRC_PORT", "port", 6697) try: port = int(port_value) except (TypeError, ValueError): return {"error": f"IRC standalone send: invalid port {port_value!r}"} nickname = _env_or_extra(extra, "IRC_NICKNAME", "nickname", "hermes-bot") use_tls_env = _get_scoped_secret("IRC_USE_TLS") use_tls = use_tls_env.lower() in _TRUTHY if use_tls_env is not None else bool(extra.get("use_tls", True)) server_password = _env_or_extra(extra, "IRC_SERVER_PASSWORD", "server_password") nickserv_password = _env_or_extra(extra, "IRC_NICKSERV_PASSWORD", "nickserv_password") # Reject control characters in chat_id to block IRC command injection. target = chat_id or channel if any(ch in target for ch in ("\r", "\n", "\x00", " ")): return {"error": "IRC standalone send: chat_id contains illegal IRC characters"} # Cap the base to 24 chars so collision retries stay within the 30-char NICKLEN most networks enforce. nick_base = nickname.rstrip("_0123456789-")[:24] or "hermes-bot" standalone_nick = f"{nick_base}-cron"[:30] plain = IRCAdapter._strip_markdown(message) ssl_ctx = ssl.create_default_context() if use_tls else None try: reader, writer = await asyncio.wait_for(asyncio.open_connection(server, port, ssl=ssl_ctx), timeout=15.0) except asyncio.CancelledError: raise except Exception as e: return {"error": f"IRC standalone connect failed: {e}"} async def _raw(line: str) -> None: writer.write((line + "\r\n").encode("utf-8")) await writer.drain() loop = asyncio.get_running_loop() async def _pump(deadline: float, on_msg): """Feed lines to ``on_msg`` until it returns non-None; answers PINGs. None on timeout/EOF.""" while True: remaining = deadline - loop.time() if remaining <= 0: return None try: raw_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=remaining) except asyncio.TimeoutError: return None except asyncio.IncompleteReadError: return _EOF msg = _parse_irc_message(raw_line.decode("utf-8", errors="replace").rstrip("\r\n")) if msg["command"] == "PING": await _raw(f"PONG :{msg['params'][0] if msg['params'] else ''}") continue result = await on_msg(msg["command"]) if result is not None: return result nick_attempts = 0 async def _on_registration(cmd: str): nonlocal nick_attempts, standalone_nick if cmd in {"432", "433"}: nick_attempts += 1 if nick_attempts > 5: return {"error": "IRC standalone send: too many nick collisions"} # Build from the stable base, not the mutated nick, so the suffix stays bounded. standalone_nick = f"{nick_base}-cron-{nick_attempts}"[:30] await _raw(f"NICK {standalone_nick}") elif cmd in {"464", "465"}: return {"error": f"IRC standalone send: server rejected client ({cmd})"} return True if cmd == "001" else None async def _on_join(cmd: str): if cmd in {"403", "405", "471", "473", "474", "475"}: return {"error": f"IRC standalone send: JOIN {target} rejected ({cmd})"} return True if cmd in {"366", "JOIN"} else None try: if server_password: await _raw(f"PASS {_strip_irc_control_chars(server_password)}") await _raw(f"NICK {standalone_nick}") await _raw(f"USER {standalone_nick} 0 * :Hermes Agent (cron)") registered = await _pump(loop.time() + 15.0, _on_registration) if registered is None: return {"error": "IRC standalone send: registration timeout (no RPL_WELCOME)"} if registered is _EOF: return {"error": "IRC standalone send: server closed connection during registration"} if registered is not True: return registered if nickserv_password: await _raw(f"PRIVMSG NickServ :IDENTIFY {_strip_irc_control_chars(nickserv_password)}") await asyncio.sleep(2) # JOIN before PRIVMSG: ``+n`` channels silently drop PRIVMSG from non-members. # Do not JOIN bare nicks (DM target) or server queries. if _is_irc_channel(target): await _raw(f"JOIN {target}") # No JOIN ack within 5s (or EOF): proceed anyway, the server may still deliver. joined = await _pump(loop.time() + 5.0, _on_join) if isinstance(joined, dict): return joined # Bytes-aware per-line splitting (same algorithm as IRCAdapter._split_message), # with control-character stripping per line to block CRLF injection from content. paragraphs = [q for q in (_strip_irc_control_chars(p).rstrip() for p in plain.split("\n")) if q] lines = _split_lines(paragraphs, _privmsg_budget(target)) for line in lines: await _raw(f"PRIVMSG {target} :{line}") await asyncio.sleep(0.3) if not lines: return {"error": "IRC standalone send: empty message after stripping"} await _raw("QUIT :delivered") with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(reader.read(1024), timeout=2.0) return {"success": True, "message_id": _ms_id()} except asyncio.CancelledError: raise except Exception as e: logger.debug("IRC standalone send raised", exc_info=True) return {"error": f"IRC standalone send failed: {e}"} finally: with contextlib.suppress(Exception): writer.close() await asyncio.wait_for(writer.wait_closed(), timeout=5.0) def register(ctx): """Plugin entry point: called by the Hermes plugin system.""" ctx.register_platform( name="irc", label="IRC", adapter_factory=lambda cfg: IRCAdapter(cfg), check_fn=check_requirements, validate_config=validate_config, is_connected=is_connected, required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"], install_hint="No extra packages needed (stdlib only)", setup_fn=interactive_setup, env_enablement_fn=_env_enablement, # env-only setups show in gateway status cron_deliver_env_var="IRC_HOME_CHANNEL", # defaults to IRC_CHANNEL (see _env_enablement) standalone_sender_fn=_standalone_send, # cron running separately from the gateway allowed_users_env="IRC_ALLOWED_USERS", allow_all_env="IRC_ALLOW_ALL_USERS", max_message_length=450, # IRC line limit after protocol overhead emoji="💬", pii_safe=False, # IRC doesn't have phone numbers to redact allow_update_command=True, platform_hint=( "You are chatting via IRC. IRC does not support markdown formatting " "— use plain text only. Messages are limited to ~450 characters per " "line (long messages are automatically split). In channels, users " "address you by prefixing your nick. Keep responses concise and " "conversational." ), )