python-twitchchat 1.0.0__py3-none-any.whl
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.dist-info/METADATA +215 -0
- python_twitchchat-1.0.0.dist-info/RECORD +8 -0
- python_twitchchat-1.0.0.dist-info/WHEEL +4 -0
- twitchchat/__init__.py +68 -0
- twitchchat/client.py +564 -0
- twitchchat/events.py +303 -0
- twitchchat/irc.py +165 -0
- twitchchat/py.typed +0 -0
|
@@ -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,8 @@
|
|
|
1
|
+
twitchchat/__init__.py,sha256=KCL-EtxGzmRyXLwmvXwXNW57Cm_zJmui0hZsqMnaVz4,1214
|
|
2
|
+
twitchchat/client.py,sha256=75GqTx34AFZ6bGC65kNf4AmG86EWzXv2Z2OKDSCiM0s,21886
|
|
3
|
+
twitchchat/events.py,sha256=2WQYCNhgaOnz1Gw19uVqKxTlzoN8piQnGkai-FAJkTU,8747
|
|
4
|
+
twitchchat/irc.py,sha256=IUXQb7wi5qEJIC86Ozfkrcidtb_TQ7j9l8h5cEuvHKI,5025
|
|
5
|
+
twitchchat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
python_twitchchat-1.0.0.dist-info/METADATA,sha256=hkjLKtwuX9ul8pjOkY1NGkPFz171n4NitwCOwesyiA0,9124
|
|
7
|
+
python_twitchchat-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
python_twitchchat-1.0.0.dist-info/RECORD,,
|
twitchchat/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Asyncio client for Twitch chat over IRC."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .client import (
|
|
6
|
+
DEFAULT_HOST,
|
|
7
|
+
DEFAULT_PORT,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
EventStream,
|
|
10
|
+
Listener,
|
|
11
|
+
RateLimiter,
|
|
12
|
+
TwitchChat,
|
|
13
|
+
TwitchChatError,
|
|
14
|
+
)
|
|
15
|
+
from .events import (
|
|
16
|
+
ChatMessage,
|
|
17
|
+
ClearChat,
|
|
18
|
+
ClearMsg,
|
|
19
|
+
Connected,
|
|
20
|
+
Event,
|
|
21
|
+
GlobalUserState,
|
|
22
|
+
Join,
|
|
23
|
+
Notice,
|
|
24
|
+
Part,
|
|
25
|
+
RawEvent,
|
|
26
|
+
Reconnect,
|
|
27
|
+
RoomState,
|
|
28
|
+
UserNotice,
|
|
29
|
+
UserState,
|
|
30
|
+
Whisper,
|
|
31
|
+
)
|
|
32
|
+
from .irc import IrcMessage, Prefix, format_line, parse_line
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
__version__ = version("python-twitchchat")
|
|
36
|
+
except PackageNotFoundError: # pragma: no cover
|
|
37
|
+
__version__ = "0.0.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DEFAULT_HOST",
|
|
41
|
+
"DEFAULT_PORT",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"ChatMessage",
|
|
44
|
+
"ClearChat",
|
|
45
|
+
"ClearMsg",
|
|
46
|
+
"Connected",
|
|
47
|
+
"Event",
|
|
48
|
+
"EventStream",
|
|
49
|
+
"GlobalUserState",
|
|
50
|
+
"IrcMessage",
|
|
51
|
+
"Join",
|
|
52
|
+
"Listener",
|
|
53
|
+
"Notice",
|
|
54
|
+
"Part",
|
|
55
|
+
"Prefix",
|
|
56
|
+
"RateLimiter",
|
|
57
|
+
"RawEvent",
|
|
58
|
+
"Reconnect",
|
|
59
|
+
"RoomState",
|
|
60
|
+
"TwitchChat",
|
|
61
|
+
"TwitchChatError",
|
|
62
|
+
"UserNotice",
|
|
63
|
+
"UserState",
|
|
64
|
+
"Whisper",
|
|
65
|
+
"__version__",
|
|
66
|
+
"format_line",
|
|
67
|
+
"parse_line",
|
|
68
|
+
]
|
twitchchat/client.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""asyncio Twitch chat client."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import secrets
|
|
8
|
+
import weakref
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
|
|
11
|
+
from typing import Protocol, Self, overload
|
|
12
|
+
lazy import ssl
|
|
13
|
+
|
|
14
|
+
from .events import ChatMessage, Connected, Event, Notice, Reconnect, event_from_irc
|
|
15
|
+
from .irc import format_line, parse_line
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_HOST",
|
|
19
|
+
"DEFAULT_PORT",
|
|
20
|
+
"AuthenticationError",
|
|
21
|
+
"EventStream",
|
|
22
|
+
"Listener",
|
|
23
|
+
"RateLimiter",
|
|
24
|
+
"TwitchChat",
|
|
25
|
+
"TwitchChatError",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("twitchchat")
|
|
29
|
+
|
|
30
|
+
DEFAULT_HOST = "irc.chat.twitch.tv"
|
|
31
|
+
DEFAULT_PORT = 6697
|
|
32
|
+
DEFAULT_CAPABILITIES = ("twitch.tv/tags", "twitch.tv/commands", "twitch.tv/membership")
|
|
33
|
+
_AUTH_FAILURES = ("Login authentication failed", "Improperly formatted auth")
|
|
34
|
+
_RECONNECT_REQUESTED = "server requested reconnect"
|
|
35
|
+
_STABLE_AFTER = 30.0 # seconds a connection must last before reconnect backoff resets
|
|
36
|
+
|
|
37
|
+
type Listener[E: Event] = Callable[[E], Awaitable[None] | None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TwitchChatError(Exception):
|
|
41
|
+
"""Base error for this library."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthenticationError(TwitchChatError):
|
|
45
|
+
"""Twitch rejected the nick/token."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RateLimiter:
|
|
49
|
+
"""Sliding-window limiter: at most ``limit`` acquisitions per ``period`` seconds."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, limit: int, period: float) -> None:
|
|
52
|
+
if limit < 1 or period <= 0:
|
|
53
|
+
msg = "limit must be >= 1 and period > 0"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
self.limit = limit
|
|
56
|
+
self.period = period
|
|
57
|
+
self._stamps: deque[float] = deque()
|
|
58
|
+
self._lock = asyncio.Lock()
|
|
59
|
+
|
|
60
|
+
async def acquire(self) -> None:
|
|
61
|
+
async with self._lock:
|
|
62
|
+
loop = asyncio.get_running_loop()
|
|
63
|
+
while True:
|
|
64
|
+
now = loop.time()
|
|
65
|
+
while self._stamps and now - self._stamps[0] >= self.period:
|
|
66
|
+
self._stamps.popleft()
|
|
67
|
+
if len(self._stamps) < self.limit:
|
|
68
|
+
self._stamps.append(now)
|
|
69
|
+
return
|
|
70
|
+
await asyncio.sleep(self.period - (now - self._stamps[0]))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Sink(Protocol):
|
|
74
|
+
def _offer(self, event: Event) -> None: ...
|
|
75
|
+
def _finish(self) -> None: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Closed:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_CLOSED = _Closed()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EventStream[E: Event]:
|
|
86
|
+
"""Async iterator over events, created by :meth:`TwitchChat.events`.
|
|
87
|
+
|
|
88
|
+
Buffers from the moment it is created until it is closed (``aclose()``,
|
|
89
|
+
``async with``, the client closing, or the stream being garbage collected).
|
|
90
|
+
At most ``maxsize`` events are buffered; when the consumer falls behind, the
|
|
91
|
+
oldest events are dropped (with a warning).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, chat: TwitchChat, types: tuple[type[E], ...], maxsize: int = 10_000) -> None:
|
|
95
|
+
if maxsize < 1:
|
|
96
|
+
msg = "maxsize must be >= 1"
|
|
97
|
+
raise ValueError(msg)
|
|
98
|
+
self._chat = chat
|
|
99
|
+
self._types = types
|
|
100
|
+
self._maxsize = maxsize
|
|
101
|
+
self._queue: asyncio.Queue[E | _Closed] = asyncio.Queue()
|
|
102
|
+
self._done = False
|
|
103
|
+
self.dropped = 0
|
|
104
|
+
chat._streams.add(self) # noqa: SLF001
|
|
105
|
+
if chat.closed:
|
|
106
|
+
self._finish()
|
|
107
|
+
|
|
108
|
+
def _offer(self, event: Event) -> None:
|
|
109
|
+
if self._done or (self._types and not isinstance(event, self._types)):
|
|
110
|
+
return
|
|
111
|
+
if self._queue.qsize() >= self._maxsize:
|
|
112
|
+
self._queue.get_nowait()
|
|
113
|
+
if not self.dropped:
|
|
114
|
+
logger.warning("event stream full (%d); dropping oldest events", self._maxsize)
|
|
115
|
+
self.dropped += 1
|
|
116
|
+
self._queue.put_nowait(event) # ty: ignore[invalid-argument-type]
|
|
117
|
+
|
|
118
|
+
def _finish(self) -> None:
|
|
119
|
+
if not self._done:
|
|
120
|
+
self._done = True
|
|
121
|
+
# The queue itself is unbounded, so the sentinel always fits.
|
|
122
|
+
self._queue.put_nowait(_CLOSED)
|
|
123
|
+
|
|
124
|
+
def __aiter__(self) -> Self:
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
async def __anext__(self) -> E:
|
|
128
|
+
item = await self._queue.get()
|
|
129
|
+
if isinstance(item, _Closed):
|
|
130
|
+
# Put the sentinel back so later and concurrent __anext__ calls also end.
|
|
131
|
+
self._queue.put_nowait(item)
|
|
132
|
+
self._chat._streams.discard(self) # noqa: SLF001
|
|
133
|
+
if self._chat.exception is not None:
|
|
134
|
+
raise self._chat.exception
|
|
135
|
+
raise StopAsyncIteration
|
|
136
|
+
return item
|
|
137
|
+
|
|
138
|
+
async def aclose(self) -> None:
|
|
139
|
+
self._chat._streams.discard(self) # noqa: SLF001
|
|
140
|
+
self._finish()
|
|
141
|
+
|
|
142
|
+
async def __aenter__(self) -> Self:
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
146
|
+
await self.aclose()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _normalize_channel(channel: str) -> str:
|
|
150
|
+
name = channel.strip().lstrip("#").lower()
|
|
151
|
+
# Space/comma would turn into extra parameters or a multi-channel JOIN.
|
|
152
|
+
if not name or any(c in name for c in " ,\r\n\0"):
|
|
153
|
+
msg = f"invalid channel name: {channel!r}"
|
|
154
|
+
raise ValueError(msg)
|
|
155
|
+
return name
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TwitchChat:
|
|
159
|
+
"""An asyncio Twitch chat (IRC) client.
|
|
160
|
+
|
|
161
|
+
With no ``token`` it logs in anonymously (``justinfanNNNNN``), which can read
|
|
162
|
+
but not send. Use as an async context manager::
|
|
163
|
+
|
|
164
|
+
async with TwitchChat(["somechannel"]) as chat:
|
|
165
|
+
async for event in chat:
|
|
166
|
+
...
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
channels: channels to join once connected (with or without ``#``).
|
|
170
|
+
nick: login name of the token's account (required with ``token``).
|
|
171
|
+
token: OAuth user access token with ``chat:read`` (and ``chat:edit`` to
|
|
172
|
+
send); the ``oauth:`` prefix is optional.
|
|
173
|
+
host, port: IRC server (TLS by default).
|
|
174
|
+
tls: ``True`` for default TLS, ``False`` for plaintext, or an SSLContext.
|
|
175
|
+
reconnect: reconnect automatically on disconnects / ``RECONNECT``.
|
|
176
|
+
message_rate: ``(limit, period)`` for outgoing PRIVMSGs. Twitch allows 20
|
|
177
|
+
per 30s for normal users, 100 per 30s where you are mod/VIP/broadcaster.
|
|
178
|
+
join_rate: ``(limit, period)`` for JOINs (Twitch: 20 per 10s).
|
|
179
|
+
capabilities: IRCv3 capabilities to request.
|
|
180
|
+
read_timeout: seconds without any data before the connection is
|
|
181
|
+
considered dead (Twitch PINGs about every 5 minutes).
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__( # noqa: PLR0913
|
|
185
|
+
self,
|
|
186
|
+
channels: Iterable[str] = (),
|
|
187
|
+
*,
|
|
188
|
+
nick: str | None = None,
|
|
189
|
+
token: str | None = None,
|
|
190
|
+
host: str = DEFAULT_HOST,
|
|
191
|
+
port: int = DEFAULT_PORT,
|
|
192
|
+
tls: bool | ssl.SSLContext = True,
|
|
193
|
+
reconnect: bool = True,
|
|
194
|
+
message_rate: tuple[int, float] = (20, 30.0),
|
|
195
|
+
join_rate: tuple[int, float] = (20, 10.0),
|
|
196
|
+
capabilities: Iterable[str] = DEFAULT_CAPABILITIES,
|
|
197
|
+
connect_timeout: float = 15.0,
|
|
198
|
+
read_timeout: float = 360.0,
|
|
199
|
+
) -> None:
|
|
200
|
+
if token is not None and not nick:
|
|
201
|
+
msg = "nick is required when a token is given"
|
|
202
|
+
raise ValueError(msg)
|
|
203
|
+
self.anonymous = token is None
|
|
204
|
+
self.nick = (nick or f"justinfan{secrets.randbelow(90000) + 10000}").lower()
|
|
205
|
+
self._token = None if token is None else "oauth:" + token.removeprefix("oauth:")
|
|
206
|
+
self.host = host
|
|
207
|
+
self.port = port
|
|
208
|
+
self._tls = tls
|
|
209
|
+
self.reconnect = reconnect
|
|
210
|
+
self.capabilities = tuple(capabilities)
|
|
211
|
+
self.connect_timeout = connect_timeout
|
|
212
|
+
self.read_timeout = read_timeout
|
|
213
|
+
self._message_limiter = RateLimiter(*message_rate)
|
|
214
|
+
self._join_limiter = RateLimiter(*join_rate)
|
|
215
|
+
|
|
216
|
+
self.channels: set[str] = {_normalize_channel(c) for c in channels}
|
|
217
|
+
self._listeners: dict[type[Event], list[Listener[Event]]] = {}
|
|
218
|
+
# Weak, so streams abandoned without aclose() (e.g. ``break`` out of
|
|
219
|
+
# ``async for event in chat``) are collected instead of buffering forever.
|
|
220
|
+
self._streams: weakref.WeakSet[_Sink] = weakref.WeakSet()
|
|
221
|
+
self._reader: asyncio.StreamReader | None = None
|
|
222
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
223
|
+
self._ready = asyncio.Event()
|
|
224
|
+
self._welcome: asyncio.Future[None] | None = None
|
|
225
|
+
self._task: asyncio.Task[None] | None = None
|
|
226
|
+
self._closed = False
|
|
227
|
+
self._closed_event = asyncio.Event()
|
|
228
|
+
self._reconnect_requested = False
|
|
229
|
+
self._background: set[asyncio.Task[None]] = set()
|
|
230
|
+
self._started = False
|
|
231
|
+
self.exception: BaseException | None = None
|
|
232
|
+
|
|
233
|
+
# -- lifecycle -----------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def closed(self) -> bool:
|
|
237
|
+
return self._closed
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def connected(self) -> bool:
|
|
241
|
+
return self._ready.is_set()
|
|
242
|
+
|
|
243
|
+
async def connect(self) -> None:
|
|
244
|
+
"""Connect, authenticate and join the configured channels.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
AuthenticationError: if Twitch rejects the credentials.
|
|
248
|
+
TimeoutError: if the server does not welcome us in time.
|
|
249
|
+
OSError: on network errors.
|
|
250
|
+
"""
|
|
251
|
+
if self._started:
|
|
252
|
+
msg = "connect() may only be called once"
|
|
253
|
+
raise TwitchChatError(msg)
|
|
254
|
+
self._started = True
|
|
255
|
+
try:
|
|
256
|
+
await self._open()
|
|
257
|
+
except BaseException:
|
|
258
|
+
await self._shutdown(None)
|
|
259
|
+
raise
|
|
260
|
+
self._task = asyncio.create_task(self._run(), name=f"twitchchat-{self.nick}")
|
|
261
|
+
|
|
262
|
+
async def close(self) -> None:
|
|
263
|
+
"""Close the connection and end all event streams."""
|
|
264
|
+
if self._task is not None and self._task is not asyncio.current_task():
|
|
265
|
+
self._task.cancel()
|
|
266
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
267
|
+
await self._task
|
|
268
|
+
await self._shutdown(None)
|
|
269
|
+
|
|
270
|
+
async def wait_closed(self) -> None:
|
|
271
|
+
"""Wait until the client closes; re-raise the error that closed it, if any."""
|
|
272
|
+
if self._task is not None:
|
|
273
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
274
|
+
await asyncio.shield(self._task)
|
|
275
|
+
if self.exception is not None:
|
|
276
|
+
raise self.exception
|
|
277
|
+
|
|
278
|
+
async def __aenter__(self) -> Self:
|
|
279
|
+
await self.connect()
|
|
280
|
+
return self
|
|
281
|
+
|
|
282
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
283
|
+
await self.close()
|
|
284
|
+
|
|
285
|
+
# -- listeners & streams ---------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def add_listener[E: Event](self, event_type: type[E], callback: Listener[E]) -> None:
|
|
288
|
+
"""Call ``callback(event)`` for every event of ``event_type`` (or a subclass).
|
|
289
|
+
|
|
290
|
+
Callbacks may be plain functions or coroutine functions; they run in order
|
|
291
|
+
on the reader task, so long-running work should be offloaded.
|
|
292
|
+
"""
|
|
293
|
+
self._listeners.setdefault(event_type, []).append(callback) # ty: ignore[invalid-argument-type]
|
|
294
|
+
|
|
295
|
+
def remove_listener[E: Event](self, event_type: type[E], callback: Listener[E]) -> None:
|
|
296
|
+
with contextlib.suppress(KeyError, ValueError):
|
|
297
|
+
self._listeners[event_type].remove(callback) # ty: ignore[invalid-argument-type]
|
|
298
|
+
|
|
299
|
+
def on[E: Event](self, event_type: type[E]) -> Callable[[Listener[E]], Listener[E]]:
|
|
300
|
+
"""Decorator form of :meth:`add_listener`."""
|
|
301
|
+
|
|
302
|
+
def decorator(callback: Listener[E]) -> Listener[E]:
|
|
303
|
+
self.add_listener(event_type, callback)
|
|
304
|
+
return callback
|
|
305
|
+
|
|
306
|
+
return decorator
|
|
307
|
+
|
|
308
|
+
@overload
|
|
309
|
+
def events(self, *, maxsize: int = ...) -> EventStream[Event]: ...
|
|
310
|
+
@overload
|
|
311
|
+
def events[E: Event](self, *types: type[E], maxsize: int = ...) -> EventStream[E]: ...
|
|
312
|
+
def events(self, *types: type[Event], maxsize: int = 10_000) -> EventStream[Event]:
|
|
313
|
+
"""Return an async iterator of events, optionally filtered by type.
|
|
314
|
+
|
|
315
|
+
At most ``maxsize`` unconsumed events are buffered (oldest dropped first).
|
|
316
|
+
"""
|
|
317
|
+
return EventStream(self, types, maxsize)
|
|
318
|
+
|
|
319
|
+
def __aiter__(self) -> AsyncIterator[Event]:
|
|
320
|
+
return self.events()
|
|
321
|
+
|
|
322
|
+
# -- commands --------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
async def send(self, channel: str, text: str, *, reply_to: str | None = None) -> None:
|
|
325
|
+
"""Send a chat message (rate limited). ``reply_to`` is a message id to reply to.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
TwitchChatError: when anonymous or closed.
|
|
329
|
+
"""
|
|
330
|
+
if self.anonymous:
|
|
331
|
+
msg = "anonymous connections cannot send messages; pass nick and token"
|
|
332
|
+
raise TwitchChatError(msg)
|
|
333
|
+
tags = {"reply-parent-msg-id": reply_to} if reply_to else None
|
|
334
|
+
line = format_line("PRIVMSG", "#" + _normalize_channel(channel), text, tags=tags)
|
|
335
|
+
await self._message_limiter.acquire()
|
|
336
|
+
await self._send_line(line)
|
|
337
|
+
|
|
338
|
+
async def reply(self, message: ChatMessage, text: str) -> None:
|
|
339
|
+
"""Reply to a chat message in its channel (threaded reply)."""
|
|
340
|
+
await self.send(message.channel, text, reply_to=message.id)
|
|
341
|
+
|
|
342
|
+
async def join(self, channel: str) -> None:
|
|
343
|
+
"""Join a channel (and rejoin it after reconnects)."""
|
|
344
|
+
name = _normalize_channel(channel)
|
|
345
|
+
self.channels.add(name)
|
|
346
|
+
if self.connected:
|
|
347
|
+
await self._join_limiter.acquire()
|
|
348
|
+
await self._send_line(format_line("JOIN", "#" + name))
|
|
349
|
+
|
|
350
|
+
async def part(self, channel: str) -> None:
|
|
351
|
+
"""Leave a channel."""
|
|
352
|
+
name = _normalize_channel(channel)
|
|
353
|
+
self.channels.discard(name)
|
|
354
|
+
if self.connected:
|
|
355
|
+
await self._send_line(format_line("PART", "#" + name))
|
|
356
|
+
|
|
357
|
+
async def send_raw(self, line: str) -> None:
|
|
358
|
+
"""Send one raw IRC line (CRLF is appended if missing). Not rate limited.
|
|
359
|
+
|
|
360
|
+
Raises:
|
|
361
|
+
ValueError: if the line contains CR, LF or NUL other than a final CRLF.
|
|
362
|
+
"""
|
|
363
|
+
body = line.removesuffix("\r\n")
|
|
364
|
+
if "\r" in body or "\n" in body or "\0" in body:
|
|
365
|
+
msg = "raw line must be a single IRC line (no embedded CR, LF or NUL)"
|
|
366
|
+
raise ValueError(msg)
|
|
367
|
+
await self._send_line(body + "\r\n")
|
|
368
|
+
|
|
369
|
+
# -- internals -------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
async def _send_line(self, line: str) -> None:
|
|
372
|
+
if self._closed:
|
|
373
|
+
msg = "client is closed"
|
|
374
|
+
raise TwitchChatError(msg)
|
|
375
|
+
if not self._ready.is_set():
|
|
376
|
+
waiters = [
|
|
377
|
+
asyncio.ensure_future(self._ready.wait()),
|
|
378
|
+
asyncio.ensure_future(self._closed_event.wait()),
|
|
379
|
+
]
|
|
380
|
+
try:
|
|
381
|
+
await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
|
|
382
|
+
finally:
|
|
383
|
+
for w in waiters:
|
|
384
|
+
w.cancel()
|
|
385
|
+
writer = self._writer
|
|
386
|
+
if writer is None:
|
|
387
|
+
msg = "not connected"
|
|
388
|
+
raise TwitchChatError(msg)
|
|
389
|
+
logger.debug("> %s", line.rstrip())
|
|
390
|
+
writer.write(line.encode())
|
|
391
|
+
await writer.drain()
|
|
392
|
+
|
|
393
|
+
def _write_now(self, line: str) -> None:
|
|
394
|
+
if self._writer is not None:
|
|
395
|
+
if not line.startswith("PASS"):
|
|
396
|
+
logger.debug("> %s", line.rstrip())
|
|
397
|
+
self._writer.write(line.encode())
|
|
398
|
+
|
|
399
|
+
async def _open(self) -> None:
|
|
400
|
+
ssl_arg: ssl.SSLContext | bool | None = self._tls or None
|
|
401
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
402
|
+
asyncio.open_connection(self.host, self.port, ssl=ssl_arg),
|
|
403
|
+
self.connect_timeout,
|
|
404
|
+
)
|
|
405
|
+
self._welcome = asyncio.get_running_loop().create_future()
|
|
406
|
+
if self.capabilities:
|
|
407
|
+
self._write_now(format_line("CAP", "REQ", " ".join(self.capabilities)))
|
|
408
|
+
self._write_now(format_line("PASS", self._token or "SCHMOOPIIE"))
|
|
409
|
+
self._write_now(format_line("NICK", self.nick))
|
|
410
|
+
await self._writer.drain()
|
|
411
|
+
# Read until welcomed (or rejected) without the main loop running yet.
|
|
412
|
+
async with asyncio.timeout(self.connect_timeout):
|
|
413
|
+
while not self._welcome.done():
|
|
414
|
+
if not await self._read_one():
|
|
415
|
+
msg = "connection closed during login"
|
|
416
|
+
raise ConnectionError(msg)
|
|
417
|
+
self._welcome.result()
|
|
418
|
+
|
|
419
|
+
async def _read_one(self) -> bool:
|
|
420
|
+
"""Read and handle one line. Returns False on EOF."""
|
|
421
|
+
assert self._reader is not None # noqa: S101
|
|
422
|
+
data = await self._reader.readline()
|
|
423
|
+
if not data:
|
|
424
|
+
return False
|
|
425
|
+
line = data.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
426
|
+
if not line:
|
|
427
|
+
return True
|
|
428
|
+
logger.debug("< %s", line)
|
|
429
|
+
# A malformed line from the server must never kill the reader.
|
|
430
|
+
try:
|
|
431
|
+
msg = parse_line(line)
|
|
432
|
+
if msg.command == "PING":
|
|
433
|
+
self._write_now(format_line("PONG", msg.trailing or "tmi.twitch.tv"))
|
|
434
|
+
return True
|
|
435
|
+
event = event_from_irc(msg)
|
|
436
|
+
except Exception:
|
|
437
|
+
logger.warning("unparseable line: %r", line, exc_info=True)
|
|
438
|
+
return True
|
|
439
|
+
await self._handle(event)
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
async def _handle(self, event: Event) -> None:
|
|
443
|
+
if isinstance(event, Connected):
|
|
444
|
+
self._on_welcome()
|
|
445
|
+
elif isinstance(event, Reconnect):
|
|
446
|
+
self._reconnect_requested = True
|
|
447
|
+
elif (
|
|
448
|
+
isinstance(event, Notice)
|
|
449
|
+
and event.text.startswith(_AUTH_FAILURES)
|
|
450
|
+
and self._welcome is not None
|
|
451
|
+
and not self._welcome.done()
|
|
452
|
+
):
|
|
453
|
+
self._welcome.set_exception(AuthenticationError(event.text))
|
|
454
|
+
await self._dispatch(event)
|
|
455
|
+
|
|
456
|
+
def _on_welcome(self) -> None:
|
|
457
|
+
self._ready.set()
|
|
458
|
+
if self._welcome is not None and not self._welcome.done():
|
|
459
|
+
self._welcome.set_result(None)
|
|
460
|
+
# Each channel counts as a join attempt; join in the background, rate limited.
|
|
461
|
+
if self.channels:
|
|
462
|
+
task = asyncio.create_task(self._join_all(sorted(self.channels)))
|
|
463
|
+
self._background.add(task)
|
|
464
|
+
task.add_done_callback(self._background.discard)
|
|
465
|
+
|
|
466
|
+
async def _join_all(self, channels: list[str]) -> None:
|
|
467
|
+
for name in channels:
|
|
468
|
+
await self._join_limiter.acquire()
|
|
469
|
+
if name in self.channels and self._ready.is_set():
|
|
470
|
+
with contextlib.suppress(OSError, TwitchChatError):
|
|
471
|
+
await self._send_line(format_line("JOIN", "#" + name))
|
|
472
|
+
|
|
473
|
+
async def _dispatch(self, event: Event) -> None:
|
|
474
|
+
for stream in list(self._streams):
|
|
475
|
+
stream._offer(event) # noqa: SLF001
|
|
476
|
+
for event_type, callbacks in list(self._listeners.items()):
|
|
477
|
+
if not isinstance(event, event_type):
|
|
478
|
+
continue
|
|
479
|
+
for callback in list(callbacks):
|
|
480
|
+
try:
|
|
481
|
+
result = callback(event)
|
|
482
|
+
if inspect.isawaitable(result):
|
|
483
|
+
await result
|
|
484
|
+
except Exception:
|
|
485
|
+
logger.exception("error in listener %r", callback)
|
|
486
|
+
|
|
487
|
+
async def _run(self) -> None:
|
|
488
|
+
error: BaseException | None = None
|
|
489
|
+
backoff = 1.0
|
|
490
|
+
loop = asyncio.get_running_loop()
|
|
491
|
+
try:
|
|
492
|
+
while True:
|
|
493
|
+
connected_at = loop.time()
|
|
494
|
+
reason = await self._read_loop()
|
|
495
|
+
self._drop_connection()
|
|
496
|
+
if not self.reconnect or self._closed:
|
|
497
|
+
break
|
|
498
|
+
logger.info("disconnected (%s); reconnecting", reason)
|
|
499
|
+
if reason == _RECONNECT_REQUESTED or loop.time() - connected_at > _STABLE_AFTER:
|
|
500
|
+
backoff = 1.0
|
|
501
|
+
else:
|
|
502
|
+
# Dropped soon after connecting: back off instead of hammering.
|
|
503
|
+
await asyncio.sleep(backoff)
|
|
504
|
+
backoff = min(backoff * 2, 60.0)
|
|
505
|
+
while True:
|
|
506
|
+
try:
|
|
507
|
+
await self._open()
|
|
508
|
+
break
|
|
509
|
+
except AuthenticationError:
|
|
510
|
+
raise
|
|
511
|
+
except (OSError, TimeoutError, ConnectionError) as e:
|
|
512
|
+
logger.warning("reconnect failed: %s; retrying in %.0fs", e, backoff)
|
|
513
|
+
self._drop_connection()
|
|
514
|
+
await asyncio.sleep(backoff)
|
|
515
|
+
backoff = min(backoff * 2, 60.0)
|
|
516
|
+
except asyncio.CancelledError:
|
|
517
|
+
raise
|
|
518
|
+
except Exception as e: # noqa: BLE001
|
|
519
|
+
error = e
|
|
520
|
+
finally:
|
|
521
|
+
await self._shutdown(error)
|
|
522
|
+
|
|
523
|
+
async def _read_loop(self) -> str:
|
|
524
|
+
while True:
|
|
525
|
+
if self._closed: # close() was called from a listener on this task
|
|
526
|
+
return "closed"
|
|
527
|
+
try:
|
|
528
|
+
async with asyncio.timeout(self.read_timeout):
|
|
529
|
+
alive = await self._read_one()
|
|
530
|
+
except TimeoutError:
|
|
531
|
+
return "read timeout"
|
|
532
|
+
except OSError as e:
|
|
533
|
+
return f"error: {e}"
|
|
534
|
+
if not alive:
|
|
535
|
+
return "connection closed by server"
|
|
536
|
+
if self._reconnect_requested:
|
|
537
|
+
self._reconnect_requested = False
|
|
538
|
+
return _RECONNECT_REQUESTED
|
|
539
|
+
|
|
540
|
+
def _drop_connection(self) -> None:
|
|
541
|
+
self._ready.clear()
|
|
542
|
+
# Pending rate-limited joins belong to the old connection; the next
|
|
543
|
+
# welcome schedules a fresh join of every channel.
|
|
544
|
+
for task in list(self._background):
|
|
545
|
+
task.cancel()
|
|
546
|
+
if self._writer is not None:
|
|
547
|
+
self._writer.close()
|
|
548
|
+
self._reader = self._writer = None
|
|
549
|
+
|
|
550
|
+
async def _shutdown(self, error: BaseException | None) -> None:
|
|
551
|
+
if self._closed:
|
|
552
|
+
return
|
|
553
|
+
self._closed = True
|
|
554
|
+
self._closed_event.set()
|
|
555
|
+
self.exception = error
|
|
556
|
+
for task in list(self._background):
|
|
557
|
+
task.cancel()
|
|
558
|
+
writer = self._writer
|
|
559
|
+
self._drop_connection()
|
|
560
|
+
if writer is not None:
|
|
561
|
+
with contextlib.suppress(Exception):
|
|
562
|
+
await asyncio.wait_for(writer.wait_closed(), 2)
|
|
563
|
+
for stream in list(self._streams):
|
|
564
|
+
stream._finish() # noqa: SLF001
|
twitchchat/events.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Typed event dataclasses built from Twitch IRC messages."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
lazy from .irc import IrcMessage
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ChatMessage",
|
|
10
|
+
"ClearChat",
|
|
11
|
+
"ClearMsg",
|
|
12
|
+
"Connected",
|
|
13
|
+
"Event",
|
|
14
|
+
"GlobalUserState",
|
|
15
|
+
"Join",
|
|
16
|
+
"Notice",
|
|
17
|
+
"Part",
|
|
18
|
+
"RawEvent",
|
|
19
|
+
"Reconnect",
|
|
20
|
+
"RoomState",
|
|
21
|
+
"UserNotice",
|
|
22
|
+
"UserState",
|
|
23
|
+
"Whisper",
|
|
24
|
+
"event_from_irc",
|
|
25
|
+
"parse_badges",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_ACTION_PREFIX = "\x01ACTION "
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_badges(value: str | None) -> dict[str, str]:
|
|
32
|
+
"""Parse a ``badges``/``badge-info`` tag (``subscriber/12,premium/1``)."""
|
|
33
|
+
if not value:
|
|
34
|
+
return {}
|
|
35
|
+
badges: dict[str, str] = {}
|
|
36
|
+
for item in value.split(","):
|
|
37
|
+
name, _, version = item.partition("/")
|
|
38
|
+
if name:
|
|
39
|
+
badges[name] = version
|
|
40
|
+
return badges
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _timestamp(tags: dict[str, str]) -> datetime | None:
|
|
44
|
+
ts = tags.get("tmi-sent-ts")
|
|
45
|
+
if ts and ts.isascii() and ts.isdigit():
|
|
46
|
+
try:
|
|
47
|
+
return datetime.fromtimestamp(int(ts) / 1000, tz=UTC)
|
|
48
|
+
except OverflowError, OSError, ValueError:
|
|
49
|
+
return None
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _int(value: str | None) -> int | None:
|
|
54
|
+
digits = "" if value is None else value.removeprefix("-")
|
|
55
|
+
if value is None or not (digits.isascii() and digits.isdigit()):
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
return int(value)
|
|
59
|
+
except ValueError: # non-numeric, or longer than int's max string digits
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
64
|
+
class Event:
|
|
65
|
+
"""Base class for every event. ``raw`` is the underlying parsed IRC line."""
|
|
66
|
+
|
|
67
|
+
raw: IrcMessage = field(repr=False)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def tags(self) -> dict[str, str]:
|
|
71
|
+
return self.raw.tags
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
75
|
+
class Connected(Event):
|
|
76
|
+
"""Successfully authenticated (``001`` welcome). Emitted on every (re)connect."""
|
|
77
|
+
|
|
78
|
+
nick: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
82
|
+
class ChatMessage(Event):
|
|
83
|
+
"""A chat message in a channel (``PRIVMSG``)."""
|
|
84
|
+
|
|
85
|
+
channel: str
|
|
86
|
+
login: str
|
|
87
|
+
text: str
|
|
88
|
+
display_name: str
|
|
89
|
+
id: str | None = None
|
|
90
|
+
user_id: str | None = None
|
|
91
|
+
room_id: str | None = None
|
|
92
|
+
color: str | None = None
|
|
93
|
+
badges: dict[str, str] = field(default_factory=dict)
|
|
94
|
+
is_action: bool = False
|
|
95
|
+
bits: int | None = None
|
|
96
|
+
timestamp: datetime | None = None
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_mod(self) -> bool:
|
|
100
|
+
return self.tags.get("mod") == "1" or "broadcaster" in self.badges
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def is_subscriber(self) -> bool:
|
|
104
|
+
return self.tags.get("subscriber") == "1"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def is_first_message(self) -> bool:
|
|
108
|
+
return self.tags.get("first-msg") == "1"
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def reply_parent_id(self) -> str | None:
|
|
112
|
+
return self.tags.get("reply-parent-msg-id")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
116
|
+
class UserNotice(Event):
|
|
117
|
+
"""Subs, resubs, gift subs, raids, announcements ... (``USERNOTICE``).
|
|
118
|
+
|
|
119
|
+
``msg_id`` is the kind (``sub``, ``resub``, ``subgift``, ``raid``,
|
|
120
|
+
``announcement`` ...), ``params`` holds the ``msg-param-*`` tags with the
|
|
121
|
+
prefix stripped, and ``text`` is the optional user-supplied message.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
channel: str
|
|
125
|
+
msg_id: str
|
|
126
|
+
login: str | None
|
|
127
|
+
display_name: str | None
|
|
128
|
+
system_msg: str
|
|
129
|
+
text: str | None = None
|
|
130
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
131
|
+
timestamp: datetime | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
135
|
+
class Join(Event):
|
|
136
|
+
"""A user joined a channel (requires the ``twitch.tv/membership`` capability)."""
|
|
137
|
+
|
|
138
|
+
channel: str
|
|
139
|
+
login: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
143
|
+
class Part(Event):
|
|
144
|
+
"""A user left a channel."""
|
|
145
|
+
|
|
146
|
+
channel: str
|
|
147
|
+
login: str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
151
|
+
class Notice(Event):
|
|
152
|
+
"""A server notice. ``channel`` is ``None`` for global notices."""
|
|
153
|
+
|
|
154
|
+
channel: str | None
|
|
155
|
+
msg_id: str | None
|
|
156
|
+
text: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
160
|
+
class ClearChat(Event):
|
|
161
|
+
"""Chat cleared (``login`` is ``None``) or a user banned/timed out."""
|
|
162
|
+
|
|
163
|
+
channel: str
|
|
164
|
+
login: str | None
|
|
165
|
+
ban_duration: int | None = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
169
|
+
class ClearMsg(Event):
|
|
170
|
+
"""A single message was deleted."""
|
|
171
|
+
|
|
172
|
+
channel: str
|
|
173
|
+
login: str | None
|
|
174
|
+
target_msg_id: str | None
|
|
175
|
+
text: str
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
179
|
+
class RoomState(Event):
|
|
180
|
+
"""Channel chat settings (emote-only, slow mode, ...). Inspect ``tags``."""
|
|
181
|
+
|
|
182
|
+
channel: str
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
186
|
+
class UserState(Event):
|
|
187
|
+
"""Your own state in a channel (sent on join and after you send a message)."""
|
|
188
|
+
|
|
189
|
+
channel: str
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
193
|
+
class GlobalUserState(Event):
|
|
194
|
+
"""Your own global state, sent after authenticating with a real token."""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
198
|
+
class Whisper(Event):
|
|
199
|
+
"""A whisper (private message) to the authenticated user."""
|
|
200
|
+
|
|
201
|
+
login: str
|
|
202
|
+
text: str
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
206
|
+
class Reconnect(Event):
|
|
207
|
+
"""Twitch is about to drop the connection; the client reconnects automatically."""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
211
|
+
class RawEvent(Event):
|
|
212
|
+
"""Any other IRC message (numerics, CAP ACK, ...) not mapped to a typed event."""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _nick(msg: IrcMessage) -> str:
|
|
216
|
+
return msg.prefix.nick if msg.prefix else ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _chat_message(msg: IrcMessage) -> ChatMessage:
|
|
220
|
+
tags = msg.tags
|
|
221
|
+
text = msg.trailing or ""
|
|
222
|
+
is_action = text.startswith(_ACTION_PREFIX) and text.endswith("\x01")
|
|
223
|
+
if is_action:
|
|
224
|
+
text = text[len(_ACTION_PREFIX) : -1]
|
|
225
|
+
login = _nick(msg)
|
|
226
|
+
return ChatMessage(
|
|
227
|
+
raw=msg,
|
|
228
|
+
channel=msg.channel or "",
|
|
229
|
+
login=login,
|
|
230
|
+
text=text,
|
|
231
|
+
display_name=tags.get("display-name") or login,
|
|
232
|
+
id=tags.get("id"),
|
|
233
|
+
user_id=tags.get("user-id"),
|
|
234
|
+
room_id=tags.get("room-id"),
|
|
235
|
+
color=tags.get("color") or None,
|
|
236
|
+
badges=parse_badges(tags.get("badges")),
|
|
237
|
+
is_action=is_action,
|
|
238
|
+
bits=_int(tags.get("bits")),
|
|
239
|
+
timestamp=_timestamp(tags),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _user_notice(msg: IrcMessage) -> UserNotice:
|
|
244
|
+
tags = msg.tags
|
|
245
|
+
prefix = "msg-param-"
|
|
246
|
+
return UserNotice(
|
|
247
|
+
raw=msg,
|
|
248
|
+
channel=msg.channel or "",
|
|
249
|
+
msg_id=tags.get("msg-id", ""),
|
|
250
|
+
login=tags.get("login"),
|
|
251
|
+
display_name=tags.get("display-name"),
|
|
252
|
+
system_msg=tags.get("system-msg", ""),
|
|
253
|
+
text=msg.params[1] if len(msg.params) > 1 else None,
|
|
254
|
+
params={k.removeprefix(prefix): v for k, v in tags.items() if k.startswith(prefix)},
|
|
255
|
+
timestamp=_timestamp(tags),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def event_from_irc(msg: IrcMessage) -> Event: # noqa: PLR0911, PLR0912
|
|
260
|
+
"""Convert a parsed IRC message into the most specific :class:`Event`."""
|
|
261
|
+
channel = msg.channel
|
|
262
|
+
match msg.command:
|
|
263
|
+
case "PRIVMSG" if channel is not None:
|
|
264
|
+
return _chat_message(msg)
|
|
265
|
+
case "USERNOTICE" if channel is not None:
|
|
266
|
+
return _user_notice(msg)
|
|
267
|
+
case "JOIN" if channel is not None:
|
|
268
|
+
return Join(raw=msg, channel=channel, login=_nick(msg))
|
|
269
|
+
case "PART" if channel is not None:
|
|
270
|
+
return Part(raw=msg, channel=channel, login=_nick(msg))
|
|
271
|
+
case "NOTICE":
|
|
272
|
+
return Notice(
|
|
273
|
+
raw=msg, channel=channel, msg_id=msg.tags.get("msg-id"), text=msg.trailing or ""
|
|
274
|
+
)
|
|
275
|
+
case "CLEARCHAT" if channel is not None:
|
|
276
|
+
return ClearChat(
|
|
277
|
+
raw=msg,
|
|
278
|
+
channel=channel,
|
|
279
|
+
login=msg.params[1] if len(msg.params) > 1 else None,
|
|
280
|
+
ban_duration=_int(msg.tags.get("ban-duration")),
|
|
281
|
+
)
|
|
282
|
+
case "CLEARMSG" if channel is not None:
|
|
283
|
+
return ClearMsg(
|
|
284
|
+
raw=msg,
|
|
285
|
+
channel=channel,
|
|
286
|
+
login=msg.tags.get("login"),
|
|
287
|
+
target_msg_id=msg.tags.get("target-msg-id"),
|
|
288
|
+
text=msg.params[1] if len(msg.params) > 1 else "",
|
|
289
|
+
)
|
|
290
|
+
case "ROOMSTATE" if channel is not None:
|
|
291
|
+
return RoomState(raw=msg, channel=channel)
|
|
292
|
+
case "USERSTATE" if channel is not None:
|
|
293
|
+
return UserState(raw=msg, channel=channel)
|
|
294
|
+
case "GLOBALUSERSTATE":
|
|
295
|
+
return GlobalUserState(raw=msg)
|
|
296
|
+
case "WHISPER":
|
|
297
|
+
return Whisper(raw=msg, login=_nick(msg), text=msg.trailing or "")
|
|
298
|
+
case "RECONNECT":
|
|
299
|
+
return Reconnect(raw=msg)
|
|
300
|
+
case "001":
|
|
301
|
+
return Connected(raw=msg, nick=msg.params[0] if msg.params else "")
|
|
302
|
+
case _:
|
|
303
|
+
return RawEvent(raw=msg)
|
twitchchat/irc.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Low-level parsing and formatting of Twitch IRC (IRCv3 with tags) lines."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"IrcMessage",
|
|
7
|
+
"Prefix",
|
|
8
|
+
"escape_tag_value",
|
|
9
|
+
"format_line",
|
|
10
|
+
"parse_line",
|
|
11
|
+
"parse_tags",
|
|
12
|
+
"unescape_tag_value",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
_UNESCAPES = {":": ";", "s": " ", "\\": "\\", "r": "\r", "n": "\n"}
|
|
16
|
+
_ESCAPES = {";": "\\:", " ": "\\s", "\\": "\\\\", "\r": "\\r", "\n": "\\n"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def unescape_tag_value(value: str) -> str:
|
|
20
|
+
r"""Unescape an IRCv3 tag value (``\s`` -> space, ``\:`` -> ``;`` ...).
|
|
21
|
+
|
|
22
|
+
Unknown escapes drop the backslash; a trailing lone backslash is removed,
|
|
23
|
+
as required by the IRCv3 message-tags specification.
|
|
24
|
+
"""
|
|
25
|
+
if "\\" not in value:
|
|
26
|
+
return value
|
|
27
|
+
out: list[str] = []
|
|
28
|
+
chars = iter(value)
|
|
29
|
+
for ch in chars:
|
|
30
|
+
if ch != "\\":
|
|
31
|
+
out.append(ch)
|
|
32
|
+
continue
|
|
33
|
+
nxt = next(chars, None)
|
|
34
|
+
if nxt is None:
|
|
35
|
+
break
|
|
36
|
+
out.append(_UNESCAPES.get(nxt, nxt))
|
|
37
|
+
return "".join(out)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def escape_tag_value(value: str) -> str:
|
|
41
|
+
"""Escape a string for use as an IRCv3 tag value."""
|
|
42
|
+
return "".join(_ESCAPES.get(ch, ch) for ch in value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_tags(raw: str) -> dict[str, str]:
|
|
46
|
+
"""Parse the tag section of a line (without the leading ``@``)."""
|
|
47
|
+
tags: dict[str, str] = {}
|
|
48
|
+
for item in raw.split(";"):
|
|
49
|
+
if not item:
|
|
50
|
+
continue
|
|
51
|
+
key, _, value = item.partition("=")
|
|
52
|
+
tags[key] = unescape_tag_value(value)
|
|
53
|
+
return tags
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Prefix:
|
|
58
|
+
"""The source of an IRC message: ``nick!user@host`` or a bare server name."""
|
|
59
|
+
|
|
60
|
+
nick: str
|
|
61
|
+
user: str | None = None
|
|
62
|
+
host: str | None = None
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def parse(cls, raw: str) -> Prefix:
|
|
66
|
+
nick, _, rest = raw.partition("!")
|
|
67
|
+
if rest:
|
|
68
|
+
user, _, host = rest.partition("@")
|
|
69
|
+
return cls(nick, user, host or None)
|
|
70
|
+
nick, _, host = nick.partition("@")
|
|
71
|
+
return cls(nick, None, host or None)
|
|
72
|
+
|
|
73
|
+
def __str__(self) -> str:
|
|
74
|
+
s = self.nick
|
|
75
|
+
if self.user is not None:
|
|
76
|
+
s += f"!{self.user}"
|
|
77
|
+
if self.host is not None:
|
|
78
|
+
s += f"@{self.host}"
|
|
79
|
+
return s
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class IrcMessage:
|
|
84
|
+
"""A single parsed IRC line."""
|
|
85
|
+
|
|
86
|
+
command: str
|
|
87
|
+
params: tuple[str, ...] = ()
|
|
88
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
89
|
+
prefix: Prefix | None = None
|
|
90
|
+
raw: str = ""
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def channel(self) -> str | None:
|
|
94
|
+
"""The channel (without ``#``) if the first parameter is a channel."""
|
|
95
|
+
if self.params and self.params[0].startswith("#"):
|
|
96
|
+
return self.params[0][1:]
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def trailing(self) -> str | None:
|
|
101
|
+
"""The last parameter, which for most commands is the message text."""
|
|
102
|
+
return self.params[-1] if self.params else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_line(line: str) -> IrcMessage:
|
|
106
|
+
"""Parse one IRC line (with or without the trailing CRLF).
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: if the line contains no command.
|
|
110
|
+
"""
|
|
111
|
+
raw = line.rstrip("\r\n")
|
|
112
|
+
rest = raw
|
|
113
|
+
tags: dict[str, str] = {}
|
|
114
|
+
prefix: Prefix | None = None
|
|
115
|
+
|
|
116
|
+
if rest.startswith("@"):
|
|
117
|
+
tag_part, _, rest = rest[1:].partition(" ")
|
|
118
|
+
tags = parse_tags(tag_part)
|
|
119
|
+
rest = rest.lstrip(" ")
|
|
120
|
+
if rest.startswith(":"):
|
|
121
|
+
prefix_part, _, rest = rest[1:].partition(" ")
|
|
122
|
+
prefix = Prefix.parse(prefix_part)
|
|
123
|
+
rest = rest.lstrip(" ")
|
|
124
|
+
|
|
125
|
+
trailing: str | None = None
|
|
126
|
+
if " :" in rest:
|
|
127
|
+
rest, _, trailing = rest.partition(" :")
|
|
128
|
+
elif rest.startswith(":"):
|
|
129
|
+
trailing = rest[1:]
|
|
130
|
+
rest = ""
|
|
131
|
+
parts = [p for p in rest.split(" ") if p]
|
|
132
|
+
if not parts:
|
|
133
|
+
msg = f"no command in IRC line: {raw!r}"
|
|
134
|
+
raise ValueError(msg)
|
|
135
|
+
command, *params = parts
|
|
136
|
+
if trailing is not None:
|
|
137
|
+
params.append(trailing)
|
|
138
|
+
return IrcMessage(command.upper(), tuple(params), tags, prefix, raw)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def format_line(command: str, *params: str, tags: dict[str, str] | None = None) -> str:
|
|
142
|
+
"""Build an IRC line (with CRLF). The last param is sent as trailing if needed.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: if any part contains CR, LF or NUL (which would allow injection).
|
|
146
|
+
"""
|
|
147
|
+
for part in (command, *params):
|
|
148
|
+
if "\r" in part or "\n" in part or "\0" in part:
|
|
149
|
+
msg = "IRC parameters must not contain CR or LF (or NUL)"
|
|
150
|
+
raise ValueError(msg)
|
|
151
|
+
pieces: list[str] = []
|
|
152
|
+
if tags:
|
|
153
|
+
pieces.append("@" + ";".join(f"{k}={escape_tag_value(v)}" for k, v in tags.items()))
|
|
154
|
+
pieces.append(command)
|
|
155
|
+
if params:
|
|
156
|
+
*middle, last = params
|
|
157
|
+
for p in middle:
|
|
158
|
+
if not p or " " in p or p.startswith(":"):
|
|
159
|
+
msg = f"invalid middle IRC parameter: {p!r}"
|
|
160
|
+
raise ValueError(msg)
|
|
161
|
+
pieces.extend(middle)
|
|
162
|
+
if not last or " " in last or last.startswith(":"):
|
|
163
|
+
last = ":" + last
|
|
164
|
+
pieces.append(last)
|
|
165
|
+
return " ".join(pieces) + "\r\n"
|
twitchchat/py.typed
ADDED
|
File without changes
|