Asyncio TeamSpeak ServerQuery client for TeamSpeak 3 and TeamSpeak 6 over SSH
Project description
atsq
Asyncio TeamSpeak ServerQuery client for TeamSpeak 3 and TeamSpeak 6, over SSH.
TeamSpeak 6 removed the classic raw/telnet ServerQuery — SSH query (port 10022) is the
only line-protocol interface left. atsq speaks that protocol against both server
generations with one async API, runs on modern Python (3.12–3.14+), and its whole test
suite executes against real teamspeak:3.13 and teamspeaksystems/teamspeak6-server
containers.
Why
py-ts3is unmaintained and importstelnetlibat module load — removed from the standard library in Python 3.13.- TeamSpeak 6 servers only offer SSH query (and HTTP WebQuery).
- Bots want asyncio-native ergonomics: awaitable commands,
@client.onevent handlers, automatic keepalive and reconnect — thediscord.pyfeel.
Install
uv add atsq # or: pip install atsq
Requires Python ≥ 3.12. The only runtime dependency is asyncssh.
Usage
One-shot administrative session:
import atsq
async with await atsq.connect("ts.example.com", 10022,
password="...", server_id=1) as ts:
for row in await ts.client_list("uid"):
print(row["clid"], row["client_nickname"])
cid = await ts.channel_create("Lounge", channel_flag_permanent=1)
Long-running bot with events and automatic reconnect:
client = atsq.Client("ts.example.com", 10022, password="...",
server_id=1, # or server_port=9987
nickname="My Bot", # re-applied on reconnect
register_events=atsq.ALL_EVENTS) # or "server", or a list
@client.on("cliententerview")
async def on_join(event: atsq.Event) -> None:
if event.get("reasonid") == "0" and event.get("client_type") == "0":
print("joined:", event["client_unique_identifier"])
@client.on("clientleftview")
async def on_leave(event: atsq.Event) -> None:
print("left:", event.get("clid"))
await client.run_forever() # reconnects with backoff; keepalive automatic
Pull-style event consumption (instead of handlers):
async for event in client.events():
handle(event)
# or: event = await client.wait_for_event(timeout=240)
Anything without a typed wrapper goes through the generic escape-safe exec(),
including pipelined bulk commands (many parameter blocks, one round trip):
rows = await ts.exec("servergrouplist")
await ts.exec("clientmove", clid=5, cid=42)
await ts.exec("channeladdperm", cid=60, blocks=[
{"permsid": "i_channel_needed_join_power", "permvalue": 75},
{"permsid": "i_channel_needed_subscribe_power", "permvalue": 60},
])
Wire constants are available as StrEnums that compare directly against
event/row values:
from atsq import ReasonId, TargetMode, ClientType, LEAVE_REASONS
if event["reasonid"] == ReasonId.CONNECT and event["client_type"] == ClientType.VOICE:
...
if event.get("reasonid") in LEAVE_REASONS:
...
File transfer (icons, avatars, channel files) — same API against TS3 and TS6:
ft = atsq.FileTransfer(client)
icon_id = await ft.upload_icon(png_bytes) # crc32-named, returns the id
data = await ft.download("/atsq.bin", cid=42)
rows = await ft.file_list(cid=42, path="/") # [] for empty dirs
await ft.delete_file("/atsq.bin", cid=42)
Errors
try:
await ts.use(99)
except atsq.QueryError as e: # error id != 0; str(e) carries the server msg
print(e.error_id, e.msg)
except atsq.QueryTimeoutError: # no response in time (connection is closed)
...
except atsq.ConnectionClosedError: # connection gone
...
atsq.FloodError (a QueryError, id 524) signals server flood protection — add your
client's IP to the server's query_ip_allowlist.txt to be exempt.
Defaults worth knowing
- Keepalive: automatic
whoamiafter 240 s idle (servers kick at ~300 s). Configure viakeepalive_interval;0disables. - Flood protection: an
error 524is retried automatically after the wait the server asks for (flood_retries, default 2;0disables). Allowlisted IPs (query_ip_allowlist.txt) never hit it in the first place. - Snapshots work via plain
exec("serversnapshotcreate")/exec("serversnapshotdeploy", version=..., data=...)— note deploy deselects the session; calluseagain afterwards. - Reconnect (
run_forever): exponential backoff 5 s → 300 s; a server message containing "banned" waits 300 s.use/servernotifyregisterandon_readyre-run after every reconnect. - Host keys: verification is off by default (TeamSpeak servers generate ephemeral
query host keys). Pin one in production:
atsq.connect(..., known_hosts=...)(forwarded to asyncssh). - close() sends
quit: on TS6 a query client that silently drops the SSH connection never produces anotifyclientleftview; a cleanquitdoes (on both generations). See docs/dialects.md.
TS3 vs TS6
Probed against real servers — the wire dialects are near-identical, and atsq
auto-detects the generation from the greeting (client.dialect). All recorded
differences and server-config notes live in docs/dialects.md.
Enable SSH query on a TS3 server with TS3SERVER_QUERY_PROTOCOLS=raw,ssh; on TS6 with
TSSERVER_QUERY_SSH_ENABLED=1 (password via TSSERVER_QUERY_ADMIN_PASSWORD).
Migrating from py-ts3
| py-ts3 | atsq |
|---|---|
TS3ServerConnection("telnet://user:pass@host:10011") |
await atsq.connect(host, 10022, username=..., password=...) (SSH) |
conn.exec_("clientlist", "uid") |
await ts.exec("clientlist", "uid") or await ts.client_list("uid") |
response resp[0]["cldbid"] |
same shape: rows[0]["cldbid"] (list[dict[str, str]]) |
conn.wait_for_event(timeout=240) |
await client.wait_for_event(timeout=240) or @client.on(...) |
event[0]["reasonid"] |
event["reasonid"] (Event is a Mapping[str, str]) |
conn.send_keepalive() |
automatic (or await ts.send_keepalive()) |
ts3.query.TS3QueryError |
atsq.QueryError (str() still contains the server msg) |
ts3.query.TS3TimeoutError |
atsq.QueryTimeoutError |
query builder .pipe() |
exec(cmd, blocks=[{...}, {...}], **shared) |
ts3.definitions constants |
atsq.ReasonId / TargetMode / ClientType / LEAVE_REASONS |
ts3.filetransfer.TS3FileTransfer |
atsq.FileTransfer (asyncio, TS3+TS6) |
| manual reconnect loop | await client.run_forever() |
Development
uv sync
uv run pytest # unit + fake-transport tests (no docker)
./scripts/run-integration-tests.sh # full suite vs real TS3 + TS6 in docker
uv run ruff check src tests scripts && uv run mypy
scripts/probe_dialect.py records raw protocol transcripts from a live server —
rerun it when a new TS6 build lands and diff against tests/unit/fixtures/.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file atsq-1.0.0a4.tar.gz.
File metadata
- Download URL: atsq-1.0.0a4.tar.gz
- Upload date:
- Size: 89.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5eccf049ca619429601ea581b9929a61b8c8f5c3ca9ad6d2c939d4b7a92b4699
|
|
| MD5 |
eedd7d4346d77c9841ae878141130d4c
|
|
| BLAKE2b-256 |
8a9e20271d17d751278bbdccb38d8eb7e35f7bef4ccbf2bfaa19991b223b4fee
|
Provenance
The following attestation bundles were made for atsq-1.0.0a4.tar.gz:
Publisher:
release.yml on dev-lukas/atsq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
atsq-1.0.0a4.tar.gz -
Subject digest:
5eccf049ca619429601ea581b9929a61b8c8f5c3ca9ad6d2c939d4b7a92b4699 - Sigstore transparency entry: 2137230937
- Sigstore integration time:
-
Permalink:
dev-lukas/atsq@aa341d0819188f57d8a8540dbcb10a15a603bfb0 -
Branch / Tag:
refs/tags/1.0.0a4 - Owner: https://github.com/dev-lukas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
self-hosted -
Publication workflow:
release.yml@aa341d0819188f57d8a8540dbcb10a15a603bfb0 -
Trigger Event:
push
-
Statement type:
File details
Details for the file atsq-1.0.0a4-py3-none-any.whl.
File metadata
- Download URL: atsq-1.0.0a4-py3-none-any.whl
- Upload date:
- Size: 25.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4926fd1284acc8a6638f7c433f19b20d02145b36aa48c25d1584c5b8a7b59f6f
|
|
| MD5 |
f06b79aa777f76926b4682e6eff71eac
|
|
| BLAKE2b-256 |
8ddd676be35f023c56ac971b3ad83a65baa13a8b1229d8a1bc25de3983feab15
|
Provenance
The following attestation bundles were made for atsq-1.0.0a4-py3-none-any.whl:
Publisher:
release.yml on dev-lukas/atsq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
atsq-1.0.0a4-py3-none-any.whl -
Subject digest:
4926fd1284acc8a6638f7c433f19b20d02145b36aa48c25d1584c5b8a7b59f6f - Sigstore transparency entry: 2137231109
- Sigstore integration time:
-
Permalink:
dev-lukas/atsq@aa341d0819188f57d8a8540dbcb10a15a603bfb0 -
Branch / Tag:
refs/tags/1.0.0a4 - Owner: https://github.com/dev-lukas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
self-hosted -
Publication workflow:
release.yml@aa341d0819188f57d8a8540dbcb10a15a603bfb0 -
Trigger Event:
push
-
Statement type: