Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copy this file to .env and fill it in: cp .env.example .env
# .env is listed in .gitignore. Never commit a real token.
# How to get a token and which scopes it needs: README, "Get a token".

# The channel to join: a Twitch login name, without the #
TWITCH_CHANNEL=your_channel

# The bot account's login name. It must be the account the token belongs to.
TWITCH_BOT_NAME=your_bot_account

# A user access token of the bot account, with or without "oauth:" in front.
# Scopes: chat:read chat:edit (main.py and examples/irc_library_echo.py)
# user:read:chat user:write:chat (examples/eventsub_echo.py)
TWITCH_OAUTH_TOKEN=

# Optional. Leave these out to use the real Twitch chat (irc.chat.twitch.tv:6697 with TLS).
# For the practice server (python tools/fake_twitch_server.py) use:
# TWITCH_IRC_HOST=127.0.0.1
# TWITCH_IRC_PORT=6667
# TWITCH_IRC_TLS=false
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: Python ${{ matrix.python }}
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7

- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: |
requirements-dev.txt
examples/requirements.txt

- name: The bot needs only the standard library
run: python -c "import main, twitch_chat, config, twitchSocket; print('imports fine without any packages')"

- name: Install test tools
run: python -m pip install -r requirements-dev.txt

- name: Lint
run: ruff check .

- name: Tests (against the fake Twitch server, never the real one)
run: pytest -v
15 changes: 11 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
__pycache__
# your settings and token (copy .env.example)
.env

.DS_Store
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/

# editors and OS
.vscode/
.DS_Store
desktop.ini

.vscode
436 changes: 436 additions & 0 deletions README.md

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Settings for the bots in this repo, read from environment variables.

Keep the token out of your code: put it in a file called .env next to this one
(copy .env.example and fill it in). .env is in .gitignore, so git will not pick it up.
Variables that are already set in your shell win over the file.
"""

from __future__ import annotations

import os
from collections.abc import Mapping, MutableMapping
from dataclasses import dataclass
from pathlib import Path

from twitch_chat import TWITCH_IRC_HOST

ENV_FILE = Path(__file__).with_name(".env")

# name of the environment variable -> what goes in it
REQUIRED = {
"TWITCH_CHANNEL": "the channel to join, e.g. your own login name",
"TWITCH_BOT_NAME": "the login name of the bot account",
"TWITCH_OAUTH_TOKEN": "a user access token of the bot account (see README: Get a token)",
}
FALSE_WORDS = {"0", "false", "no", "off"}


class ConfigError(Exception):
"""A required setting is missing or has a bad value."""


@dataclass(frozen=True)
class Settings:
channel: str
bot_name: str
token: str
host: str = TWITCH_IRC_HOST
port: int | None = None # None: the usual port for TLS or plain text
use_tls: bool = True

@classmethod
def from_env(cls, environ: Mapping[str, str] = os.environ) -> Settings:
missing = [name for name in REQUIRED if not environ.get(name, "").strip()]
if missing:
lines = [f" {name}: {REQUIRED[name]}" for name in missing]
raise ConfigError("missing settings (put them in .env, see .env.example):\n" + "\n".join(lines))
port = environ.get("TWITCH_IRC_PORT", "").strip()
if port and not port.isdigit():
raise ConfigError(f"TWITCH_IRC_PORT must be a number, got {port!r}")
return cls(
channel=environ["TWITCH_CHANNEL"].strip(),
bot_name=environ["TWITCH_BOT_NAME"].strip(),
token=environ["TWITCH_OAUTH_TOKEN"].strip(),
host=environ.get("TWITCH_IRC_HOST", "").strip() or TWITCH_IRC_HOST,
port=int(port) if port else None,
use_tls=environ.get("TWITCH_IRC_TLS", "true").strip().lower() not in FALSE_WORDS,
)


def load_env_file(path: Path = ENV_FILE, environ: MutableMapping[str, str] = os.environ) -> None:
"""Copy KEY=value lines from ``path`` into ``environ``, without overwriting.

A small stand-in for the python-dotenv package, so the bot needs nothing
outside the standard library. Blank lines and lines starting with # are skipped;
quotes around a value are removed.
"""
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
environ.setdefault(key.strip(), value.strip().strip("'\""))
Binary file added docs/images/hero-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/hero-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/message-anatomy-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/message-anatomy-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
157 changes: 157 additions & 0 deletions docs/images/src/hero.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<!doctype html>
<!--
README cover. Render the light and dark versions (#dark picks the dark theme) with a
Chromium-based browser, e.g.
msedge --headless=new --hide-scrollbars --force-device-scale-factor=2 --window-size=960,400
--default-background-color=00000000 --virtual-time-budget=5000
--screenshot=hero-dark.png "file:///…/hero.html#dark"
then shrink the PNGs: magick hero-dark.png -strip -dither None -colors 256 PNG8:hero-dark.png
Every name on it is from the code: the host and port in twitch_chat.py, the commands it
sends (CAP REQ, PASS, NICK, JOIN, PONG, PRIVMSG), DEFAULT_RATE_LIMITS, and what main.py prints.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>twitch-echo-bot-with-python cover</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=block" rel="stylesheet">
<style>
:root {
--bg: #ffffff; --bg2: #f6f8fa; --ink: #1f2328; --muted: #59636e; --line: #d1d9e0; --card: #ffffff;
--purple: #8250df; --blue: #0969da; --green: #1a7f37; --orange: #bc4c00;
--shadow: 0 1px 2px rgba(31,35,40,.06), 0 6px 18px rgba(31,35,40,.06);
--tint1: rgba(130,80,223,.11); --tint2: rgba(9,105,218,.08); --wire: #afb8c1;
}
.dark {
--bg: #0d1117; --bg2: #151b23; --ink: #f0f6fc; --muted: #9198a1; --line: #3d444d; --card: #151b23;
--purple: #ab7df8; --blue: #4493f8; --green: #3fb950; --orange: #f0883e;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.35);
--tint1: rgba(171,125,248,.15); --tint2: rgba(68,147,248,.12); --wire: #656c76;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: transparent; }
body { width: 960px; height: 400px; font-family: Inter, "Segoe UI", sans-serif; color: var(--ink); }
.frame { position: relative; width: 960px; height: 400px; border-radius: 22px; overflow: hidden;
border: 1px solid var(--line);
background: radial-gradient(520px 300px at 92% 8%, var(--tint1), transparent 70%),
radial-gradient(420px 260px at 4% 100%, var(--tint2), transparent 70%), var(--bg); }
.dots { position: absolute; inset: 0; opacity: .55;
background-image: radial-gradient(var(--line) 1px, transparent 1.2px); background-size: 22px 22px;
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 55%, #000 100%); }

header { position: absolute; left: 44px; top: 30px; display: flex; align-items: flex-start; gap: 18px; }
.mark { width: 58px; height: 58px; border-radius: 16px; background: linear-gradient(140deg, #3b1d7a, #8250df 55%, #1f6feb);
display: grid; place-items: center; box-shadow: var(--shadow); flex: none; }
h1 { margin: 2px 0 0; font-size: 38px; font-weight: 800; letter-spacing: -0.035em; line-height: 1; white-space: nowrap; }
.tag { margin: 9px 0 0; font-size: 17.5px; color: var(--muted); letter-spacing: -0.005em; white-space: nowrap; }
.tag b { color: var(--ink); font-weight: 600; }
.cmd { display: inline-flex; gap: 10px; margin-top: 12px; }
.pip { font-family: "JetBrains Mono", Consolas, monospace; font-size: 13.5px;
padding: 7px 13px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); box-shadow: var(--shadow); white-space: nowrap; }
.pip span { color: var(--muted); margin-right: 8px; user-select: none; }

.node { position: absolute; background: var(--card); border: 1px solid var(--line); border-radius: 14px;
box-shadow: var(--shadow); padding: 12px 15px; }
.label { font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted);
display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.label i { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.mono { font-family: "JetBrains Mono", Consolas, monospace; font-size: 13.5px; margin-top: 7px; white-space: nowrap; }
.sub { font-size: 12.5px; color: var(--muted); margin-top: 4px; white-space: nowrap; }
code, .code { font-family: "JetBrains Mono", Consolas, monospace; font-size: 12px; color: var(--ink); }
.type { margin-left: auto; font-family: "JetBrains Mono", Consolas, monospace; font-size: 11.5px; letter-spacing: 0;
text-transform: none; font-weight: 600; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--ink); }

#twitch { left: 44px; top: 186px; width: 236px; }
#twitch .label i { background: var(--purple); }
#twitch .lines { margin-top: 9px; display: grid; gap: 5px; }
#twitch .lines div { font-family: "JetBrains Mono", Consolas, monospace; font-size: 11.5px; padding: 4px 8px; border-radius: 7px;
background: var(--bg2); border: 1px solid var(--line); white-space: nowrap; }
#twitch .lines .in { color: var(--purple); }

#lesson { left: 318px; top: 166px; width: 330px; padding: 0; overflow: hidden; }
#lesson .bar { height: 28px; display: flex; align-items: center; gap: 6px; padding: 0 12px; border-bottom: 1px solid var(--line); background: var(--bg2); }
#lesson .bar span { width: 9px; height: 9px; border-radius: 50%; background: var(--line); }
#lesson .bar b { margin-left: 8px; font-family: "JetBrains Mono", Consolas, monospace; font-size: 11.5px; font-weight: 500; color: var(--muted); }
#lesson .body { padding: 11px 16px 13px; }
#lesson .label i { background: var(--blue); }
.steps { margin: 8px 0 0; padding: 0; list-style: none; font-size: 12.5px; color: var(--muted); }
.steps li { display: flex; align-items: center; gap: 9px; margin-top: 6px; white-space: nowrap; }
.steps .n { min-width: 18px; height: 18px; padding: 0 4px; flex: none; border-radius: 999px; display: grid; place-items: center;
font-size: 10.5px; font-weight: 700; color: var(--blue); border: 1px solid var(--line); background: var(--bg2); }

#echo { left: 686px; top: 186px; width: 230px; }
#echo .label i { background: var(--green); }
.bubble { margin-top: 9px; font-size: 13px; padding: 7px 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); white-space: nowrap; }
.bubble b { font-weight: 700; }
.bubble .alice { color: var(--orange); }
.bubble .bot { color: var(--green); }

svg.wires { position: absolute; inset: 0; }
.wire { fill: none; stroke: var(--wire); stroke-width: 2; stroke-dasharray: 5 6; }
.head { fill: var(--wire); }
</style>
</head>
<body>
<script>if (location.hash === '#dark') document.documentElement.className = 'dark';</script>
<div class="frame">
<div class="dots"></div>

<header>
<div class="mark">
<!-- a chat bubble with an echo -->
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8h17a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H14l-6 5v-5H6a3 3 0 0 1-3-3v-8a3 3 0 0 1 3-3z"/>
<path d="M29 13v9a3 3 0 0 1-3 3"/>
</svg>
</div>
<div>
<h1>twitch-echo-bot-with-python</h1>
<p class="tag">Learn how <b>Twitch chat</b> works from one small file: a <b>raw socket</b>, nothing else.</p>
<div class="cmd">
<div class="pip"><span>$</span>python main.py</div>
<div class="pip"><span>$</span>python tools/fake_twitch_server.py</div>
</div>
</div>
</header>

<svg class="wires" width="960" height="400">
<path class="wire" d="M283 262 H309"/>
<path class="head" d="M315 262 l-9 -5 v10 z"/>
<path class="wire" d="M651 262 H677"/>
<path class="head" d="M683 262 l-9 -5 v10 z"/>
</svg>

<div class="node" id="twitch">
<div class="label"><i></i>Twitch chat <span class="type">TLS</span></div>
<div class="mono">irc.chat.twitch.tv:6697</div>
<div class="lines">
<div class="in">PING :tmi.twitch.tv</div>
<div class="in">… PRIVMSG #demo :hi bot!</div>
<div>PONG :tmi.twitch.tv</div>
</div>
</div>

<div class="node" id="lesson">
<div class="bar"><span></span><span></span><span></span><b>twitch_chat.py</b></div>
<div class="body">
<div class="label"><i></i>9 small steps</div>
<ul class="steps">
<li><span class="n">1–4</span>TLS, then <code>CAP</code> <code>PASS</code> <code>NICK</code> <code>JOIN</code></li>
<li><span class="n">5</span>cut the byte stream at <code>\r\n</code></li>
<li><span class="n">6</span>tags · prefix · command · params</li>
<li><span class="n">7</span>answer <code>PING</code> with <code>PONG</code></li>
<li><span class="n">8</span>send, at most 20 per 30 s</li>
<li><span class="n">9</span>reconnect when it drops</li>
</ul>
</div>
</div>

<div class="node" id="echo">
<div class="label"><i></i>Echo</div>
<div class="bubble"><b class="alice">alice</b>: hi bot!</div>
<div class="bubble"><b class="bot">echo_bot</b>: hi bot!</div>
<div class="sub">no token? try the fake server</div>
</div>
</div>
</body>
</html>
Loading
Loading