Answer an Instagram voice message with a voice message. Your customer sends a voice note, and about ten seconds later they get a spoken answer back, in their own language, from your own business knowledge.
Persian is the first language it was built for, so the text handling is tuned for Persian, but the pipeline itself is language neutral.
Text to speech does not read what you wrote. It reads what it thinks you wrote, and money is where it goes wrong.
Write a price the normal way and this is what the customer hears:
| written | heard | off by |
|---|---|---|
۱٬۴۰۰٬۰۰۰ تومان |
"1400 thousand Toman" | 1000x |
۱۶٬۰۰۰٬۰۰۰ تومان |
"sixteen thousand" | 1000x |
هفده میلیون (seventeen million) |
"seven million" | 10x |
Those are real transcripts of real audio that real customers received. The bug is silent: the text log looks perfect, the audio is wrong, and you only find out when someone arrives expecting a price that was a thousand times lower.
One shape survives. Digits plus the unit word:
python -m ivr norm "قهوه ۱٬۴۰۰٬۰۰۰ تومان و کیک ۸۸۰٬۰۰۰ تومان است."
# قهوه ۱ میلیون و ۴۰۰ هزار تومان و کیک ۸۸۰ هزار تومان است.Eight attempts out of eight came back correct in that shape. Phone numbers are left alone, because a bare digit run is only rewritten when a currency word follows it and phone numbers start with a zero.
No packages to install. Python 3.9 or newer, standard library only.
git clone https://github.com/ssepehrnoush/insta-voice-reply
cd insta-voice-reply
python -m ivr test-speechThat last command needs no API key and no network:
speech normaliser: 13 of 13 correct
cp config.example.json config.json # edit it, no secrets in here
export IVR_API_KEY=... # your model provider key
export IVR_PAGE_TOKEN=... # Instagram page access token
export IVR_VERIFY_TOKEN=... # any string, must match the dashboard
export IVR_APP_SECRET=... # from the Meta app, signs the webhook
python -m ivr servePoint the Meta webhook at https://your-domain/… in front of the port this
binds to, subscribe the messages field, and send a voice note from a second
account.
Everything below was learned by breaking it in production first.
1. The audio format is not negotiable. Instagram rejects OGG and Opus on
the send endpoint with This attachment format is not supported. Sixteen
consecutive failures over ten days, silent because the code fell back to text
and logged one line. AAC in an MP4 container, served as a .m4a URL, works.
It makes sense in hindsight: Instagram hands you voice notes as m4a itself.
2. Meta downloads the file from you. The answer has to sit on a public URL
that returns 200 to a stranger. Use an unguessable filename and delete it on a
timer, because a voice answer says out loud what the customer asked about.
python -m ivr sweep does the deleting.
3. Polling cannot replace the webhook. The conversation listing marks a voice note as an unsupported attachment with no URL. There is no second route to the audio. If your events never arrive, the cause is almost always that the app is still in development mode, where the handshake succeeds and the dashboard looks green but nothing is delivered.
4. Answer Meta before you do the work. Anything slow inside the request gets the delivery retried, and the same voice note arrives two or three times. This project acknowledges first, then works, and keeps a list of answered message ids so a retry cannot produce a second answer.
| It does | It never does |
|---|---|
| Reads voice notes delivered to your webhook | Scrapes, logs in as a user, or automates the app |
| Sends messages as your professional account | Follows, likes, or posts anything |
| Downloads the incoming audio to transcribe it | Stores the customer's audio after answering |
| Writes what it sent to a local JSONL ledger | Sends any data anywhere except your model provider and Meta |
| Answers only when a guard allows it | Replies to a thread a human is already handling |
A bot with no brakes is worse than no bot. These are on unless you turn them off in config.
- Human hold. If a person from your side replied in that thread in the last 30 minutes, the bot stays quiet.
- Blocklist. Accounts the bot must never answer: colleagues, suppliers, competitors.
- Per run cap. Each voice answer costs three model calls and several seconds. Past the cap it sends text instead of nothing.
- Duplicate suppression. Message ids are remembered, written atomically, so a webhook retry never doubles an answer.
- Signature verification. Every webhook body is checked against
X-Hub-Signature-256in constant time, with support for two secrets at once so you can rotate without downtime.
Two ways exist, and they are not equal. Diacritics work, and the engine honours them: a single word carrying a damma came out exactly as long as the unambiguous spelling, while the bare word was shorter, which is the audible signature of the wrong vowel. But across a full sentence the diacritics slowed the reading by about a third, 8.2 seconds against 6.1 for the same sentence.
So where an unambiguous spelling exists, use the spelling:
{ "spellings": { "ربات": "روبات" } }The speech normaliser has no dependency on Instagram or on any model, so it is useful anywhere you hand Persian text to a TTS.
from ivr.speech import say_numbers, for_speech
say_numbers("۱٬۴۰۰٬۰۰۰ تومان") # ۱ میلیون و ۴۰۰ هزار تومان
for_speech(text, spellings={"ربات": "روبات"}, cap=1400)- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m unittest discover -s tests -v
- run: python -m ivr test-speech63 tests, no network, no skips. The workflow in this repository fails the build if any test reports as skipped, because a skipped test that counts as green is how you end up shipping code that never ran.
- No outreach. It answers people who wrote to you. It will not send the first message, and there is no bulk sending anywhere in the code. That is both a product choice and the line between a helpful bot and a spam tool.
- No booking, no payments, no order changes. It answers questions. Any action that costs money or time belongs to a human.
- No conversation memory across sessions. History is passed in by the caller if you want it. Nothing is persisted except the ledger of what was sent and the list of answered ids.
- No multi account management. One business, one token. Running it for clients is a different product with different problems.
- No medical, legal, or financial advice. If your knowledge file contains that kind of content, the honesty of the answers is your responsibility, not the library's.
- No model shipped. You bring an OpenAI compatible endpoint. Any provider
with
/audio/transcriptions,/chat/completions, and/audio/speechworks, including a self hosted one.
Three POST requests do not need a package. On a system Python where you cannot install wheels freely, an import that breaks at 3am costs more than the convenience saved, and a bot answering customers should have as few moving parts as possible.
Two conventions, both there for a reason.
Persian literals inside ivr/speech.py and ivr/guards.py are written as
\uXXXX escapes, and CI fails the build if a raw one appears outside a
comment. Arabic kaf and Persian kaf are different code points that look
identical in an editor, and the zero width non joiner is invisible, so without
escapes a reviewer cannot see what a regex really matches.
The tests, the example config, and the table behind ivr test-speech keep
plain Persian on purpose. Those are the parts a human reads to understand what
the tool does, and a wrong character there shows up as a failing test rather
than as a silent production bug.
MIT