atsq 1.0.0a4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. atsq-1.0.0a4/.coverage +0 -0
  2. atsq-1.0.0a4/.github/workflows/ci.yml +57 -0
  3. atsq-1.0.0a4/.github/workflows/release.yml +53 -0
  4. atsq-1.0.0a4/.gitignore +7 -0
  5. atsq-1.0.0a4/LICENSE +21 -0
  6. atsq-1.0.0a4/PKG-INFO +200 -0
  7. atsq-1.0.0a4/README.md +180 -0
  8. atsq-1.0.0a4/docker/docker-compose.test.yml +62 -0
  9. atsq-1.0.0a4/docker/query_ip_allowlist.txt +3 -0
  10. atsq-1.0.0a4/docs/dialects.md +92 -0
  11. atsq-1.0.0a4/docs/testing.md +57 -0
  12. atsq-1.0.0a4/pyproject.toml +67 -0
  13. atsq-1.0.0a4/renovate.json +9 -0
  14. atsq-1.0.0a4/scripts/probe_dialect.py +298 -0
  15. atsq-1.0.0a4/scripts/run-integration-tests.sh +74 -0
  16. atsq-1.0.0a4/src/atsq/__init__.py +40 -0
  17. atsq-1.0.0a4/src/atsq/client.py +420 -0
  18. atsq-1.0.0a4/src/atsq/connection.py +332 -0
  19. atsq-1.0.0a4/src/atsq/definitions.py +63 -0
  20. atsq-1.0.0a4/src/atsq/dialect.py +66 -0
  21. atsq-1.0.0a4/src/atsq/errors.py +63 -0
  22. atsq-1.0.0a4/src/atsq/escape.py +63 -0
  23. atsq-1.0.0a4/src/atsq/events.py +51 -0
  24. atsq-1.0.0a4/src/atsq/filetransfer.py +222 -0
  25. atsq-1.0.0a4/src/atsq/protocol.py +164 -0
  26. atsq-1.0.0a4/src/atsq/py.typed +0 -0
  27. atsq-1.0.0a4/src/atsq/transport.py +156 -0
  28. atsq-1.0.0a4/tests/__init__.py +0 -0
  29. atsq-1.0.0a4/tests/fake/__init__.py +0 -0
  30. atsq-1.0.0a4/tests/fake/fake_transport.py +83 -0
  31. atsq-1.0.0a4/tests/fake/test_client.py +431 -0
  32. atsq-1.0.0a4/tests/fake/test_connection.py +335 -0
  33. atsq-1.0.0a4/tests/fake/test_filetransfer.py +262 -0
  34. atsq-1.0.0a4/tests/integration/__init__.py +0 -0
  35. atsq-1.0.0a4/tests/integration/conftest.py +69 -0
  36. atsq-1.0.0a4/tests/integration/test_flood_live.py +58 -0
  37. atsq-1.0.0a4/tests/integration/test_serverquery.py +468 -0
  38. atsq-1.0.0a4/tests/unit/__init__.py +0 -0
  39. atsq-1.0.0a4/tests/unit/fixtures/probe_ts3.log +129 -0
  40. atsq-1.0.0a4/tests/unit/fixtures/probe_ts6.log +129 -0
  41. atsq-1.0.0a4/tests/unit/test_definitions.py +22 -0
  42. atsq-1.0.0a4/tests/unit/test_errors.py +52 -0
  43. atsq-1.0.0a4/tests/unit/test_escape.py +67 -0
  44. atsq-1.0.0a4/tests/unit/test_events.py +30 -0
  45. atsq-1.0.0a4/tests/unit/test_fixtures_replay.py +69 -0
  46. atsq-1.0.0a4/tests/unit/test_package.py +5 -0
  47. atsq-1.0.0a4/tests/unit/test_protocol.py +269 -0
  48. atsq-1.0.0a4/uv.lock +550 -0
atsq-1.0.0a4/.coverage ADDED
Binary file
@@ -0,0 +1,57 @@
1
+ name: atsq CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ # One pipeline per ref at a time: the self-hosted runner hosts several repos,
11
+ # and parallel docker integration stacks collide on memory.
12
+ concurrency:
13
+ group: ${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: false
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ # The runner may not have python3.13/3.14 binaries; python-checks.yml would
20
+ # silently fall back to python3. So every job pins its interpreter through
21
+ # uv (which downloads the requested CPython itself) instead of relying on
22
+ # the workflow's python_version resolution.
23
+
24
+ jobs:
25
+ unit-py312:
26
+ uses: dev-lukas/ci-cd-actions/.github/workflows/python-checks.yml@main
27
+ with:
28
+ install_command: python -m pip install --quiet uv && uv sync --frozen --python 3.12
29
+ check_commands: |
30
+ uv run --frozen ruff check src tests
31
+ uv run --frozen mypy
32
+ uv run --frozen pytest -q
33
+
34
+ unit-py313:
35
+ uses: dev-lukas/ci-cd-actions/.github/workflows/python-checks.yml@main
36
+ with:
37
+ install_command: python -m pip install --quiet uv && uv sync --frozen --python 3.13
38
+ check_commands: |
39
+ uv run --frozen python -c "import sys; assert sys.version_info[:2] == (3, 13), sys.version"
40
+ uv run --frozen pytest -q
41
+
42
+ unit-py314:
43
+ uses: dev-lukas/ci-cd-actions/.github/workflows/python-checks.yml@main
44
+ with:
45
+ install_command: python -m pip install --quiet uv && uv sync --frozen --python 3.14
46
+ check_commands: |
47
+ uv run --frozen python -c "import sys; assert sys.version_info[:2] == (3, 14), sys.version"
48
+ uv run --frozen pytest -q
49
+
50
+ integration:
51
+ needs: [unit-py312, unit-py313, unit-py314]
52
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
53
+ uses: dev-lukas/ci-cd-actions/.github/workflows/python-checks.yml@main
54
+ with:
55
+ install_command: python -m pip install --quiet uv && uv sync --frozen --python 3.12
56
+ check_commands: |
57
+ ./scripts/run-integration-tests.sh
@@ -0,0 +1,53 @@
1
+ name: Release to PyPI
2
+
3
+ # Publishes on version tags (e.g. 1.0.0a4) via PyPI Trusted Publishing:
4
+ # the pypa action exchanges GitHub's OIDC token for a short-lived PyPI
5
+ # token - no stored secrets. The publisher is registered on PyPI as
6
+ # repo dev-lukas/atsq, workflow release.yml, environment pypi.
7
+ on:
8
+ push:
9
+ tags:
10
+ # glob, not regex: any x.y.z-style tag incl. pre-releases (1.0.0a4)
11
+ - "[0-9]*.[0-9]*.[0-9]*"
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ build:
18
+ runs-on: self-hosted
19
+ steps:
20
+ - uses: actions/checkout@v6.0.2
21
+ - name: Build sdist and wheel
22
+ # The venv lives OUTSIDE the checkout: anything inside the project
23
+ # dir would get packed into the sdist and break the build.
24
+ run: |
25
+ set -euo pipefail
26
+ python3 -m venv "$RUNNER_TEMP/release-venv"
27
+ "$RUNNER_TEMP/release-venv/bin/pip" install --quiet uv
28
+ "$RUNNER_TEMP/release-venv/bin/uv" build
29
+ - name: Check tag matches project version
30
+ run: |
31
+ set -euo pipefail
32
+ version="$("$RUNNER_TEMP/release-venv/bin/uv" version --short)"
33
+ [ "$version" = "${GITHUB_REF_NAME}" ] || {
34
+ echo "tag ${GITHUB_REF_NAME} != pyproject version ${version}" >&2
35
+ exit 1
36
+ }
37
+ - uses: actions/upload-artifact@v4
38
+ with:
39
+ name: dist
40
+ path: dist/
41
+
42
+ publish:
43
+ needs: build
44
+ runs-on: self-hosted
45
+ environment: pypi
46
+ permissions:
47
+ id-token: write # OIDC for Trusted Publishing
48
+ steps:
49
+ - uses: actions/download-artifact@v4
50
+ with:
51
+ name: dist
52
+ path: dist/
53
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
atsq-1.0.0a4/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lukas Roth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
atsq-1.0.0a4/PKG-INFO ADDED
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: atsq
3
+ Version: 1.0.0a4
4
+ Summary: Asyncio TeamSpeak ServerQuery client for TeamSpeak 3 and TeamSpeak 6 over SSH
5
+ Project-URL: Repository, https://github.com/dev-lukas/atsq
6
+ Author: Lukas Roth
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Communications :: Conferencing
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: asyncssh>=2.14
19
+ Description-Content-Type: text/markdown
20
+
21
+ # atsq
22
+
23
+ Asyncio TeamSpeak ServerQuery client for **TeamSpeak 3** and **TeamSpeak 6**, over SSH.
24
+
25
+ TeamSpeak 6 removed the classic raw/telnet ServerQuery — SSH query (port 10022) is the
26
+ only line-protocol interface left. `atsq` speaks that protocol against both server
27
+ generations with one async API, runs on modern Python (3.12–3.14+), and its whole test
28
+ suite executes against real `teamspeak:3.13` and `teamspeaksystems/teamspeak6-server`
29
+ containers.
30
+
31
+ ## Why
32
+
33
+ - [`py-ts3`](https://github.com/benediktschmitt/py-ts3) is unmaintained and imports
34
+ `telnetlib` at module load — removed from the standard library in Python 3.13.
35
+ - TeamSpeak 6 servers only offer SSH query (and HTTP WebQuery).
36
+ - Bots want asyncio-native ergonomics: awaitable commands, `@client.on` event handlers,
37
+ automatic keepalive and reconnect — the `discord.py` feel.
38
+
39
+ ## Install
40
+
41
+ ```
42
+ uv add atsq # or: pip install atsq
43
+ ```
44
+
45
+ Requires Python ≥ 3.12. The only runtime dependency is
46
+ [asyncssh](https://asyncssh.readthedocs.io/).
47
+
48
+ ## Usage
49
+
50
+ One-shot administrative session:
51
+
52
+ ```python
53
+ import atsq
54
+
55
+ async with await atsq.connect("ts.example.com", 10022,
56
+ password="...", server_id=1) as ts:
57
+ for row in await ts.client_list("uid"):
58
+ print(row["clid"], row["client_nickname"])
59
+ cid = await ts.channel_create("Lounge", channel_flag_permanent=1)
60
+ ```
61
+
62
+ Long-running bot with events and automatic reconnect:
63
+
64
+ ```python
65
+ client = atsq.Client("ts.example.com", 10022, password="...",
66
+ server_id=1, # or server_port=9987
67
+ nickname="My Bot", # re-applied on reconnect
68
+ register_events=atsq.ALL_EVENTS) # or "server", or a list
69
+
70
+ @client.on("cliententerview")
71
+ async def on_join(event: atsq.Event) -> None:
72
+ if event.get("reasonid") == "0" and event.get("client_type") == "0":
73
+ print("joined:", event["client_unique_identifier"])
74
+
75
+ @client.on("clientleftview")
76
+ async def on_leave(event: atsq.Event) -> None:
77
+ print("left:", event.get("clid"))
78
+
79
+ await client.run_forever() # reconnects with backoff; keepalive automatic
80
+ ```
81
+
82
+ Pull-style event consumption (instead of handlers):
83
+
84
+ ```python
85
+ async for event in client.events():
86
+ handle(event)
87
+ # or: event = await client.wait_for_event(timeout=240)
88
+ ```
89
+
90
+ Anything without a typed wrapper goes through the generic escape-safe `exec()`,
91
+ including pipelined bulk commands (many parameter blocks, one round trip):
92
+
93
+ ```python
94
+ rows = await ts.exec("servergrouplist")
95
+ await ts.exec("clientmove", clid=5, cid=42)
96
+ await ts.exec("channeladdperm", cid=60, blocks=[
97
+ {"permsid": "i_channel_needed_join_power", "permvalue": 75},
98
+ {"permsid": "i_channel_needed_subscribe_power", "permvalue": 60},
99
+ ])
100
+ ```
101
+
102
+ Wire constants are available as `StrEnum`s that compare directly against
103
+ event/row values:
104
+
105
+ ```python
106
+ from atsq import ReasonId, TargetMode, ClientType, LEAVE_REASONS
107
+
108
+ if event["reasonid"] == ReasonId.CONNECT and event["client_type"] == ClientType.VOICE:
109
+ ...
110
+ if event.get("reasonid") in LEAVE_REASONS:
111
+ ...
112
+ ```
113
+
114
+ File transfer (icons, avatars, channel files) — same API against TS3 and TS6:
115
+
116
+ ```python
117
+ ft = atsq.FileTransfer(client)
118
+ icon_id = await ft.upload_icon(png_bytes) # crc32-named, returns the id
119
+ data = await ft.download("/atsq.bin", cid=42)
120
+ rows = await ft.file_list(cid=42, path="/") # [] for empty dirs
121
+ await ft.delete_file("/atsq.bin", cid=42)
122
+ ```
123
+
124
+ ### Errors
125
+
126
+ ```python
127
+ try:
128
+ await ts.use(99)
129
+ except atsq.QueryError as e: # error id != 0; str(e) carries the server msg
130
+ print(e.error_id, e.msg)
131
+ except atsq.QueryTimeoutError: # no response in time (connection is closed)
132
+ ...
133
+ except atsq.ConnectionClosedError: # connection gone
134
+ ...
135
+ ```
136
+
137
+ `atsq.FloodError` (a `QueryError`, id 524) signals server flood protection — add your
138
+ client's IP to the server's `query_ip_allowlist.txt` to be exempt.
139
+
140
+ ### Defaults worth knowing
141
+
142
+ - **Keepalive**: automatic `whoami` after 240 s idle (servers kick at ~300 s).
143
+ Configure via `keepalive_interval`; `0` disables.
144
+ - **Flood protection**: an `error 524` is retried automatically after the wait
145
+ the server asks for (`flood_retries`, default 2; `0` disables). Allowlisted
146
+ IPs (`query_ip_allowlist.txt`) never hit it in the first place.
147
+ - **Snapshots** work via plain `exec("serversnapshotcreate")` /
148
+ `exec("serversnapshotdeploy", version=..., data=...)` — note deploy
149
+ deselects the session; call `use` again afterwards.
150
+ - **Reconnect** (`run_forever`): exponential backoff 5 s → 300 s; a server message
151
+ containing "banned" waits 300 s. `use`/`servernotifyregister` and `on_ready` re-run
152
+ after every reconnect.
153
+ - **Host keys**: verification is off by default (TeamSpeak servers generate ephemeral
154
+ query host keys). Pin one in production: `atsq.connect(..., known_hosts=...)`
155
+ (forwarded to asyncssh).
156
+ - **close() sends `quit`**: on TS6 a query client that silently drops the SSH
157
+ connection never produces a `notifyclientleftview`; a clean `quit` does (on both
158
+ generations). See [docs/dialects.md](docs/dialects.md).
159
+
160
+ ## TS3 vs TS6
161
+
162
+ Probed against real servers — the wire dialects are near-identical, and `atsq`
163
+ auto-detects the generation from the greeting (`client.dialect`). All recorded
164
+ differences and server-config notes live in [docs/dialects.md](docs/dialects.md).
165
+
166
+ Enable SSH query on a TS3 server with `TS3SERVER_QUERY_PROTOCOLS=raw,ssh`; on TS6 with
167
+ `TSSERVER_QUERY_SSH_ENABLED=1` (password via `TSSERVER_QUERY_ADMIN_PASSWORD`).
168
+
169
+ ## Migrating from py-ts3
170
+
171
+ | py-ts3 | atsq |
172
+ |---|---|
173
+ | `TS3ServerConnection("telnet://user:pass@host:10011")` | `await atsq.connect(host, 10022, username=..., password=...)` (SSH) |
174
+ | `conn.exec_("clientlist", "uid")` | `await ts.exec("clientlist", "uid")` or `await ts.client_list("uid")` |
175
+ | response `resp[0]["cldbid"]` | same shape: `rows[0]["cldbid"]` (`list[dict[str, str]]`) |
176
+ | `conn.wait_for_event(timeout=240)` | `await client.wait_for_event(timeout=240)` or `@client.on(...)` |
177
+ | `event[0]["reasonid"]` | `event["reasonid"]` (Event is a `Mapping[str, str]`) |
178
+ | `conn.send_keepalive()` | automatic (or `await ts.send_keepalive()`) |
179
+ | `ts3.query.TS3QueryError` | `atsq.QueryError` (str() still contains the server msg) |
180
+ | `ts3.query.TS3TimeoutError` | `atsq.QueryTimeoutError` |
181
+ | query builder `.pipe()` | `exec(cmd, blocks=[{...}, {...}], **shared)` |
182
+ | `ts3.definitions` constants | `atsq.ReasonId` / `TargetMode` / `ClientType` / `LEAVE_REASONS` |
183
+ | `ts3.filetransfer.TS3FileTransfer` | `atsq.FileTransfer` (asyncio, TS3+TS6) |
184
+ | manual reconnect loop | `await client.run_forever()` |
185
+
186
+ ## Development
187
+
188
+ ```
189
+ uv sync
190
+ uv run pytest # unit + fake-transport tests (no docker)
191
+ ./scripts/run-integration-tests.sh # full suite vs real TS3 + TS6 in docker
192
+ uv run ruff check src tests scripts && uv run mypy
193
+ ```
194
+
195
+ `scripts/probe_dialect.py` records raw protocol transcripts from a live server —
196
+ rerun it when a new TS6 build lands and diff against `tests/unit/fixtures/`.
197
+
198
+ ## License
199
+
200
+ MIT
atsq-1.0.0a4/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # atsq
2
+
3
+ Asyncio TeamSpeak ServerQuery client for **TeamSpeak 3** and **TeamSpeak 6**, over SSH.
4
+
5
+ TeamSpeak 6 removed the classic raw/telnet ServerQuery — SSH query (port 10022) is the
6
+ only line-protocol interface left. `atsq` speaks that protocol against both server
7
+ generations with one async API, runs on modern Python (3.12–3.14+), and its whole test
8
+ suite executes against real `teamspeak:3.13` and `teamspeaksystems/teamspeak6-server`
9
+ containers.
10
+
11
+ ## Why
12
+
13
+ - [`py-ts3`](https://github.com/benediktschmitt/py-ts3) is unmaintained and imports
14
+ `telnetlib` at module load — removed from the standard library in Python 3.13.
15
+ - TeamSpeak 6 servers only offer SSH query (and HTTP WebQuery).
16
+ - Bots want asyncio-native ergonomics: awaitable commands, `@client.on` event handlers,
17
+ automatic keepalive and reconnect — the `discord.py` feel.
18
+
19
+ ## Install
20
+
21
+ ```
22
+ uv add atsq # or: pip install atsq
23
+ ```
24
+
25
+ Requires Python ≥ 3.12. The only runtime dependency is
26
+ [asyncssh](https://asyncssh.readthedocs.io/).
27
+
28
+ ## Usage
29
+
30
+ One-shot administrative session:
31
+
32
+ ```python
33
+ import atsq
34
+
35
+ async with await atsq.connect("ts.example.com", 10022,
36
+ password="...", server_id=1) as ts:
37
+ for row in await ts.client_list("uid"):
38
+ print(row["clid"], row["client_nickname"])
39
+ cid = await ts.channel_create("Lounge", channel_flag_permanent=1)
40
+ ```
41
+
42
+ Long-running bot with events and automatic reconnect:
43
+
44
+ ```python
45
+ client = atsq.Client("ts.example.com", 10022, password="...",
46
+ server_id=1, # or server_port=9987
47
+ nickname="My Bot", # re-applied on reconnect
48
+ register_events=atsq.ALL_EVENTS) # or "server", or a list
49
+
50
+ @client.on("cliententerview")
51
+ async def on_join(event: atsq.Event) -> None:
52
+ if event.get("reasonid") == "0" and event.get("client_type") == "0":
53
+ print("joined:", event["client_unique_identifier"])
54
+
55
+ @client.on("clientleftview")
56
+ async def on_leave(event: atsq.Event) -> None:
57
+ print("left:", event.get("clid"))
58
+
59
+ await client.run_forever() # reconnects with backoff; keepalive automatic
60
+ ```
61
+
62
+ Pull-style event consumption (instead of handlers):
63
+
64
+ ```python
65
+ async for event in client.events():
66
+ handle(event)
67
+ # or: event = await client.wait_for_event(timeout=240)
68
+ ```
69
+
70
+ Anything without a typed wrapper goes through the generic escape-safe `exec()`,
71
+ including pipelined bulk commands (many parameter blocks, one round trip):
72
+
73
+ ```python
74
+ rows = await ts.exec("servergrouplist")
75
+ await ts.exec("clientmove", clid=5, cid=42)
76
+ await ts.exec("channeladdperm", cid=60, blocks=[
77
+ {"permsid": "i_channel_needed_join_power", "permvalue": 75},
78
+ {"permsid": "i_channel_needed_subscribe_power", "permvalue": 60},
79
+ ])
80
+ ```
81
+
82
+ Wire constants are available as `StrEnum`s that compare directly against
83
+ event/row values:
84
+
85
+ ```python
86
+ from atsq import ReasonId, TargetMode, ClientType, LEAVE_REASONS
87
+
88
+ if event["reasonid"] == ReasonId.CONNECT and event["client_type"] == ClientType.VOICE:
89
+ ...
90
+ if event.get("reasonid") in LEAVE_REASONS:
91
+ ...
92
+ ```
93
+
94
+ File transfer (icons, avatars, channel files) — same API against TS3 and TS6:
95
+
96
+ ```python
97
+ ft = atsq.FileTransfer(client)
98
+ icon_id = await ft.upload_icon(png_bytes) # crc32-named, returns the id
99
+ data = await ft.download("/atsq.bin", cid=42)
100
+ rows = await ft.file_list(cid=42, path="/") # [] for empty dirs
101
+ await ft.delete_file("/atsq.bin", cid=42)
102
+ ```
103
+
104
+ ### Errors
105
+
106
+ ```python
107
+ try:
108
+ await ts.use(99)
109
+ except atsq.QueryError as e: # error id != 0; str(e) carries the server msg
110
+ print(e.error_id, e.msg)
111
+ except atsq.QueryTimeoutError: # no response in time (connection is closed)
112
+ ...
113
+ except atsq.ConnectionClosedError: # connection gone
114
+ ...
115
+ ```
116
+
117
+ `atsq.FloodError` (a `QueryError`, id 524) signals server flood protection — add your
118
+ client's IP to the server's `query_ip_allowlist.txt` to be exempt.
119
+
120
+ ### Defaults worth knowing
121
+
122
+ - **Keepalive**: automatic `whoami` after 240 s idle (servers kick at ~300 s).
123
+ Configure via `keepalive_interval`; `0` disables.
124
+ - **Flood protection**: an `error 524` is retried automatically after the wait
125
+ the server asks for (`flood_retries`, default 2; `0` disables). Allowlisted
126
+ IPs (`query_ip_allowlist.txt`) never hit it in the first place.
127
+ - **Snapshots** work via plain `exec("serversnapshotcreate")` /
128
+ `exec("serversnapshotdeploy", version=..., data=...)` — note deploy
129
+ deselects the session; call `use` again afterwards.
130
+ - **Reconnect** (`run_forever`): exponential backoff 5 s → 300 s; a server message
131
+ containing "banned" waits 300 s. `use`/`servernotifyregister` and `on_ready` re-run
132
+ after every reconnect.
133
+ - **Host keys**: verification is off by default (TeamSpeak servers generate ephemeral
134
+ query host keys). Pin one in production: `atsq.connect(..., known_hosts=...)`
135
+ (forwarded to asyncssh).
136
+ - **close() sends `quit`**: on TS6 a query client that silently drops the SSH
137
+ connection never produces a `notifyclientleftview`; a clean `quit` does (on both
138
+ generations). See [docs/dialects.md](docs/dialects.md).
139
+
140
+ ## TS3 vs TS6
141
+
142
+ Probed against real servers — the wire dialects are near-identical, and `atsq`
143
+ auto-detects the generation from the greeting (`client.dialect`). All recorded
144
+ differences and server-config notes live in [docs/dialects.md](docs/dialects.md).
145
+
146
+ Enable SSH query on a TS3 server with `TS3SERVER_QUERY_PROTOCOLS=raw,ssh`; on TS6 with
147
+ `TSSERVER_QUERY_SSH_ENABLED=1` (password via `TSSERVER_QUERY_ADMIN_PASSWORD`).
148
+
149
+ ## Migrating from py-ts3
150
+
151
+ | py-ts3 | atsq |
152
+ |---|---|
153
+ | `TS3ServerConnection("telnet://user:pass@host:10011")` | `await atsq.connect(host, 10022, username=..., password=...)` (SSH) |
154
+ | `conn.exec_("clientlist", "uid")` | `await ts.exec("clientlist", "uid")` or `await ts.client_list("uid")` |
155
+ | response `resp[0]["cldbid"]` | same shape: `rows[0]["cldbid"]` (`list[dict[str, str]]`) |
156
+ | `conn.wait_for_event(timeout=240)` | `await client.wait_for_event(timeout=240)` or `@client.on(...)` |
157
+ | `event[0]["reasonid"]` | `event["reasonid"]` (Event is a `Mapping[str, str]`) |
158
+ | `conn.send_keepalive()` | automatic (or `await ts.send_keepalive()`) |
159
+ | `ts3.query.TS3QueryError` | `atsq.QueryError` (str() still contains the server msg) |
160
+ | `ts3.query.TS3TimeoutError` | `atsq.QueryTimeoutError` |
161
+ | query builder `.pipe()` | `exec(cmd, blocks=[{...}, {...}], **shared)` |
162
+ | `ts3.definitions` constants | `atsq.ReasonId` / `TargetMode` / `ClientType` / `LEAVE_REASONS` |
163
+ | `ts3.filetransfer.TS3FileTransfer` | `atsq.FileTransfer` (asyncio, TS3+TS6) |
164
+ | manual reconnect loop | `await client.run_forever()` |
165
+
166
+ ## Development
167
+
168
+ ```
169
+ uv sync
170
+ uv run pytest # unit + fake-transport tests (no docker)
171
+ ./scripts/run-integration-tests.sh # full suite vs real TS3 + TS6 in docker
172
+ uv run ruff check src tests scripts && uv run mypy
173
+ ```
174
+
175
+ `scripts/probe_dialect.py` records raw protocol transcripts from a live server —
176
+ rerun it when a new TS6 build lands and diff against `tests/unit/fixtures/`.
177
+
178
+ ## License
179
+
180
+ MIT
@@ -0,0 +1,62 @@
1
+ # Real TeamSpeak servers for integration tests and the dialect probe.
2
+ #
3
+ # Host ports are ephemeral (127.0.0.1::10022) on purpose: the CI runner is
4
+ # shared between repos, fixed ports would collide. Discover them with
5
+ # docker compose -p atsq-integration port ts3 10022
6
+ #
7
+ # ts3: serveradmin password is generated on first boot and printed once in
8
+ # the logs (loginname= "serveradmin", password= "..."). Teardown uses
9
+ # -v so every run is a first boot.
10
+ # The query IP allowlist must contain the client's source subnet or the
11
+ # server drops/bans query connections; 0.0.0.0/0 is fine for these
12
+ # throwaway test servers only.
13
+ # ts6: password is deterministic via TSSERVER_QUERY_ADMIN_PASSWORD.
14
+
15
+ name: atsq-integration
16
+
17
+ services:
18
+ ts3:
19
+ image: teamspeak:3.13
20
+ environment:
21
+ TS3SERVER_LICENSE: accept
22
+ TS3SERVER_QUERY_PROTOCOLS: raw,ssh
23
+ volumes:
24
+ - ./query_ip_allowlist.txt:/var/ts3server/query_ip_allowlist.txt
25
+ ports:
26
+ - "127.0.0.1::10022"
27
+ - "127.0.0.1::30033" # file transfer data channel
28
+ healthcheck:
29
+ # raw stays enabled solely so this healthcheck can probe the query
30
+ # interface with the tools available in the image.
31
+ test: ["CMD-SHELL", "echo quit | nc localhost 10011 | grep -q TS3"]
32
+ interval: 5s
33
+ timeout: 5s
34
+ retries: 24
35
+
36
+ # Same TS3 image but WITHOUT the allowlist: connections arrive from the
37
+ # docker gateway IP and are flood-limited, so the flood/524 handling can
38
+ # be exercised against a real server (the allowlisted ones never 524).
39
+ ts3strict:
40
+ image: teamspeak:3.13
41
+ environment:
42
+ TS3SERVER_LICENSE: accept
43
+ TS3SERVER_QUERY_PROTOCOLS: raw,ssh
44
+ ports:
45
+ - "127.0.0.1::10022"
46
+ healthcheck:
47
+ test: ["CMD-SHELL", "echo quit | nc localhost 10011 | grep -q TS3"]
48
+ interval: 5s
49
+ timeout: 5s
50
+ retries: 24
51
+
52
+ ts6:
53
+ image: teamspeaksystems/teamspeak6-server:latest
54
+ environment:
55
+ TSSERVER_LICENSE_ACCEPTED: accept
56
+ TSSERVER_QUERY_SSH_ENABLED: "1"
57
+ TSSERVER_QUERY_ADMIN_PASSWORD: atsq-ci-password
58
+ volumes:
59
+ - ./query_ip_allowlist.txt:/var/tsserver/query_ip_allowlist.txt
60
+ ports:
61
+ - "127.0.0.1::10022"
62
+ - "127.0.0.1::30033" # file transfer data channel
@@ -0,0 +1,3 @@
1
+ 127.0.0.1
2
+ ::1
3
+ 0.0.0.0/0