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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
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