TeamSpeak 3 is old, but many gaming communities still run it, and its voice is great. Its chat is not: a plain-text log with BBCode. To share a screenshot, you upload it to the channel's file browser, post a link, and everyone downloads it and opens it somewhere else.
I wanted a Discord-style chat inside the TeamSpeak 3 client: pictures and GIFs inline, a video player, voice messages, replies, reactions, polls. With one hard rule: no extra server, no cloud, no account. The server a community already runs had to be enough.
The result is TS Media chat, a free plugin for the TeamSpeak 3.6 client on Windows, written in C++17 with Qt 5.15 Widgets and now at v2.3.1. (The source is public on GitHub; it's source-available rather than open source.)
What's in this post:
- storing media in the channel's own file browser
- drawing previews into TeamSpeak's chat documents, reversibly
- three ways my plugin crashed TeamSpeak, one way it quietly broke, and the fixes
- measured anti-flood numbers and a tiny protocol over plugin commands
- anonymous polls with a hash chain, and live music through your own voice
- auto-updates from GitHub without signing keys
- testing a plugin for a closed-source app
The core trick: the server already has storage
Every TeamSpeak channel has a file browser. Drag a file from it into the chat and TeamSpeak posts a BBCode link with a ts3file:// URL. TS Media does exactly that: it uploads the file with TeamSpeak's own file transfer (into /tsmedia, previews into /tsmedia/previews) and posts an ordinary ts3file link with a few extra parameters (a video link here):
[URL=ts3file://<host>?port=..&serverUID=..&channel=..&path=..&filename=..&isDir=0
&size=..&fileDateTime=..&tsm=2&w=..&h=..&d=..&bh=..&pv=..&sha=..]<file name>[/URL]
Everything from tsm= on is TS Media's own, and TeamSpeak ignores parameters it doesn't know. Without the plugin you see a normal download link and a short grey note, TS Media chat plugin required to view this in chat. With the plugin, the extras (width and height, duration, a BlurHash placeholder, a preview path, the SHA-256, spoiler, album and voice-message flags) are enough to draw the media in place and hide the note.
Two lessons:
-
Every link is hostile. Anyone can type a
ts3filelink with any parameters, so sizes are clamped, previews must be in the same channel, and images above 80 megapixels are never decoded. - Why the link carries a SHA-256. TeamSpeak reports a download as complete before it has finished writing the preallocated file, so the end of the file can still be zeros. The plugin waits until the file is really written, then checks the hash. A file that was swapped on the server after it was posted is deleted and marked File doesn't match what was sent.
Living inside someone else's Qt app
A TeamSpeak plugin is a DLL with C exports (ts3plugin_init, ts3plugin_onTextMessageEvent and so on; plugin API 26). TeamSpeak's UI is Qt, so a plugin built against the same Qt can work with its widgets, under a few rules.
Exactly the same Qt, and only part of it. TeamSpeak 3.6.2 ships Qt 5.15.2, so I build against 5.15.2, and only with QtCore, QtGui and QtWidgets: there's no QtMultimedia and no QtConcurrent. The build script checks every imported Qt symbol against the exports of TeamSpeak's own Qt DLLs: a plugin that imports one symbol they don't export fails to load on users' PCs.
Previews live in TeamSpeak's chat documents. Each chat view is a QTextBrowser (ChatTab : TextBrowser : QTextBrowser) with the stock QTextDocumentLayout. One message is one QTextBlock; a \n becomes U+2028 inside that block. A preview is an image resource in that document, placed under the link. Follow-up messages that only carry more album links are hidden with QTextBlock::setVisible(false); the layout honours it but doesn't keep the view pinned to the bottom, so the plugin does.
Every edit must be reversible. TeamSpeak can unload a plugin while TeamSpeak itself keeps running, so every change the plugin makes to its documents must be undone byte for byte. For HD emoji, each emoji (a whole grapheme cluster, such as a ZWJ sequence or a flag) is replaced by one image whose format remembers what was there:
// c: one emoji found in the chat document (position, length, text, format)
cursor.setPosition(c.position);
cursor.setPosition(c.position + c.length, QTextCursor::KeepAnchor);
QTextImageFormat image;
image.merge(c.format);
image.setObjectType(QTextFormat::ImageObject);
image.setProperty(kOriginalText, c.text); // UserProperty + 0x7460
image.setProperty(kOriginalFormat, QTextFormat(c.format)); // format before
image.setName(resourceName(c.id, px)); // e.g. "tsmemoji:1f602/22"
cursor.insertText(QString(QChar::ObjectReplacementCharacter), image);
On unload, restoreInto() puts back the original text and format of every fragment with those properties. Reply lines, reaction rows, poll cards and the restyled layout carry their own UserProperty markers the same way. The Cozy layout below is TeamSpeak's own chat document, restyled in place; on unload it goes back to TeamSpeak's plain look.
One chat input for every server tab. TeamSpeak 3.6.2 has a single ChatLineEdit (a QTextEdit with acceptRichText=false) shared by all server tabs, so everything attached to it must follow the tab in front. And its "Enter Chat Message..." placeholder isn't one: it's real grey text that TeamSpeak removes on focus-in. An early emoji picker inserted 😀 into an empty, unfocused input, and the message went out as 😀Enter Chat Message....
Things that crashed (or quietly broke) TeamSpeak
A plugin shares the host's process: when it crashes, TeamSpeak crashes. Each of these really happened; I count crash dumps before and after every live test.
1. A pending QTimer::singleShot at unload
Up to 2.0.7, and in my 2.1.0 development build, TeamSpeak crashed on exit if you quit within 15 seconds of starting it (or within about 30 seconds of sending a file). The dump pointed at module base + 0x1CC40, which the linker map resolved to QFunctorSlotObject<Core::start()::lambda_4>::impl:
// kStartupCacheCheckMs = 15000
QTimer::singleShot(kStartupCacheCheckMs, this, [this] { enforceCacheLimit(); });
In Qt 5.15 the functor overload creates an internal timer whose parent is the event dispatcher, not your context object. The context is only watched, so deleting it neither stops the timer nor frees the functor.
The functor gets destroyed later (when the timer fires, or when QApplication goes) by code in my DLL, which by then is unloaded.
Before the fix, the 2.1 code had 19 such calls, including a 30-minute timer after a failed upload. The fix is a timer the object owns:
template <class Fn>
QTimer* singleShotOwned(int ms, QObject* owner, Fn fn)
{
auto* timer = new QTimer(owner);
timer->setSingleShot(true);
QObject::connect(timer, &QTimer::timeout, owner, [timer, fn = std::move(fn)]() mutable {
timer->deleteLater();
fn();
});
timer->start(ms);
return timer;
}
Shutdown deletes the owners while the DLL is still loaded, and the timers and functors go with them. (A zero-delay singleShot is fine: it's a posted event, removed with its receiver.)
2. qApp->quit()
The updater needs to restart TeamSpeak. My first fallback was QTimer::singleShot(0, qApp, SLOT(quit())). TeamSpeak crashed at once inside its own code, ts3plugin_shutdown never ran, and the process lived on without a window until I killed it. What works (4 runs out of 4): find TeamSpeak's own Quit action (the one with Ctrl+Q) and trigger it through a queued call.
QAction* findQuitAction()
{
const QKeySequence quit(Qt::CTRL | Qt::Key_Q);
for (QWidget* window : QApplication::topLevelWidgets()) {
if (!qobject_cast<QMainWindow*>(window))
continue;
for (QAction* action : window->findChildren<QAction*>())
if (!action->isSeparator() && !action->menu()
&& action->shortcut().matches(quit) == QKeySequence::ExactMatch)
return action;
}
return nullptr;
}
// then, from the updater:
QMetaObject::invokeMethod(action, "trigger", Qt::QueuedConnection);
3. QStringLiteral in long-lived Qt state
QStringLiteral builds the string at compile time. Its characters live in the DLL's read-only data, and copies share them. If one lands in Qt state that outlives the DLL (style sheets, QSettings, tooltips, QFont families, statics), Qt later reads memory that is gone. Long-lived strings get heap data:
// Bad: the characters stay in the DLL image,
// and Qt's font cache can outlive the DLL
QFont bad(QStringLiteral("Segoe UI Emoji"));
// Good: the characters are copied to the heap
QFont good(QString::fromLatin1("Segoe UI Emoji"));
4. [noparse] protects nothing
Not a crash, but just as surprising. A caption containing [URL=...] must not become a link. The usual BBCode escape is [noparse], but TeamSpeak 3.6 drops those tags and still parses what's inside: a wrapped ts3file link became a real link (my own plugin drew a file card for it). A backslash before each bracket works, in messages and in plugin-printed lines:
text.replace(QLatin1Char('['), QLatin1String("\\["));
text.replace(QLatin1Char(']'), QLatin1String("\\]"));
The receiving side skips \[URL= too, so a caption can't smuggle in a fake file link for the plugin to prefetch.
One precaution: binaries from VS 2022 17.10+ can crash in std::mutex on an older msvcp140.dll, and the Visual C++ runtime on users' PCs comes with TeamSpeak and may be older than your compiler. Microsoft's documented workaround is to define _DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR.
Measure first
My 2.1 code assumed a 1024-byte message limit. Before 2.2, I wrote a measurement spike: a test-only build that ran probes from inside a TeamSpeak 3.6.2 client against a local 3.13.7 server (default anti-flood, Guest group). Its numbers shaped everything after:
| What | Measured |
|---|---|
| Text message limit | 8192 UTF-8 bytes (error 0x0605 above that) |
| Silent drop | an escaped command over ~9.1 KB gets no answer and is never delivered (backslash, slash, space, pipe and control characters count double) |
| Text flood cost | 15 of 150 points, 5 back per second: 10 in a row, then "retry in ~5.7 s"; 1 per 3.3 s sustained |
| Plugin command cost | 5 points on a separate counter: ~30 in a row, then ~1 per second |
| Rejected attempts | charged too: retrying every 2 s while blocked never recovered in 120 s |
| Channel or server plugin commands | echoed back to the sender |
| A list of target clients | one stale client id fails the whole command (0x0200) |
| Plugin id | a random GUID on every load; receivers see the DLL name without its suffix (tsmedia for tsmedia_win64.dll) |
| File transfers | never flood-blocked (60 uploads at 150 ms intervals); each start still costs ~3 points on the text counter |
So messages are sized on their escaped length (at most 8168 bytes; a 10-item album still fits). A FloodGovernor per connection models both counters, keeps 30 points for the user's own typing, and after 0x020c waits for the server's retry in <n>ms plus 250 ms, then sends exactly one command. Plugin commands carry signalling only; bulk data goes through file transfer.
A tiny protocol over plugin commands
Copies of the plugin talk through sendPluginCommand / onPluginCommandEvent in a format I called tsm1:
tsm1 HELLO pv=1 v=2.2.0 caps=p,r rr=1 I'm here (to the channel)
tsm1 HI pv=1 v=2.2.0 caps=p,r the answer, only to whoever asked
tsm1 BYE presence off, or the plugin unloads
tsm1 R s=c i=<media key>:up.heart my complete reaction set on that item
tsm1 SYNC s=c m=<media key>,... a late joiner asks for reactions
What kept it robust:
-
Strict and small. ASCII only, at most 32 tokens, values limited to
[A-Za-z0-9._,:-]. Outgoing commands are at most 900 bytes; incoming ones over 2048 bytes are dropped before they're even copied. -
Forward compatible. Unknown types and fields are ignored, and so is a future
tsm2.e=carries reactions with any emoji, whilei=keeps the six classic codes (👍 ❤️ 😂 😮 😢 🔥), so a client that doesn't knowe=still sees those. 2.2 simply ignores 2.3's poll and text-reaction types. -
Identity from the server, never the payload. The server fills in the invoker's id, name and unique id. A payload claiming
uid=FAKEUID invokerid=999arrived with the real invoker. -
Match by name, send with the GUID. Receivers see the plugin as
tsmedia(the DLL name without its suffix), but sending needs the random GUID TeamSpeak hands out on each load. Channel commands echo back to you, so drop your own. -
Idempotent state. Each
Rcarries the sender's whole set for an item, so a lost command heals with the next one.
Presence keeps the send window honest: one HELLO 800 ms after you enter a channel, one HI back from each member running the plugin after a random 150 to 900 ms, and the window can say 3 of 5 people here will see it in the chat.
TeamSpeak messages have no ids, so a reaction on a text message names it by a hash of the sender and the normalized text, plus "received N seconds ago". Clocks and time zones don't matter.
Anonymous polls without a server
A poll is an ordinary chat message, readable without the plugin; plugin users get a card and vote over plugin commands. No server counts votes: every plugin in the channel keeps the ballots it has seen and answers late joiners. So how can a vote be anonymous yet impossible for others to change?
A hash chain, the idea behind Lamport's one-time passwords:
- On your first vote, your plugin makes a random 16-byte secret for that poll.
his the first 16 bytes of SHA-256 over"tsmpoll1|" + poll + "|" + value. Write hⁿ forhapplied n times:h¹²⁰(secret)is the ballot id. Nothing in it says who you are. - Your vote reveals
h¹¹⁹(secret); anyone can hash it once and get the id. - Each change (another option, a retraction) reveals the next link back. SHA-256 can't be inverted, so only the secret's owner can do that. The newest state wins.
Ballot makeBallot(const QString& key, const QByteArray& secret, int step, quint16 mask)
{
Ballot b;
b.step = qBound(1, step, kChainLength); // kChainLength = 120
b.proof = chainAt(key, secret, kChainLength - b.step);
b.id = chainAt(key, b.proof, b.step);
b.mask = mask; // the chosen options; 0 = retracted
return b;
}
The honest limits are in the poll docs: the server tells every receiver who sent each plugin command. TS Media discards that, but a modified client could log it and tie a live vote to a person, or invent ballots. The counts are only as trustworthy as the plugin users in the channel.
Live music through your own voice
Live music plays a file to your channel through your own voice, and you can keep talking over it. Everyone hears it, plugin or not.
TeamSpeak hands the plugin each block of captured voice as 16-bit PCM at 48 kHz in ts3plugin_onEditCapturedVoiceDataEvent(sch, samples, sampleCount, channels, int* edited). Media Foundation decodes the file into a 48 kHz ring buffer on a worker thread, and the callback mixes it in with a soft knee above 0.75, so music plus voice never clips hard.
Once you export a voice callback, TeamSpeak calls it for every audio block, music or not. So the hot path is a few atomic operations (a counter and one load), with no locks and no allocation, and swapping the engine waits until no callback still holds the old one:
void onCaptured(quint64 sch, short* samples, int frames, int channels, int* edited)
{
++g_inside;
Engine* engine = g_engine.load();
if (engine && engine->connection() == sch)
engine->captured(samples, frames, channels, edited);
--g_inside;
}
// swapping engines
void setActive(Engine* engine)
{
Engine* previous = g_engine.exchange(engine);
if (previous == engine)
return;
// a callback that took the previous engine finishes with it first
while (g_inside.load() != 0)
std::this_thread::yield();
}
*edited holds two flags. Flag 1 tells TeamSpeak the plugin changed the samples. Flag 2 is set on entry when TeamSpeak is going to send the block, and setting it yourself has the block sent anyway. With music playing the plugin sets both, and keeps the microphone samples only in blocks TeamSpeak would have sent, so background noise between your sentences isn't sent.
The voice activation surprise. In a live test, nobody heard the music until I spoke. With voice activation, TeamSpeak keeps a transmission going on the plugin's send flag, but never starts one while it hears silence.
The fix: when music should go out and my own CLIENT_FLAG_TALKING is 0, the plugin briefly turns voice activation off with setPreProcessorConfigValue(sch, "vad", "false"), with the microphone samples silenced, and turns it back on once TeamSpeak is sending. It's rate-limited, always undone (pause, stop, tab change, unload) and never touches the user's capture profile.
Push-to-talk is a documented limit: the plugin can't see the key, so with push-to-talk you can't talk over the music; the channel hears only the music.
Only the tab in front. TeamSpeak sends voice only from the server tab in front, so the music pauses while its tab is in the background. The plugin API can't bring a tab to the front, so Play in the compact music bar finds TeamSpeak's QTabBar and selects the right tab.
Video with Media Foundation, emoji with DirectWrite
-
Video plays through
IMFMediaEnginein frame-server mode on D3D11 (WARP as a fallback), with frames handed over asQImages.mfplat.dllandmfreadwrite.dllare delay-loaded and checked for, so the plugin still loads on Windows N editions without the Media Feature Pack, just without video, audio playback, voice messages and video compression. Videos over 25 MB are transcoded to H.264/AAC before upload, and the result is checked before it's sent. -
HD emoji are drawn with DirectWrite and Direct2D (loaded at run time; Segoe UI Emoji with
D2D1_DRAW_TEXT_OPTIONS_ENABLE_COLOR_FONT) on one low-priority worker thread: about 3 ms per new emoji on Windows 11, then cached. The chat document changes in small steps, so a wall of emoji can't stall TeamSpeak.
Updating from GitHub without signing keys
The official addon site isn't an option for new TeamSpeak 3 addons, so releases live on GitHub. I wanted updates without a key to guard: publishing a release should be enough. In official builds, once the user agrees:
-
The check: WinHTTP (loaded only while it runs), HTTPS only, the repository name compiled into the DLL, at most 5 redirects, each only to
github.comor*.githubusercontent.com. The plugin builds every URL itself. -
The manifest: an unsigned
tsmedia-update-v2.jsonon the latest release lists each file's size and SHA-256. Every download must match, its PE machine must match (x64 or x86), and all of the new DLL's imports must resolve in the running TeamSpeak. Only strictly newer versions count. The trade-off: whoever controls the GitHub repository can ship an update to everyone who turned updates on. -
The install: a loaded DLL can't be overwritten or deleted, but on NTFS it can be renamed. TeamSpeak only loads
*_win64.dll(*_win32.dllon 32-bit) from the top ofplugins/, never subfolders, so staging and rollback copies are safe there:
// simplified: the running plugin swaps itself
MoveFileExW(L"plugins\\tsmedia_win64.dll",
L"plugins\\tsmedia\\update\\rollback\\<old>\\tsmedia_win64.dll.bak",
MOVEFILE_WRITE_THROUGH | MOVEFILE_REPLACE_EXISTING);
MoveFileExW(L"plugins\\tsmedia\\update\\staging\\<new>\\tsmedia_win64.dll.new",
L"plugins\\tsmedia_win64.dll", MOVEFILE_WRITE_THROUGH);
-
The safety net: a boot counter at the top of
ts3plugin_init. If a new version never finished starting, the next start restores the previous DLL and returns 1, so TeamSpeak unloads the plugin and keeps working. - The bridge: 2.2.0 only trusts a signed manifest (ECDSA P-256), so every release still carries the one signed manifest of 2.2.1. 2.2.0 users hop to 2.2.1, then to the latest; no key is needed again.
Testing a plugin for a closed-source app
You can't unit test TeamSpeak, but most of your code doesn't have to need it.
- Pure logic first. Link parsing, the protocol, the flood governor (with an injected clock), ballots, the mixing math and the update manifest have no TeamSpeak in them and run in one Qt Test executable. The upload engine runs against a fake TeamSpeak function table: the test writes what "arrives", then calls the engine as TeamSpeak would.
-
Always
-platform offscreen. Test windows that took the focus once swallowed my keystrokes. SetQT_QPA_FONTDIR, or the offscreen platform draws text as boxes. -
A driver inside TeamSpeak. Test builds (never released) run a script of JSON commands inside TeamSpeak against a local test server, and only while the current tab is connected to
127.0.0.1,localhostor::1:
{"cmd":"send","files":["cat.png"],"caption":"hello"}
{"cmd":"waitidle","timeout":20000}
{"cmd":"snap","name":"after-send"}
{"cmd":"quit"}
- Never the real microphone. In one live test, a click on the mic button sent 2.5 seconds of real audio to the local server. Since then, test builds record generated sound, and live tests mute TeamSpeak's own capture and play nothing audible.
- Quit on purpose. Unload tests quit within seconds of an upload, to catch the timer bug above, then count crash dumps.
Wrap-up
If you write a plugin for someone else's app:
- Measure the host before you design. My message limit was off by a factor of 8.
- Own every timer, string and widget you hand to the host, and make every change reversible.
- Treat whatever other clients send as hostile, and keep identity out of payloads.
TS Media chat is free on GitHub (v2.3.1 at the time of writing). From the latest release you only need the .ts3_plugin file: double-click it with TeamSpeak closed (TeamSpeak 3.6.x, Windows 10 and 11). The architecture notes go much deeper.
Metihttp
/
Teamspeak_Media_chat
TeamSpeak 3 plugin: Discord-style inline images, GIFs, videos and file cards. Files stay on your own TeamSpeak server.
If your community still runs TeamSpeak 3, give it a try. If this post saved you a crash or two, a star on the repo helps other TeamSpeak users find it. Bugs go to GitHub issues; questions and ideas are welcome in the comments or on Telegram, @metii.
What would you build on TeamSpeak's three pipes: voice, file transfer and plugin commands?
I wrote this post with help from an AI assistant and checked the technical details against the project's code.




Top comments (0)