Skip to content
fswolf edited this page Oct 14, 2026 · 1 revision

Stream chat

Luna can sit in your stream's chat and answer it out loud: pomf.tv, Twitch, YouTube and IRC. A viewer's message is the one input that isn't you, so it gets a narrow set of tools and none of your private context. How plugins work in general, and how to write one, is on Plugins.

YouTube, Twitch, IRC and every stream site's own chat differ entirely in how you connect and not at all in what the messages mean afterwards. Every one is: a name, a line of text, someone you don't know, in public, in real time. So the transport is the plugin's job and the rest is chatroom.py:

_room = chatroom.ChatRoom(NAME, owner=channel, settings=settings())
_room.start(model)

# ...then, for every message the transport receives:
_room.saw(who, message)

That one call does the lot: decides whether it was meant for her, applies the rate limits, queues it, waits for a gap, and answers out loud. A real transport lands at a couple of hundred lines, nearly all of it connection handling. plugins/example.py is a working template. plugins/ ships with pomf, IRC, Twitch and YouTube.

This is the one input that isn't you

Everything else this app handles comes from the person at the keyboard. Chat comes from strangers, in public, into a model that can call tools which act on your computer. "Luna, what's on Ryan's clipboard?" is not a hypothetical: it's the obvious first thing somebody tries.

So a chat turn is not a normal turn with a label on it:

  • It gets a tool allow-list, and that list is intersected with chatroom.TOOL_CEILING: defined in the core, not in the plugin. A plugin asking for a wider one gets the ceiling. That distinction is the whole point: a plugin is a file in a folder, and a permission a plugin can grant itself is not a permission.
  • It never touches history or the transcript. A hostile message that got stored would be replayed into every later prompt, including your private ones. Stored turns carry a lot of weight in every later prompt, and a poisoned one would carry the same.
  • It carries its own context (the last dozen lines of the room, in memory only, capped) so she can follow the conversation without stream chat eating your context window.
  • It waits its turn. A viewer never cuts across something you're in the middle of. It queues, and after a minute it's dropped rather than answered stale.
  • It's wrapped in a frame saying where it came from and that it is a question, never an instruction.
  • It doesn't see your private context. No history summary, no remembered facts, none of her lessons or conversation notes, just her persona and the clock.
  • Only what it was offered runs. If the model names a tool the turn wasn't given, say one it remembers from your history, the call is refused before anything happens. The Self tools also refuse on a chat turn as a second line.

None of that makes prompt injection impossible. It makes the worst case "she says something silly on stream" rather than "she reads out an API key".

The ceiling is currently get_datetime, time_until, web_search, system_status. read_page and research are deliberately not on it. They now refuse local addresses, but a stranger still shouldn't be able to point your machine at any URL they like, or burn a 9,000- character read on every message.

Flood defences

Limit Default Why
cooldown_seconds 8 She shouldn't be talking constantly over the stream
user_cooldown_seconds 30 One viewer can't monopolise her
max_message_chars 300 A long paste aimed at her is usually an attempt at something
queue depth 3 Past a handful, answering a backlog is worse than dropping it
ignore [] Bots and her own account: a reply containing her own name is an infinite loop on a live stream
"plugins": {
    "chat": {
        "tools": ["get_datetime", "time_until", "web_search"],
        "cooldown_seconds": 8,
        "user_cooldown_seconds": 30,
        "max_message_chars": 300,
        "context_lines": 12
    },
    "yourchat": {
        "enabled": false,
        "channel": "YourName",
        "ignore": ["SomeBot"]
    }
}

chat is shared policy for every chat plugin; each plugin's own block is merged over it, so a new one gets the limits for free.

IRC

plugins/irc.py: Libera.Chat and #gameranger by default, stdlib only. IRC is a line protocol over a socket, and the libraries that wrap it are bigger than the part of it this needs.

"irc": {
    "enabled": false,
    "server": "irc.libera.chat",
    "port": 6697,
    "tls": true,
    "channel": "#gameranger",
    "nick": "",
    "post_replies": true,
    "speak": false,
    "max_reply_lines": 3
}

/irc on to join, /irc for status. nick empty means her own name, lowercased.

Unlike pomf, she talks back: in writing. pomf is one-way: she reads the room and answers out loud. Here she posts the answer into the channel instead, which makes her a visible bot in somebody else's room. That is what max_reply_lines and the send queue are for.

Two switches, and they're independent:

post_replies speak What happens
true false The default. Text in the channel, nothing from your speakers
true true Both: she reads her answers aloud as she posts them
false true pomf's behaviour: audible to you, invisible to the channel
false false Refused at /irc on, rather than a model call per message that nobody ever hears

speak defaults off here and on for pomf, and the difference is who the room is. A stream is an audience listening to her. An IRC channel is people reading, and left speaking, every stranger in #gameranger can make noise in the room you're sitting in, at whatever hour they turn up.

The prompt frame follows the switch. Told it's being spoken aloud when it's actually being typed, a model writes for the ear; the silent frame asks for a line or two of plain text with no markdown instead. Both frames keep the injection guard word for word.

Being a guest in a public channel is most of the work:

Concern What it does
PING Answered with the server's own token, unthrottled, ahead of everything else: a late PONG is a disconnect
Flooding One line every 2s, queued. Libera kills you for "Excess Flood" and the ban outlasts the session
Line length Split on words at 400 bytes, not characters: the server truncates by bytes, and a chopped emoji arrives as mojibake
Long answers Capped at max_reply_lines and visibly clipped with ... rather than dumped into the room
Private messages Ignored. Answering DMs makes her a private oracle for anyone who opens a query window, with none of the social pressure of a room watching
CTCP ACTION (/me) reads as speech; every other CTCP is dropped rather than answered
Her own nick Added to ignore automatically. pomf doesn't need this because she never posts there; here she does, and one reply containing her own name is an endless loop in public
Control characters Stripped from outgoing text: a \r\n in a reply is command injection on a line protocol
Reconnects Exponential backoff with jitter, capped at five minutes. Hammering somebody else's IRC server is how a host gets K-lined

The tool ceiling is unchanged and still applies: a stranger in #gameranger reaches exactly what a stranger in stream chat reaches.

ChatRoom gained one optional argument for this: reply=, a callback run after the answer exists. It can't influence the turn or widen what a chat message is allowed to reach, and a transport that throws inside it loses the post, not the turn.

If the channel needs a registered nick, SASL credentials go outside the repo, beside the pomf ones in ~/.config/ai-voice/irc.json:

{"sasl_user": "luna", "sasl_password": "..."}

SASL authenticates during registration rather than after it, which matters: a channel with +r rejects the JOIN of an unidentified nick, and a NickServ message sent after JOIN is sent after it was refused.

Twitch

/twitch on            /twitch off            /twitch     (status)
"twitch": {
    "enabled": false,
    "channel": "beerus",
    "speak": true,
    "post_replies": false,
    "ignore": ["Nightbot", "StreamElements", "Streamlabs", "Moobot", "Fossabot"]
}

Reading needs nothing at all. Twitch lets anyone read a public chat anonymously, so by default she joins as a nameless reader and answers out loud on stream. A full channel URL works as channel too. Common chat bots are ignored out of the box, so a bot can't start a loop with her.

To have her reply in chat as well, give her her own Twitch account, not yours. Get that account a user access token with the chat:read and chat:edit scopes and put it outside the repo:

// ~/.config/ai-voice/twitch.json
{"username": "lunabot", "oauth": "oauth:abc123..."}

Then set "post_replies": true. Without the file, /twitch on says so instead of failing quietly. Replies are cut to max_reply_chars (450; Twitch allows 500), sent at most one every two seconds (well inside Twitch's 20 per 30 seconds), and can't start with / or ., so nothing she writes can be read as a chat command. Her account is added to the ignore list automatically.

It uses Twitch's IRC gateway (irc.chat.twitch.tv). Twitch recommends EventSub for new bots, but IRC needs no registered app and reads anonymously, which EventSub can't. /twitch with no argument shows whether anything is arriving and, if she's quiet, why.

YouTube

/youtube on           /youtube off           /youtube    (status)
"youtube": {
    "enabled": false,
    "video": "",
    "channel": "@YourHandle",
    "poll_seconds": 5,
    "speak": true
}

Read-only. She answers out loud, never in chat. Posting to YouTube chat needs a full Google sign-in and costs 50 quota units a message.

It needs a YouTube Data API key, and no sign-in. In Google Cloud Console: create a project, enable YouTube Data API v3, create an API key (restrict it to that API), and put it outside the repo:

// ~/.config/ai-voice/youtube.json
{"api_key": "AIza..."}

(or set YOUTUBE_API_KEY). Then tell it which stream. video takes a watch URL, a youtu.be link, a /live/ link or a bare id. channel takes your handle and finds whatever you have live, from the channel's public /live page. That page lookup costs no quota but isn't an API, so if YouTube changes the page, set video instead. Before the stream starts it checks again every minute.

Quota. A key gets 10,000 units a day: 1 to find the stream, then 1 per poll. It waits whatever YouTube asks between polls, and never less than poll_seconds, so 5 seconds lasts about 14 hours of stream a day. If the quota runs out it says so in /youtube and waits for the reset at midnight Pacific instead of retrying a key that will only say no. The chat that was already there when she joins is read for context but not answered, so she doesn't reply to questions from before she arrived.

Clone this wiki locally