python-twitchchat 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_twitchchat-1.0.0/.gitignore +68 -0
- python_twitchchat-1.0.0/PKG-INFO +215 -0
- python_twitchchat-1.0.0/README.md +197 -0
- python_twitchchat-1.0.0/examples/watch_chat.py +68 -0
- python_twitchchat-1.0.0/pyproject.toml +89 -0
- python_twitchchat-1.0.0/src/twitchchat/__init__.py +68 -0
- python_twitchchat-1.0.0/src/twitchchat/client.py +564 -0
- python_twitchchat-1.0.0/src/twitchchat/events.py +303 -0
- python_twitchchat-1.0.0/src/twitchchat/irc.py +165 -0
- python_twitchchat-1.0.0/src/twitchchat/py.typed +0 -0
- python_twitchchat-1.0.0/tests/test_client.py +468 -0
- python_twitchchat-1.0.0/tests/test_events.py +175 -0
- python_twitchchat-1.0.0/tests/test_irc.py +148 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
test.py
|
|
2
|
+
run.bat
|
|
3
|
+
*.DS_Store
|
|
4
|
+
# Byte-compiled / optimized / DLL files
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
|
|
8
|
+
# vim swp files
|
|
9
|
+
.*.swp
|
|
10
|
+
|
|
11
|
+
# C extensions
|
|
12
|
+
*.so
|
|
13
|
+
|
|
14
|
+
# Distribution / packaging
|
|
15
|
+
.Python
|
|
16
|
+
env/
|
|
17
|
+
build/
|
|
18
|
+
develop-eggs/
|
|
19
|
+
dist/
|
|
20
|
+
downloads/
|
|
21
|
+
eggs/
|
|
22
|
+
.eggs/
|
|
23
|
+
lib/
|
|
24
|
+
lib64/
|
|
25
|
+
parts/
|
|
26
|
+
sdist/
|
|
27
|
+
var/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
.installed.cfg
|
|
30
|
+
*.egg
|
|
31
|
+
|
|
32
|
+
# PyInstaller
|
|
33
|
+
# Usually these files are written by a python script from a template
|
|
34
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
35
|
+
*.manifest
|
|
36
|
+
*.spec
|
|
37
|
+
|
|
38
|
+
# Installer logs
|
|
39
|
+
pip-log.txt
|
|
40
|
+
pip-delete-this-directory.txt
|
|
41
|
+
|
|
42
|
+
# Unit test / coverage reports
|
|
43
|
+
htmlcov/
|
|
44
|
+
.tox/
|
|
45
|
+
.coverage
|
|
46
|
+
.coverage.*
|
|
47
|
+
.cache
|
|
48
|
+
nosetests.xml
|
|
49
|
+
coverage.xml
|
|
50
|
+
*,cover
|
|
51
|
+
|
|
52
|
+
# Translations
|
|
53
|
+
*.mo
|
|
54
|
+
*.pot
|
|
55
|
+
|
|
56
|
+
# Django stuff:
|
|
57
|
+
*.log
|
|
58
|
+
|
|
59
|
+
# Sphinx documentation
|
|
60
|
+
docs/_build/
|
|
61
|
+
|
|
62
|
+
# PyBuilder
|
|
63
|
+
target/
|
|
64
|
+
|
|
65
|
+
# uv / tooling
|
|
66
|
+
.venv/
|
|
67
|
+
.ruff_cache/
|
|
68
|
+
.pytest_cache/
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-twitchchat
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Asyncio client for Twitch chat (IRC): typed events, callbacks and async iteration.
|
|
5
|
+
Project-URL: Homepage, https://github.com/shughes-uk/python-twitchchat
|
|
6
|
+
Author: shughes-uk
|
|
7
|
+
Keywords: asyncio,chat,irc,twitch
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
14
|
+
Classifier: Topic :: Communications :: Chat :: Internet Relay Chat
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.15
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# python-twitchchat
|
|
20
|
+
|
|
21
|
+
An asyncio client for [Twitch chat over IRC](https://dev.twitch.tv/docs/chat/irc/).
|
|
22
|
+
It has no runtime dependencies and gives you typed events, an async iterator,
|
|
23
|
+
callback registration, rate-limited sending, automatic PING/PONG and automatic
|
|
24
|
+
reconnects.
|
|
25
|
+
|
|
26
|
+
- **Requirements:** Python 3.15 or newer, standard library only.
|
|
27
|
+
- **Install:** `pip install python-twitchchat` or `uv add python-twitchchat`. The import name is `twitchchat`.
|
|
28
|
+
- Connects to `irc.chat.twitch.tv:6697` over TLS.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Reading chat anonymously
|
|
33
|
+
|
|
34
|
+
If you don't pass a token, the client logs in as `justinfanNNNNN`. That's
|
|
35
|
+
enough to read chat, but not to send.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
|
|
40
|
+
from twitchchat import ChatMessage, TwitchChat, UserNotice
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def main() -> None:
|
|
44
|
+
async with TwitchChat(["somechannel", "#another"]) as chat:
|
|
45
|
+
async for event in chat: # every event; or chat.events(ChatMessage, UserNotice)
|
|
46
|
+
match event:
|
|
47
|
+
case ChatMessage():
|
|
48
|
+
print(f"[#{event.channel}] <{event.display_name}> {event.text}")
|
|
49
|
+
case UserNotice(): # sub, resub, subgift, raid, announcement, ...
|
|
50
|
+
print(f"[#{event.channel}] {event.msg_id}: {event.system_msg}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
asyncio.run(main())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Callbacks and sending messages
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
|
|
61
|
+
from twitchchat import ChatMessage, TwitchChat, UserNotice
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def main() -> None:
|
|
65
|
+
chat = TwitchChat(["mychannel"], nick="mybot", token="oauth-token-with-chat:read-chat:edit")
|
|
66
|
+
|
|
67
|
+
@chat.on(ChatMessage)
|
|
68
|
+
async def on_message(msg: ChatMessage) -> None:
|
|
69
|
+
if msg.text == "!ping":
|
|
70
|
+
await chat.reply(msg, "pong") # threaded reply
|
|
71
|
+
|
|
72
|
+
@chat.on(UserNotice)
|
|
73
|
+
def on_sub(notice: UserNotice) -> None: # plain functions work too
|
|
74
|
+
if notice.msg_id in {"sub", "resub"}:
|
|
75
|
+
print(notice.login, notice.params.get("cumulative-months"))
|
|
76
|
+
|
|
77
|
+
async with chat:
|
|
78
|
+
await chat.send("mychannel", "hello chat")
|
|
79
|
+
await chat.wait_closed() # run until closed or a fatal error
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### API overview
|
|
86
|
+
|
|
87
|
+
`TwitchChat(channels, *, nick=None, token=None, reconnect=True, message_rate=(20, 30.0), join_rate=(20, 10.0), ...)`
|
|
88
|
+
|
|
89
|
+
| Member | Purpose |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `async with chat` / `await chat.connect()` / `await chat.close()` | Connect and authenticate, then join the configured channels. A bad token raises `AuthenticationError`. |
|
|
92
|
+
| `async for event in chat` / `chat.events(*types, maxsize=10_000)` | Async iterator of events, optionally filtered by type. It buffers from the moment it is created (up to `maxsize` events; the oldest are dropped if you fall behind) and ends when the client closes or you `aclose()` it. |
|
|
93
|
+
| `chat.on(EventType)` / `add_listener` / `remove_listener` | Register sync or async callbacks. They also match subclasses, so `chat.on(Event)` receives everything. |
|
|
94
|
+
| `await chat.send(channel, text, reply_to=None)` / `await chat.reply(msg, text)` | Send a message, rate limited client-side. Anonymous sessions raise `TwitchChatError`. |
|
|
95
|
+
| `await chat.join(channel)` / `await chat.part(channel)` | Change channels at runtime. Joined channels are rejoined after a reconnect. |
|
|
96
|
+
| `await chat.send_raw(line)` | Send a raw IRC line. |
|
|
97
|
+
| `await chat.wait_closed()` | Wait for the client to close, re-raising any fatal error. |
|
|
98
|
+
|
|
99
|
+
**Events** are frozen dataclasses. Each one has `.raw` (the parsed `IrcMessage`) and `.tags`:
|
|
100
|
+
|
|
101
|
+
| Event | IRC message |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `ChatMessage` | `PRIVMSG` |
|
|
104
|
+
| `UserNotice` | `USERNOTICE`: subs, raids, announcements and similar |
|
|
105
|
+
| `Join`, `Part` | `JOIN`, `PART` |
|
|
106
|
+
| `Notice` | `NOTICE` |
|
|
107
|
+
| `ClearChat`, `ClearMsg` | `CLEARCHAT`, `CLEARMSG` |
|
|
108
|
+
| `RoomState`, `UserState`, `GlobalUserState` | `ROOMSTATE`, `USERSTATE`, `GLOBALUSERSTATE` |
|
|
109
|
+
| `Whisper` | `WHISPER` |
|
|
110
|
+
| `Reconnect` | `RECONNECT` (the client reconnects on its own) |
|
|
111
|
+
| `Connected` | `001`, emitted on every (re)connect |
|
|
112
|
+
| `RawEvent` | Anything else |
|
|
113
|
+
|
|
114
|
+
The low-level parser is public: `twitchchat.parse_line()` and `format_line()`
|
|
115
|
+
handle IRCv3 tags, including escaped values such as `\s`, `\:` and `\\`.
|
|
116
|
+
|
|
117
|
+
### Migrating from 0.1
|
|
118
|
+
|
|
119
|
+
The old thread-based `twitch_chat(user, oauth, channels, client_id)` class has
|
|
120
|
+
been replaced:
|
|
121
|
+
|
|
122
|
+
| Old | New |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `subscribeChatMessage(cb)` | `chat.on(ChatMessage)(cb)` |
|
|
125
|
+
| `subscribeUsernotice(cb)` | `chat.on(UserNotice)(cb)` |
|
|
126
|
+
| `send_message(channel, msg)` | `await chat.send(channel, msg)` |
|
|
127
|
+
| `start()` / `join()` / `stop()` | `async with` / `wait_closed()` / `close()` |
|
|
128
|
+
|
|
129
|
+
Callbacks now get typed dataclasses instead of dicts of raw tags; raw tags are
|
|
130
|
+
still available as `event.tags`. The unused `client_id` argument has been
|
|
131
|
+
dropped, because the library makes no HTTP API calls.
|
|
132
|
+
|
|
133
|
+
## Twitch platform notes (checked October 2026)
|
|
134
|
+
|
|
135
|
+
- **IRC still works, but Twitch recommends EventSub.** The product lifecycle
|
|
136
|
+
page lists Chat (IRC) as active, and no shutdown date has been announced.
|
|
137
|
+
Twitch's [IRC migration guide](https://dev.twitch.tv/docs/chat/irc-migration/)
|
|
138
|
+
recommends that new bots read chat through EventSub (`channel.chat.message`)
|
|
139
|
+
and send through the Helix *Send Chat Message* API. Some newer features only
|
|
140
|
+
exist there.
|
|
141
|
+
- **Endpoints:** use `irc.chat.twitch.tv:6697` (TLS) or `wss://irc-ws.chat.twitch.tv:443`.
|
|
142
|
+
Non-TLS WebSocket connections were decommissioned on 2025-08-15. The old code
|
|
143
|
+
used plaintext port 6667; this version uses TLS by default.
|
|
144
|
+
- **Auth:** `PASS oauth:<user access token>` followed by `NICK <login>`. Reading
|
|
145
|
+
needs the `chat:read` scope and sending needs `chat:edit`. You can get a token
|
|
146
|
+
through any OAuth flow, for example the
|
|
147
|
+
[device code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow).
|
|
148
|
+
Anonymous `justinfan` logins aren't documented, but they still work for
|
|
149
|
+
reading; this was verified live.
|
|
150
|
+
- **Rate limits:** normal accounts get 20 messages per 30 seconds, plus 1 message
|
|
151
|
+
per second per channel. Where you are mod, VIP or broadcaster the limit is 100
|
|
152
|
+
per 30 seconds, so pass `message_rate=(100, 30.0)`. Joins are limited to 20 per
|
|
153
|
+
10 seconds. Going over the message limit can get your messages ignored for an
|
|
154
|
+
hour. The client limits itself to the normal-user defaults.
|
|
155
|
+
- **Kraken (v5) is gone:** it was shut down in February 2023. This library never
|
|
156
|
+
needed it, and the old `client_id` parameter did nothing, so it was removed.
|
|
157
|
+
Channel hosting (`HOSTTARGET`) was also removed by Twitch.
|
|
158
|
+
|
|
159
|
+
## Example
|
|
160
|
+
|
|
161
|
+
[`examples/watch_chat.py`](examples/watch_chat.py) watches channels anonymously
|
|
162
|
+
and prints parsed events. It exits non-zero if no chat message arrives within
|
|
163
|
+
the time limit.
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
uv run examples/watch_chat.py --seconds 60 somebigchannel anotherone
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
The project uses [uv](https://docs.astral.sh/uv/). `.python-version` pins
|
|
172
|
+
Python 3.15, which uv downloads automatically.
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
uv sync # create .venv with dev tools (ruff, ty, pytest, pytest-asyncio)
|
|
176
|
+
uv run ruff check # lint
|
|
177
|
+
uv run ruff format # format (use --check in CI)
|
|
178
|
+
uv run ty check # type check
|
|
179
|
+
uv run pytest # unit tests (parser + client against a local fake IRC server)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
CI (`.github/workflows/ci.yml`) runs all four checks on every push and pull
|
|
183
|
+
request. Tests run on Linux, Windows and macOS.
|
|
184
|
+
|
|
185
|
+
## Releasing
|
|
186
|
+
|
|
187
|
+
The version comes from the git tag through `hatch-vcs`, so there is no version
|
|
188
|
+
number to edit by hand.
|
|
189
|
+
|
|
190
|
+
1. One-time setup on PyPI: go to *Your projects → Publishing → Add a new pending
|
|
191
|
+
publisher* (or the project's *Settings → Publishing* once it exists). Choose
|
|
192
|
+
GitHub and fill in:
|
|
193
|
+
- owner `shughes-uk`
|
|
194
|
+
- repository `python-twitchchat`
|
|
195
|
+
- workflow `release.yml`
|
|
196
|
+
- environment `pypi`
|
|
197
|
+
|
|
198
|
+
Then create an environment named `pypi` in the GitHub repository settings.
|
|
199
|
+
You can add required reviewers to it if you want manual approval before each
|
|
200
|
+
publish.
|
|
201
|
+
2. Tag and push:
|
|
202
|
+
```sh
|
|
203
|
+
git tag v2.0.0
|
|
204
|
+
git push origin v2.0.0
|
|
205
|
+
```
|
|
206
|
+
3. `.github/workflows/release.yml` then:
|
|
207
|
+
- runs CI
|
|
208
|
+
- runs `uv build` and checks the built version matches the tag
|
|
209
|
+
- publishes to PyPI with trusted publishing (no API token needed)
|
|
210
|
+
- creates a GitHub Release with generated notes and the sdist and wheel attached
|
|
211
|
+
|
|
212
|
+
Tags such as `v2.1.0rc1` are marked as pre-releases.
|
|
213
|
+
|
|
214
|
+
Note that the distribution name is **`python-twitchchat`**, because `twitchchat`
|
|
215
|
+
is already taken on PyPI by an unrelated project.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# python-twitchchat
|
|
2
|
+
|
|
3
|
+
An asyncio client for [Twitch chat over IRC](https://dev.twitch.tv/docs/chat/irc/).
|
|
4
|
+
It has no runtime dependencies and gives you typed events, an async iterator,
|
|
5
|
+
callback registration, rate-limited sending, automatic PING/PONG and automatic
|
|
6
|
+
reconnects.
|
|
7
|
+
|
|
8
|
+
- **Requirements:** Python 3.15 or newer, standard library only.
|
|
9
|
+
- **Install:** `pip install python-twitchchat` or `uv add python-twitchchat`. The import name is `twitchchat`.
|
|
10
|
+
- Connects to `irc.chat.twitch.tv:6697` over TLS.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
### Reading chat anonymously
|
|
15
|
+
|
|
16
|
+
If you don't pass a token, the client logs in as `justinfanNNNNN`. That's
|
|
17
|
+
enough to read chat, but not to send.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import asyncio
|
|
21
|
+
|
|
22
|
+
from twitchchat import ChatMessage, TwitchChat, UserNotice
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def main() -> None:
|
|
26
|
+
async with TwitchChat(["somechannel", "#another"]) as chat:
|
|
27
|
+
async for event in chat: # every event; or chat.events(ChatMessage, UserNotice)
|
|
28
|
+
match event:
|
|
29
|
+
case ChatMessage():
|
|
30
|
+
print(f"[#{event.channel}] <{event.display_name}> {event.text}")
|
|
31
|
+
case UserNotice(): # sub, resub, subgift, raid, announcement, ...
|
|
32
|
+
print(f"[#{event.channel}] {event.msg_id}: {event.system_msg}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
asyncio.run(main())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Callbacks and sending messages
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
|
|
43
|
+
from twitchchat import ChatMessage, TwitchChat, UserNotice
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def main() -> None:
|
|
47
|
+
chat = TwitchChat(["mychannel"], nick="mybot", token="oauth-token-with-chat:read-chat:edit")
|
|
48
|
+
|
|
49
|
+
@chat.on(ChatMessage)
|
|
50
|
+
async def on_message(msg: ChatMessage) -> None:
|
|
51
|
+
if msg.text == "!ping":
|
|
52
|
+
await chat.reply(msg, "pong") # threaded reply
|
|
53
|
+
|
|
54
|
+
@chat.on(UserNotice)
|
|
55
|
+
def on_sub(notice: UserNotice) -> None: # plain functions work too
|
|
56
|
+
if notice.msg_id in {"sub", "resub"}:
|
|
57
|
+
print(notice.login, notice.params.get("cumulative-months"))
|
|
58
|
+
|
|
59
|
+
async with chat:
|
|
60
|
+
await chat.send("mychannel", "hello chat")
|
|
61
|
+
await chat.wait_closed() # run until closed or a fatal error
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
asyncio.run(main())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### API overview
|
|
68
|
+
|
|
69
|
+
`TwitchChat(channels, *, nick=None, token=None, reconnect=True, message_rate=(20, 30.0), join_rate=(20, 10.0), ...)`
|
|
70
|
+
|
|
71
|
+
| Member | Purpose |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `async with chat` / `await chat.connect()` / `await chat.close()` | Connect and authenticate, then join the configured channels. A bad token raises `AuthenticationError`. |
|
|
74
|
+
| `async for event in chat` / `chat.events(*types, maxsize=10_000)` | Async iterator of events, optionally filtered by type. It buffers from the moment it is created (up to `maxsize` events; the oldest are dropped if you fall behind) and ends when the client closes or you `aclose()` it. |
|
|
75
|
+
| `chat.on(EventType)` / `add_listener` / `remove_listener` | Register sync or async callbacks. They also match subclasses, so `chat.on(Event)` receives everything. |
|
|
76
|
+
| `await chat.send(channel, text, reply_to=None)` / `await chat.reply(msg, text)` | Send a message, rate limited client-side. Anonymous sessions raise `TwitchChatError`. |
|
|
77
|
+
| `await chat.join(channel)` / `await chat.part(channel)` | Change channels at runtime. Joined channels are rejoined after a reconnect. |
|
|
78
|
+
| `await chat.send_raw(line)` | Send a raw IRC line. |
|
|
79
|
+
| `await chat.wait_closed()` | Wait for the client to close, re-raising any fatal error. |
|
|
80
|
+
|
|
81
|
+
**Events** are frozen dataclasses. Each one has `.raw` (the parsed `IrcMessage`) and `.tags`:
|
|
82
|
+
|
|
83
|
+
| Event | IRC message |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `ChatMessage` | `PRIVMSG` |
|
|
86
|
+
| `UserNotice` | `USERNOTICE`: subs, raids, announcements and similar |
|
|
87
|
+
| `Join`, `Part` | `JOIN`, `PART` |
|
|
88
|
+
| `Notice` | `NOTICE` |
|
|
89
|
+
| `ClearChat`, `ClearMsg` | `CLEARCHAT`, `CLEARMSG` |
|
|
90
|
+
| `RoomState`, `UserState`, `GlobalUserState` | `ROOMSTATE`, `USERSTATE`, `GLOBALUSERSTATE` |
|
|
91
|
+
| `Whisper` | `WHISPER` |
|
|
92
|
+
| `Reconnect` | `RECONNECT` (the client reconnects on its own) |
|
|
93
|
+
| `Connected` | `001`, emitted on every (re)connect |
|
|
94
|
+
| `RawEvent` | Anything else |
|
|
95
|
+
|
|
96
|
+
The low-level parser is public: `twitchchat.parse_line()` and `format_line()`
|
|
97
|
+
handle IRCv3 tags, including escaped values such as `\s`, `\:` and `\\`.
|
|
98
|
+
|
|
99
|
+
### Migrating from 0.1
|
|
100
|
+
|
|
101
|
+
The old thread-based `twitch_chat(user, oauth, channels, client_id)` class has
|
|
102
|
+
been replaced:
|
|
103
|
+
|
|
104
|
+
| Old | New |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `subscribeChatMessage(cb)` | `chat.on(ChatMessage)(cb)` |
|
|
107
|
+
| `subscribeUsernotice(cb)` | `chat.on(UserNotice)(cb)` |
|
|
108
|
+
| `send_message(channel, msg)` | `await chat.send(channel, msg)` |
|
|
109
|
+
| `start()` / `join()` / `stop()` | `async with` / `wait_closed()` / `close()` |
|
|
110
|
+
|
|
111
|
+
Callbacks now get typed dataclasses instead of dicts of raw tags; raw tags are
|
|
112
|
+
still available as `event.tags`. The unused `client_id` argument has been
|
|
113
|
+
dropped, because the library makes no HTTP API calls.
|
|
114
|
+
|
|
115
|
+
## Twitch platform notes (checked October 2026)
|
|
116
|
+
|
|
117
|
+
- **IRC still works, but Twitch recommends EventSub.** The product lifecycle
|
|
118
|
+
page lists Chat (IRC) as active, and no shutdown date has been announced.
|
|
119
|
+
Twitch's [IRC migration guide](https://dev.twitch.tv/docs/chat/irc-migration/)
|
|
120
|
+
recommends that new bots read chat through EventSub (`channel.chat.message`)
|
|
121
|
+
and send through the Helix *Send Chat Message* API. Some newer features only
|
|
122
|
+
exist there.
|
|
123
|
+
- **Endpoints:** use `irc.chat.twitch.tv:6697` (TLS) or `wss://irc-ws.chat.twitch.tv:443`.
|
|
124
|
+
Non-TLS WebSocket connections were decommissioned on 2025-08-15. The old code
|
|
125
|
+
used plaintext port 6667; this version uses TLS by default.
|
|
126
|
+
- **Auth:** `PASS oauth:<user access token>` followed by `NICK <login>`. Reading
|
|
127
|
+
needs the `chat:read` scope and sending needs `chat:edit`. You can get a token
|
|
128
|
+
through any OAuth flow, for example the
|
|
129
|
+
[device code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow).
|
|
130
|
+
Anonymous `justinfan` logins aren't documented, but they still work for
|
|
131
|
+
reading; this was verified live.
|
|
132
|
+
- **Rate limits:** normal accounts get 20 messages per 30 seconds, plus 1 message
|
|
133
|
+
per second per channel. Where you are mod, VIP or broadcaster the limit is 100
|
|
134
|
+
per 30 seconds, so pass `message_rate=(100, 30.0)`. Joins are limited to 20 per
|
|
135
|
+
10 seconds. Going over the message limit can get your messages ignored for an
|
|
136
|
+
hour. The client limits itself to the normal-user defaults.
|
|
137
|
+
- **Kraken (v5) is gone:** it was shut down in February 2023. This library never
|
|
138
|
+
needed it, and the old `client_id` parameter did nothing, so it was removed.
|
|
139
|
+
Channel hosting (`HOSTTARGET`) was also removed by Twitch.
|
|
140
|
+
|
|
141
|
+
## Example
|
|
142
|
+
|
|
143
|
+
[`examples/watch_chat.py`](examples/watch_chat.py) watches channels anonymously
|
|
144
|
+
and prints parsed events. It exits non-zero if no chat message arrives within
|
|
145
|
+
the time limit.
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
uv run examples/watch_chat.py --seconds 60 somebigchannel anotherone
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
The project uses [uv](https://docs.astral.sh/uv/). `.python-version` pins
|
|
154
|
+
Python 3.15, which uv downloads automatically.
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
uv sync # create .venv with dev tools (ruff, ty, pytest, pytest-asyncio)
|
|
158
|
+
uv run ruff check # lint
|
|
159
|
+
uv run ruff format # format (use --check in CI)
|
|
160
|
+
uv run ty check # type check
|
|
161
|
+
uv run pytest # unit tests (parser + client against a local fake IRC server)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
CI (`.github/workflows/ci.yml`) runs all four checks on every push and pull
|
|
165
|
+
request. Tests run on Linux, Windows and macOS.
|
|
166
|
+
|
|
167
|
+
## Releasing
|
|
168
|
+
|
|
169
|
+
The version comes from the git tag through `hatch-vcs`, so there is no version
|
|
170
|
+
number to edit by hand.
|
|
171
|
+
|
|
172
|
+
1. One-time setup on PyPI: go to *Your projects → Publishing → Add a new pending
|
|
173
|
+
publisher* (or the project's *Settings → Publishing* once it exists). Choose
|
|
174
|
+
GitHub and fill in:
|
|
175
|
+
- owner `shughes-uk`
|
|
176
|
+
- repository `python-twitchchat`
|
|
177
|
+
- workflow `release.yml`
|
|
178
|
+
- environment `pypi`
|
|
179
|
+
|
|
180
|
+
Then create an environment named `pypi` in the GitHub repository settings.
|
|
181
|
+
You can add required reviewers to it if you want manual approval before each
|
|
182
|
+
publish.
|
|
183
|
+
2. Tag and push:
|
|
184
|
+
```sh
|
|
185
|
+
git tag v2.0.0
|
|
186
|
+
git push origin v2.0.0
|
|
187
|
+
```
|
|
188
|
+
3. `.github/workflows/release.yml` then:
|
|
189
|
+
- runs CI
|
|
190
|
+
- runs `uv build` and checks the built version matches the tag
|
|
191
|
+
- publishes to PyPI with trusted publishing (no API token needed)
|
|
192
|
+
- creates a GitHub Release with generated notes and the sdist and wheel attached
|
|
193
|
+
|
|
194
|
+
Tags such as `v2.1.0rc1` are marked as pre-releases.
|
|
195
|
+
|
|
196
|
+
Note that the distribution name is **`python-twitchchat`**, because `twitchchat`
|
|
197
|
+
is already taken on PyPI by an unrelated project.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Anonymously watch one or more Twitch channels and print parsed events.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
uv run examples/watch_chat.py [--seconds 60] channel [channel ...]
|
|
6
|
+
|
|
7
|
+
Exits non-zero if no chat message was received within the time limit.
|
|
8
|
+
Read-only: anonymous (justinfan) connections cannot send messages.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from twitchchat import ChatMessage, Join, Notice, RoomState, TwitchChat, UserNotice
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def watch(channels: list[str], seconds: float) -> int:
|
|
20
|
+
"""Print events for ``seconds`` and return the number of chat messages seen."""
|
|
21
|
+
count = 0
|
|
22
|
+
async with TwitchChat(channels) as chat:
|
|
23
|
+
print(f"connected as {chat.nick}, joining {', '.join(channels)}")
|
|
24
|
+
|
|
25
|
+
@chat.on(UserNotice)
|
|
26
|
+
def on_usernotice(event: UserNotice) -> None:
|
|
27
|
+
print(f"[#{event.channel}] *** {event.msg_id}: {event.system_msg} {event.params}")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
async with asyncio.timeout(seconds):
|
|
31
|
+
async for event in chat.events(ChatMessage, Join, Notice, RoomState):
|
|
32
|
+
match event:
|
|
33
|
+
case ChatMessage():
|
|
34
|
+
count += 1
|
|
35
|
+
ts = event.timestamp.strftime("%H:%M:%S") if event.timestamp else "?"
|
|
36
|
+
badges = ",".join(event.badges)
|
|
37
|
+
print(
|
|
38
|
+
f"[#{event.channel} {ts}] <{event.display_name}"
|
|
39
|
+
f"{' [' + badges + ']' if badges else ''}> {event.text!r}"
|
|
40
|
+
)
|
|
41
|
+
case Join(login=login) if login == chat.nick:
|
|
42
|
+
print(f"joined #{event.channel}")
|
|
43
|
+
case RoomState():
|
|
44
|
+
print(f"roomstate #{event.channel}: {event.tags}")
|
|
45
|
+
case Notice():
|
|
46
|
+
print(f"notice: {event.text}")
|
|
47
|
+
case _:
|
|
48
|
+
pass
|
|
49
|
+
except TimeoutError:
|
|
50
|
+
pass
|
|
51
|
+
return count
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> None:
|
|
55
|
+
"""Command line entry point."""
|
|
56
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
57
|
+
parser.add_argument("channels", nargs="+")
|
|
58
|
+
parser.add_argument("--seconds", type=float, default=60.0)
|
|
59
|
+
parser.add_argument("--debug", action="store_true")
|
|
60
|
+
args = parser.parse_args()
|
|
61
|
+
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
|
62
|
+
count = asyncio.run(watch(args.channels, args.seconds))
|
|
63
|
+
print(f"received {count} chat messages in {args.seconds:.0f}s")
|
|
64
|
+
sys.exit(0 if count else 1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "python-twitchchat"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Asyncio client for Twitch chat (IRC): typed events, callbacks and async iteration."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "shughes-uk" }]
|
|
7
|
+
requires-python = ">=3.15"
|
|
8
|
+
dependencies = []
|
|
9
|
+
keywords = ["twitch", "irc", "chat", "asyncio"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Framework :: AsyncIO",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Programming Language :: Python :: 3.15",
|
|
17
|
+
"Topic :: Communications :: Chat :: Internet Relay Chat",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/shughes-uk/python-twitchchat"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.version]
|
|
29
|
+
source = "vcs"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.version.raw-options]
|
|
32
|
+
fallback_version = "0.0.0"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["src", "tests", "examples", "README.md", "pyproject.toml"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/twitchchat"]
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=8",
|
|
43
|
+
"pytest-asyncio>=1",
|
|
44
|
+
"ruff>=0.13",
|
|
45
|
+
"ty>=0.0.1",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
addopts = ["-ra", "--strict-markers"]
|
|
53
|
+
filterwarnings = ["error"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 100
|
|
57
|
+
target-version = "py315"
|
|
58
|
+
src = ["src", "tests", "examples"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = [
|
|
62
|
+
"F", "E", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "RUF", "PL", "PT", "RET",
|
|
63
|
+
"ASYNC", "S", "BLE", "FBT", "PIE", "PERF", "TRY", "EM", "G", "LOG", "SLF", "TC",
|
|
64
|
+
"PTH", "D2", "D3", "D4",
|
|
65
|
+
]
|
|
66
|
+
ignore = [
|
|
67
|
+
"D203", "D213", "D401", "D413",
|
|
68
|
+
"TRY003", # long messages in exception constructors
|
|
69
|
+
"FBT001", "FBT002", # boolean params are part of the public API (tls=, reconnect=)
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.per-file-ignores]
|
|
73
|
+
"tests/**" = ["S101", "S105", "S106", "EM101", "PLR2004", "SLF001", "D", "FBT003"]
|
|
74
|
+
"examples/**" = ["T201", "D"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.pydocstyle]
|
|
77
|
+
convention = "google"
|
|
78
|
+
|
|
79
|
+
[tool.ruff.format]
|
|
80
|
+
docstring-code-format = true
|
|
81
|
+
|
|
82
|
+
[tool.ty.environment]
|
|
83
|
+
python-version = "3.15"
|
|
84
|
+
|
|
85
|
+
[tool.ty.src]
|
|
86
|
+
include = ["src", "tests", "examples"]
|
|
87
|
+
|
|
88
|
+
[tool.ty.rules]
|
|
89
|
+
possibly-unresolved-reference = "error"
|