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.
- atsq-1.0.0a4/.coverage +0 -0
- atsq-1.0.0a4/.github/workflows/ci.yml +57 -0
- atsq-1.0.0a4/.github/workflows/release.yml +53 -0
- atsq-1.0.0a4/.gitignore +7 -0
- atsq-1.0.0a4/LICENSE +21 -0
- atsq-1.0.0a4/PKG-INFO +200 -0
- atsq-1.0.0a4/README.md +180 -0
- atsq-1.0.0a4/docker/docker-compose.test.yml +62 -0
- atsq-1.0.0a4/docker/query_ip_allowlist.txt +3 -0
- atsq-1.0.0a4/docs/dialects.md +92 -0
- atsq-1.0.0a4/docs/testing.md +57 -0
- atsq-1.0.0a4/pyproject.toml +67 -0
- atsq-1.0.0a4/renovate.json +9 -0
- atsq-1.0.0a4/scripts/probe_dialect.py +298 -0
- atsq-1.0.0a4/scripts/run-integration-tests.sh +74 -0
- atsq-1.0.0a4/src/atsq/__init__.py +40 -0
- atsq-1.0.0a4/src/atsq/client.py +420 -0
- atsq-1.0.0a4/src/atsq/connection.py +332 -0
- atsq-1.0.0a4/src/atsq/definitions.py +63 -0
- atsq-1.0.0a4/src/atsq/dialect.py +66 -0
- atsq-1.0.0a4/src/atsq/errors.py +63 -0
- atsq-1.0.0a4/src/atsq/escape.py +63 -0
- atsq-1.0.0a4/src/atsq/events.py +51 -0
- atsq-1.0.0a4/src/atsq/filetransfer.py +222 -0
- atsq-1.0.0a4/src/atsq/protocol.py +164 -0
- atsq-1.0.0a4/src/atsq/py.typed +0 -0
- atsq-1.0.0a4/src/atsq/transport.py +156 -0
- atsq-1.0.0a4/tests/__init__.py +0 -0
- atsq-1.0.0a4/tests/fake/__init__.py +0 -0
- atsq-1.0.0a4/tests/fake/fake_transport.py +83 -0
- atsq-1.0.0a4/tests/fake/test_client.py +431 -0
- atsq-1.0.0a4/tests/fake/test_connection.py +335 -0
- atsq-1.0.0a4/tests/fake/test_filetransfer.py +262 -0
- atsq-1.0.0a4/tests/integration/__init__.py +0 -0
- atsq-1.0.0a4/tests/integration/conftest.py +69 -0
- atsq-1.0.0a4/tests/integration/test_flood_live.py +58 -0
- atsq-1.0.0a4/tests/integration/test_serverquery.py +468 -0
- atsq-1.0.0a4/tests/unit/__init__.py +0 -0
- atsq-1.0.0a4/tests/unit/fixtures/probe_ts3.log +129 -0
- atsq-1.0.0a4/tests/unit/fixtures/probe_ts6.log +129 -0
- atsq-1.0.0a4/tests/unit/test_definitions.py +22 -0
- atsq-1.0.0a4/tests/unit/test_errors.py +52 -0
- atsq-1.0.0a4/tests/unit/test_escape.py +67 -0
- atsq-1.0.0a4/tests/unit/test_events.py +30 -0
- atsq-1.0.0a4/tests/unit/test_fixtures_replay.py +69 -0
- atsq-1.0.0a4/tests/unit/test_package.py +5 -0
- atsq-1.0.0a4/tests/unit/test_protocol.py +269 -0
- 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
|
atsq-1.0.0a4/.gitignore
ADDED
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
|