DEV Community

Cover image for I built a Discord bot that reads Vietnamese chat aloud. Here's what broke once real servers used it
Bùi Trọng Khánh Duy
Bùi Trọng Khánh Duy

Posted on

I built a Discord bot that reads Vietnamese chat aloud. Here's what broke once real servers used it

Every Discord gaming group has one: the friend who can't talk. It's 1 a.m. and their family is asleep, or they're in a dorm, or the mic is just broken. So they type. And nobody in the middle of a match reads the chat.

Discord has a built-in /tts, but only people who have that text channel open hear it, and the Vietnamese voice reads chat the way it's written. Type "ko" (short for "không", "no") and you hear the letters K and O.

I'm Khanh Duy, an indie developer in Ho Chi Minh City, and I built Voxy to fix that for Vietnamese servers: a bot that sits in a voice channel and reads that channel's text chat out loud. You type /join once, and from then on whatever anyone types in the voice channel's chat is spoken to the room. Disclosure up front: it's my product, it's freemium, and this post is about the parts that turned out harder than I expected.

The naive version is three lines: get message, call a text-to-speech API, play the audio into the voice connection. It works fine with two friends. Then real servers showed up.

1. Nobody chats in textbook Vietnamese

A normal message in a gaming server looks like this:

ae ơi tối nay 8h30 vô ko, ai thiếu 100k thì ib t nha kkkkk
Enter fullscreen mode Exit fullscreen mode

Roughly: "guys, you in tonight at 8:30? whoever's short 100k, DM me lol". Hand that straight to a TTS engine and you get letter soup: "a e", "tám hát ba mươi", "ka ô", "một trăm ka", and then five K's in a row.

So every message goes through a "chat to speech" pass before synthesis:

  • Shorthand: ko, k, hok, khum all become "không"; ae becomes "anh em"; ib becomes "nhắn tin".
  • Numbers with units: 100k is "100 nghìn", 8h30 is a time.
  • Laughter: kkkkk is a laugh, not five letters. A built-in k never matches inside 100k or kkkk, because a dictionary term needs word edges.
  • Links and code blocks are announced, not read character by character.
  • Discord timestamps like <t:1792774733:R> are read as a real date and time.

Word counting uses Intl.Segmenter with the vi locale, so I didn't have to write a Vietnamese word splitter:

const WORD_SEGMENTER = new Intl.Segmenter("vi", { granularity: "word" });

function limitWords(input: string, maxWords: number) {
  let wordCount = 0;
  for (const segment of WORD_SEGMENTER.segment(input)) {
    if (!segment.isWordLike) continue;
    wordCount += 1;
    if (wordCount > maxWords) {
      return { text: input.slice(0, segment.index).trim(), truncated: true };
    }
  }
  return { text: input, truncated: false };
}
Enter fullscreen mode Exit fullscreen mode

Each message is capped at 40 words. Longer ones are cut and end with ", còn nữa" ("there's more") so the room knows it was trimmed. Repeated characters are capped with a grapheme segmenter too, otherwise "hahahahaaaaaaa" becomes a very long second of everyone's evening.

The shorthand list can't be global

My first version had a hard-coded shorthand list. After it had run in real servers for a while, I had to pull an entry out because it meant different things in different servers. There is no dictionary that is right for everyone.

Now each server has its own pronunciation dictionary (up to 300 words). The built-in shorthand is copied into it on first use, admins can edit or delete anything, and a deleted word never comes back on its own. One rule I'm glad I added early: built-in shorthand is never applied to display names. Otherwise a friend whose nickname happens to be a shorthand gets renamed every time they speak.

2. Busy servers made the bot slow, and TTS wasn't the culprit

In small rooms everything felt instant. In a busy server, messages started arriving seconds late, sometimes after the conversation had moved on.

I assumed the TTS API. It was actually a REST call I made for every message to fetch the member and check they were still in the room and allowed to use the bot. In large servers that call took anywhere from a quarter of a second to several seconds.

What fixed it:

  1. No per-message member fetch. Use the cached roles from when the message was sent, and re-check the room and permissions once, right before playback.
  2. Synthesize the next message while the current one plays. There are 8 synthesis slots shared across rooms, and work-ahead only runs while 2 of them stay free, so one loud server can't starve a message that needs to play now.
  3. Drop stale messages. Anything older than 30 seconds isn't read. By then it's no longer part of the conversation.

Point 2 has a cost: if a message is synthesized ahead and then deleted, I've still paid for that synthesis. Speed was worth it.

3. One bot can only be in one voice channel per server

Discord lets a bot be connected to one voice channel per server at a time. Big servers often have several rooms going at once.

So Voxy has three helper bots, Voxy 2, 3 and 4. If Voxy is busy in another room, /join offers to add a helper, which gives up to 4 rooms read in parallel in one server. Settings and the server's credit balance are shared between them.

4. Releases used to kick everyone out of the room

This is the one that cost me the most sleep. Early on, every deploy or host restart disconnected every room, and people had to /join again. As a solo developer I ship several times a day, so this happened a lot.

Two facts shaped the fix:

  • Discord keeps an orphaned bot in its voice channel for about 118 seconds after the process disappears.
  • The bot's own voice state, as Discord reports it on a fresh READY, is the source of truth. Not my database.

Short restarts: hand off instead of leaving. On a release (and on a crash, which takes the same path) the bot stops speech, settles billing, closes its stores and exits without sending a voice leave or closing the gateway politely. The supervisor starts the new process within seconds, and on READY it re-adopts every room that Discord still lists for it, well inside the 118-second window. To the people in the room, the bot never left.

Longer outages: rejoin, carefully. Each live session is stored as one row (voice channel, chat channel being read, last_seen_at updated every 60 seconds). On startup, after re-adopting what Discord still lists, the bot rejoins a stored session only if:

  • it's at most 30 minutes old,
  • the room still has a real (non-bot) member,
  • the bot can still connect and speak there.

Rejoins are spaced 1.5 seconds apart. The row is deleted the moment a session ends on purpose: /leave, someone disconnecting the bot, the channel being deleted, the bot being removed from the server. That last part matters most. A bot that "helpfully" walks back into a room it was just kicked out of is worse than one that never reconnects.

Messages typed while the bot is reconnecting are held and read in order once audio is back, as long as they're still under 30 seconds old.

If you build a voice bot: trust the voice state Discord sends you about your own bot. Saved state is a hint, never an instruction to join.

5. Money, without charging anyone wrongly

TTS is billed per character, so it can't be free forever. Right now the standard voice is free for 20,000 characters per server per day, and HD voices use credits from one-time packs. No subscription.

The part I spent the most care on is the ledger. Credits are reserved when synthesis starts and only charged when it finishes. The easy-to-miss case is a message cancelled mid-synthesis while the provider request is still in flight. The reservation isn't released until that request actually settles, so I never refund credits for audio the provider still bills me for. And the browser never adds credits by itself. Only the backend, after the payment is confirmed.

Stack

  • Bot: TypeScript, discord.js 14, @discordjs/voice, on Node.
  • Monorepo: Bun workspaces. A shared core package holds product facts, plus contract, db (Drizzle + SQLite), bot, server and client packages.
  • Website: React, static build.
  • Backend: Docker on a single VPS.

One rule I'd recommend to anyone shipping alone: every public number lives in one file. Free characters per day, number of voices, bots per server, the word limit. A test fails if any sentence on the website, the help pages or the changelog disagrees with that file. So the website can't quietly drift away from what the bot actually does.

Where it is now

Voxy has 10 Vietnamese voices (each member can pick their own with /myvoice), speed from 0.5x to 2x, and as of today it's in about 24 servers. Small, and I'm fine with that. It's built for one language and one kind of room.

If you run a Vietnamese Discord server, the full command list is at voxybot.app/commands. And if you build voice bots in other languages, I'd like to hear how you handle shorthand and slang, or what broke for you once real users arrived. Comments are open.

Top comments (0)