A self-hosted SIP to Telegram voice gateway. SIPgram registers on your FreePBX/Asterisk as ordinary extensions and turns calls into regular Telegram calls: peer to peer voice calls, not group chats and not voice messages. One gateway account serves every user at the same time.
- An extension rings, and the gateway calls its owner in Telegram after sending the caller name and number to the chat.
- Send a number to the chat, and the gateway calls you back and dials it through the PBX.
- Call the gateway account yourself, and it dials the default destination (an IVR, a receptionist) or the last number you sent.
- During a call: DTMF, a second call with the first one on hold (the PBX plays music), switching, transfer, automatic reconnect when the Telegram call drops.
- Routing, recording, queues, follow-me and CDR stay on the PBX. To Asterisk this is just a set of SIP phones.
- Call recordings arrive in the chat as a voice message and are never written to disk.
- Opus at 48 kHz on the SIP side, so wideband audio survives all the way from Telegram to the extension.
- Conference through the PBX, HTTP API for CRM systems, admin alerts, several gateway accounts.
- Incoming call screening: work hours, quiet hours, blacklist, whitelist, forwarding of filtered calls back to the PBX.
- Telegram group call: the other party adds people right in the call, or
/groupmoves the conversation into a group voice chat or a Telegram conference call that needs no group. The PBX party stays in the conversation. - Optional bot with buttons: DTMF keypad, hold and switch, transfer, call back. A call takes one card message in the chat: incoming, in progress, outcome.
- Any Telegram client can answer, Telegram Web included.
- Runs on Linux (Docker or native) and on Windows, with no compilation:
pip installis enough.
PSTN / SIP trunk ──► FreePBX ──SIP/RTP (Opus, G.711)──► SIPgram ──Telegram P2P (MTProto)──► users' Telegram
Asterisk Python: own SIP stack + NTgCalls + Telethon
- Requirements
- Quick start with Docker
- Quick start without Docker
- Telegram side
- PBX side
- Configuration
- Using it
- Several users
- HTTP API
- Windows
- Diagnostics
- Tests
- How it is built
- Limitations
- License
- A separate Telegram account for the gateway, on any number that can receive an SMS. Telegram cannot call
itself, so the gateway needs an account of its own. One account is enough for everybody: it holds up to
telegram.max_callsconversations in parallel. api_idandapi_hashfrom https://my.telegram.org (API development tools).- An extension with a password on the PBX, one per user.
- The Telegram id of each user (ask
@userinfobot) or their @username. - Optionally a bot token from
@BotFatherfor the buttons. - Linux with glibc 2.28 or newer (Debian 10+, Ubuntu 20.04+, not Alpine) or Windows 10/11 x64, Python 3.10 to 3.14. Or Docker, which covers the Linux case.
git clone https://github.com/vasmarfas/sipgram.git && cd sipgram
mkdir -p config sessions
sudo chown -R 1000:1000 config sessions
cp config.example.yaml config/config.yaml
cp .env.example .env
docker compose build
docker compose run --rm sipgram login
docker compose run --rm sipgram whoami
docker compose run --rm sipgram check
docker compose up -d && docker compose logs -fFill in api_id, api_hash, users, sip.server and sip.accounts in config/config.yaml, and the extension
passwords in .env, before the login step. login asks for the phone number of the gateway account, the code
from the SMS and the 2FA password, and has to be done once. whoami shows the session, the resolved users and the
bot; check registers every extension once.
The container runs as uid 1000, so the two bind-mounted directories have to belong to it, hence the chown.
Without it the first login fails with Permission denied when it tries to write the session file.
A prebuilt image is published on every push to the default branch, for linux/amd64 and linux/arm64:
docker pull ghcr.io/vasmarfas/sipgram:latestTo use it instead of a local build, replace build: . with image: ghcr.io/vasmarfas/sipgram:latest in
docker-compose.yml.
The container runs with network_mode: host, so the SIP ports of the extensions (5070, 5071, and so on) and the
RTP range (rtp_port_min to rtp_port_max, 40000 to 40200/udp by default) have to be reachable from the PBX.
docker compose ps shows the health state, which comes from sipgram status.
python -m venv venv
. venv/bin/activate
pip install -e .
cp config.example.yaml config.yaml
export SIP_PASSWORD_491=...
sipgram login
sipgram whoami
sipgram check
sipgram runOn Windows, activate the environment with venv\Scripts\activate and set the password with
$env:SIP_PASSWORD_491 = "..."; see Windows for the rest of the platform notes.
Use sipgram -c path/to/config.yaml <command> when the config is not in the current directory, and
sipgram -v --sip-trace run for a verbose log.
| Command | What it does |
|---|---|
sipgram run |
Start the gateway. |
sipgram login [gateway] |
Interactive Telegram login, once per gateway account. |
sipgram whoami |
Show the sessions, resolve the configured users, check the bot. |
sipgram check [account] |
Register each extension once and report the result. |
sipgram doctor [--offline] |
Check config, directories, ports, DNS, registration, sessions, libopus, schedule. |
sipgram status |
Health of a running gateway, read from the state file. Exit code 0 means healthy. |
- Every user adds the gateway account to their contacts, or allows calls from everyone (Settings, Privacy and
Security, Calls). Otherwise Telegram rejects the call with
USER_PRIVACY_RESTRICTED: the PBX gets 480 and the user gets a message in the chat saying what to change. - The gateway adds the users to its own contacts so that it can accept their calls.
- Give the gateway account a recognisable name and avatar, for example "Office". Telegram calls will show up as coming from it.
- If
bot_tokenis set, each user presses Start in the bot once. After that notifications and buttons come from the bot while the calls themselves still come from the gateway account. Without a bot everything works through text commands. - Do not use a freshly registered account for the gateway. Telegram restricts new accounts.
To the PBX the gateway is a set of ordinary SIP phones: every extension in sip.accounts registers separately.
The instructions below are for FreePBX 15/16/17 (Asterisk 16 to 22, chan_pjsip). A plain pjsip.conf is at the end.
Applications, Extensions, Add Extension, Add New SIP [chan_pjsip] Extension.
| Field | Value |
|---|---|
| User Extension | the number, for example 491 |
| Display Name | the owner's name, shown on the PBX for calls coming from Telegram |
| Secret | a long password, the same one that goes into password or into .env as SIP_PASSWORD_491 |
On the Advanced tab:
| Field | Value | Why |
|---|---|---|
| Max Contacts | 1 | one registration per extension |
| Codecs (Allowed) | opus, ulaw, alaw, optionally slin16 |
the order sets the priority |
| DTMF Signaling | RFC 4733 | matches the default dtmf: rfc2833 |
| Direct Media | No | RTP always goes through Asterisk |
| Rewrite Contact | Yes | Asterisk answers to the real address and port of the gateway behind NAT |
| RTP Symmetric | Yes | RTP goes back where it came from |
| Force rport | Yes | |
| Qualify Frequency | 60 | OPTIONS pings, which SIPgram answers |
| Allow Transfer | Yes | needed for /transfer <number> (REFER) |
| Call Waiting | Yes | a second call reaches the gateway; with No the PBX answers busy itself |
| Music on Hold Class | any | this is what the other party hears while on hold |
Voicemail, Follow Me and ring time are up to you. They kick in when Telegram does not answer within
ring_timeout: the PBX gets 480 and continues down its own chain. Submit, then Apply Config.
In config.yaml:
sip:
server: pbx.example.com
accounts:
- {username: "491", password: "${SIP_PASSWORD_491}", user: 123456789}
- {username: "492", password: "${SIP_PASSWORD_492}", user: "@petya"}- Gateway next to the PBX, same network or same host: nothing else to configure. On a FreePBX host port 5060 is
taken by Asterisk, so the accounts use their own ports starting at
local_port: 5070. - Gateway behind NAT, PBX on the internet: Rewrite Contact and RTP Symmetric on the extension are usually enough.
Set
sip.public_ipif you want the address advertised explicitly. Forwardlocal_port/udp and thertp_port_mintortp_port_maxrange, or rely on symmetric RTP, since the gateway sends RTP first. - PBX behind NAT: Settings, Asterisk SIP Settings, NAT Settings, External Address and Local Networks. Without them the SDP from the PBX carries a private address and there is no audio.
- FreePBX firewall and fail2ban: put the gateway address into Connectivity, Firewall, Networks as Local or Trusted, otherwise it can get banned after a few re-registrations.
- Transport: UDP by default. For TCP or TLS enable the transport in Asterisk SIP Settings and set
transport: tcp|tlswithport: 5061for TLS. Usetls_verify: falsefor a self-signed certificate. - Legacy chan_sip listens on a different port (usually 5160 in FreePBX). The extensions in
sip.accountsmust live in the driver whose port is insip.port.
Point the calls at the user's extension the usual way:
- Connectivity, Inbound Routes, Set Destination, Extensions, 491;
- a Ring Group or Queue built from such extensions: Telegram calls every owner and the first one to answer takes the call, the PBX clears the rest;
- Follow Me on a primary extension with 491 added.
While Telegram rings, the PBX has 180 Ringing and plays its own ringback to the caller. A decline in Telegram becomes 603, busy becomes 486, no answer becomes 480. FreePBX handles all of them normally.
One extension for several people (a family, a duty shift): users: [id1, id2] with ring_all: true calls
everybody at once and the first to answer takes the call. Without ring_all only the first user in the list rings.
The gateway sends an INVITE to sip:<number>@<domain> as the user's extension, so Outbound Routes and the
extension permissions (context from-internal) apply as usual. The outbound caller ID is whatever the extension has.
The number you send goes through calls.dial_rules. By default spaces, brackets and dashes are stripped, a leading
+ is removed and 8XXXXXXXXXX becomes 7XXXXXXXXXX. Adjust them to your Outbound Routes.
The INVITE carries X-TG-User-Id and X-TG-User-Name, which are handy in the dialplan
(${PJSIP_HEADER(read,X-TG-User-Id)}) for CDR or routing.
All of it is plain SIP, and apart from Allow Transfer and Call Waiting nothing extra has to be enabled:
- hold is a re-INVITE with
a=sendonly, and Asterisk starts the extension's MOH class for the other party; - a second incoming call rings the same extension, the gateway answers 180 and waits
ring_timeoutseconds for the user to decide; /transfer <number>sends REFER, and Asterisk connects the other party itself and drops the gateway leg;/transferwith two calls bridges them inside the gateway, so both stay its channels and the CDR continues.
Add a shared account:
sip:
accounts:
- {username: "tg-trunk", password: "${SIP_PASSWORD_TRUNK}", shared: true}Calls arriving on it are routed by the dialled number (the user part of the Request-URI):
| Dialled | Reaches |
|---|---|
+79991234567 |
the Telegram user with that phone number, imported as a contact; the number must be visible |
tg#username |
that @username |
123456789 (6 digits or more) |
that numeric Telegram id |
The target must be listed in users. For the PBX to pass such a number through you need a trunk rather than an
extension: Connectivity, Trunks, Add Trunk, chan_pjsip.
- Trunk Name
sipgram; pjsip Settings, General: Authentication None, Registration None, SIP Server is the gateway address, SIP Server Port is thelocal_portof the shared account, Contextfrom-internal. - Advanced: From User
tg-trunk, codecs ulaw and alaw. - An Outbound Route with a dial pattern like
tg#.or+X.pointing at thesipgramtrunk.
Users without an extension of their own dial out through the shared account. The gateway takes new calls and SIP
MESSAGEs only from the sip.server address and the networks in sip.allow_from, and drops the rest with a warning
in the log. It still does not authenticate them, so do not expose its SIP ports to the internet without a firewall.
; pjsip.conf
[491]
type=endpoint
context=from-internal
disallow=all
allow=opus,alaw,ulaw
auth=491
aors=491
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
dtmf_mode=rfc4733
allow_transfer=yes
moh_suggest=default
callerid="John" <491>
[491]
type=auth
auth_type=userpass
username=491
password=A_VERY_LONG_PASSWORD
[491]
type=aor
max_contacts=1
qualify_frequency=60; extensions.conf
[from-internal]
exten => 491,1,Dial(PJSIP/491,40)
same => n,Hangup()The integration tests run against exactly this configuration, see tests/integration/asterisk/.
The annotated example is config.example.yaml. The minimum is:
telegram:
api_id: 12345
api_hash: "0123456789abcdef0123456789abcdef"
session: gateway
users:
- {id: 123456789, name: John}
sip:
server: pbx.example.com
accounts:
- {username: "491", password: "${SIP_PASSWORD_491}", user: 123456789}| Key | Meaning |
|---|---|
telegram.max_calls |
how many conversations the gateway account holds at once (10 by default) |
telegram.bot_token |
bot token for the buttons; empty means text commands only |
telegram.gateways |
additional gateway accounts; users[].gateway pins a user to one of them |
telegram.language, users[].language |
starting interface language (ru or en); each user switches it with /lang and the choice is remembered |
users[] |
id, @username or +phone, plus name and can_call: false for receive-only users |
sip.* |
defaults for every account: server, port, transport (udp/tcp/tls), codecs, RTP ports, public_ip, allow_from (addresses and networks besides sip.server that may place calls) |
sip.accounts[].user / users |
owners of the extension; the first one takes incoming calls, ring_all: true rings everybody and the first to answer wins |
sip.accounts[].shared |
shared trunk account: the PBX dials tg#username, +7999... or an id through it, and users without their own extension dial out through it |
sip.accounts[].ring_timeout |
how long Telegram rings before the PBX gets 480 and follows its own logic |
sip.accounts[].default_destination |
where to connect when the owner calls the gateway without sending a number |
calls.outgoing_mode |
callback (the gateway calls you first, then dials) or direct (dials immediately) |
calls.call_waiting |
a second incoming call during a conversation: ring and offer /switch; false answers busy |
calls.reconnect_timeout |
when Telegram drops, hold the other party and call back for this many seconds |
calls.dial_rules |
regex rewrites for the numbers you send |
calls.ringback |
ringback tone while the PBX dials: ru, us, none |
calls.record |
off, ask (turned on with /rec) or all; the recording is sent as a voice message and not stored |
calls.conference_extension |
ConfBridge room on the PBX, enables /conf |
calls.group_chat |
Telegram group whose voice chat /group uses; empty means a Telegram conference call with no group |
schedule.* |
work and quiet hours, blacklist and whitelist, what happens to a filtered call; empty by default, so nothing is filtered |
users[].schedule |
the same for one user; missing keys fall back to the global section |
notifications.admin |
who gets told about lost registrations, Telegram problems, start and stop |
api.* |
HTTP API for CRM systems and scripts, see HTTP API |
Any value of the form ${VAR} is taken from the environment, so secrets can stay out of the YAML (.env for
Docker). The old v0.1 config format (lines:) is still read and migrated on the fly, with a note in the log.
Everything is controlled from the chat with the gateway account, or from the bot, which offers the same actions as buttons.
| Action | How |
|---|---|
| Incoming call from the PBX | A message with the caller name and number, then a Telegram call. No answer within ring_timeout gives the PBX 480, so voicemail and follow-me work; declining gives 603. With the bot this is one card: incoming, in progress, and the outcome with a "Call back" button. |
| Call out | Send a number (+7 999 123-45-67, 8903..., 101, sip:user@host). The gateway calls you back and dials. While the PBX is dialling you hear a ringback or the early media of the PBX. With several extensions, /line shows and picks the line and 491: 101 dials through a specific one. |
| Call the gateway | Connects you to the default_destination of your line, or to the last number you sent within pending_number_ttl. Inviting the gateway into a Telegram group call works the same way: it joins and brings the number in. |
| DTMF | During a call send digits, * or # (1, 123#) or use /dtmf 123; the bot has a keypad. You hear the tone yourself and the gateway echoes the digits back as a message. By default the digits go out of band (RFC 4733), so a human on the other end does not hear them; set dtmf: inband if they should. Codes like *1... are intercepted by the PBX itself, see Diagnostics. |
| Feature codes | Outside a call send the code (*97, *43) and the gateway dials it as an ordinary number, with no in-call interception. |
| Second incoming call | You get a "second incoming call" message: /switch accepts it and puts the current one on hold with MOH from the PBX, /decline rejects it. /switch then toggles between the two, /hangup ends the current one and brings back the held one. |
| Second outgoing call | /call 102 during a conversation holds the current call and dials 102. Then /switch or /transfer. |
| Transfer | /transfer with two calls connects them to each other and drops you (attended). /transfer 102 transfers the other party to 102 through REFER (blind). |
| Hang up | Hang up in Telegram to end everything, or /hangup for the current call only. |
| Connection lost | If the Telegram call drops on its own, the other party goes on hold and the gateway calls you back for up to reconnect_timeout seconds. |
| Add people | Tap "Add participant" in the Telegram call. The call becomes a group call, the gateway moves into it together with the PBX party and the card shows the number of participants. When nobody but the gateway is left, the PBX leg is hung up. |
| Encryption key | The call card shows four emoji. If they match the emoji on the Telegram call screen, you and the gateway share the key and nobody sits in the middle. |
/cb, /redial |
Call the last caller back, repeat the last number dialled. |
/dnd |
Do not disturb: incoming PBX calls get busy. |
| Schedule | /schedule shows your work and quiet hours, the number lists and whether you are taking calls right now. It is configured in schedule and empty by default. |
/status, /history, /help |
State of the lines and calls, the last 10 calls, the command list. |
| Language | /lang en or /lang ru switches the interface for you only, and the choice survives restarts. |
| Recording | /rec toggles recording of the current conversation. When it stops, or the call ends, the recording arrives as a voice message. Nothing is written to disk. With calls.record: all every conversation is recorded. |
| Conference | /conf moves the active and held calls into the ConfBridge room on the PBX and joins you to it. /conf 101 also dials 101 into the same room. |
| Group call | /group moves the conversation into a Telegram group call: the voice chat of calls.group_chat, or a new Telegram conference call when no group is set. The PBX legs stay on the gateway and the Telegram call is replaced by an invitation. /group @petya also invites a person, /group 101 brings in an extension, /group stop ends everything and /hangup ends only your lines. |
FreePBX feature codes (## for transfer, *2 for parking) work through DTMF as well when they are enabled for the
extension.
Give each person their own extension in sip.accounts with a user:. Conversations of different users run in
parallel through one gateway account, and each user has one conversation plus a held call and a waiting one.
For one extension shared by several people use users: [...] with ring_all: true: everyone rings, the first to
answer takes the call and the rest see a missed call. To call arbitrary Telegram users from the PBX, use a shared
account, see Gateway mode.
Disabled by default. Turn it on in config.yaml:
api:
enabled: true
bind: 127.0.0.1
port: 8080
token: "${SIPGRAM_API_TOKEN}"
allow: ["10.0.0.0/8"]
rate_limit: 120With network_mode: host in Docker the port is opened on the host itself, so keep bind: 127.0.0.1 unless you
really want it exposed.
Every request except /api/health needs the token, as Authorization: Bearer <token> or X-Api-Token. It is
compared in constant time. A wrong token gives 401, an address outside allow gives 403 and going over
rate_limit gives 429. All refusals are logged with the client address.
| Method and path | What it does |
|---|---|
GET /api/health |
No token. {"ok": true, "accounts": {...}}, or 503 when an account is not registered. Suitable for monitoring. |
GET /api/status |
Gateway accounts with their load, SIP accounts with their registration state, all active conversations. |
GET /api/users |
Users: id, language, do-not-disturb, their lines and gateway account. |
GET /api/history?user=<id>&limit=20 |
Call history of one user. |
POST /api/calls |
Click to call. Body: {"user": 123, "number": "+7 999 ...", "account": "491"}, where account is optional. |
POST /api/calls/<user>/hangup |
End the user's current conversation. |
POST /api/calls/<user>/dtmf |
{"digits": "123#"} into the active conversation. |
POST /api/calls/<user>/transfer |
{"number": "101"}, transfer through REFER. |
POST /api/messages |
{"user": 123, "text": "..."}, a message to the user from the gateway. |
user is the numeric id or the same value as in users[].id.
curl -X POST http://127.0.0.1:8080/api/calls \
-H "Authorization: Bearer $SIPGRAM_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"user": 123456789, "number": "+7 999 123-45-67"}'From there it behaves like sending a number to the chat: the gateway calls the user in Telegram and connects them.
Response codes: 200 done, 400 bad body, 401 token, 403 address, 404 no such user, 409 impossible in the current state (no active call, line busy), 429 rate limit, 500 internal error, which is logged with a traceback.
SIPgram runs as an ordinary console application: Python 3.10 to 3.14 x64, pip install, no compilers, because the
ntgcalls wheel for Windows x64 is on PyPI. Tested on Windows 11 with Python 3.14.
winget install Python.Python.3.12
cd C:\sipgram
python -m venv venv
venv\Scripts\activate
pip install -e .
copy config.example.yaml config.yaml
$env:SIP_PASSWORD_491 = "secret"
sipgram login
sipgram check
sipgram runOn the first run Windows Firewall asks about python.exe. Allow it for private networks. If the PBX is on another
network, open local_port/udp and the rtp_port_min to rtp_port_max/udp range on the host.
To run it as a service, either use Task Scheduler (trigger At startup, action
C:\sipgram\venv\Scripts\sipgram.exe -c C:\sipgram\config.yaml run, "Run whether user is logged on or not",
passwords in system-level environment variables) or NSSM:
nssm install sipgram C:\sipgram\venv\Scripts\sipgram.exe -c C:\sipgram\config.yaml run, with a stdout file on
the I/O tab or logging.file in the config.
Platform notes:
timeBeginPeriod(1)is called at startup. Without it the 15.6 ms system timer breaks 20 ms RTP packetisation.- On shutdown the process ends through
TerminateProcess, because ntgcalls blocks a normal interpreter exit on Windows. The Telegram sessions and the SIP registration are closed properly before that: the de-registration is sent first. - Docker Desktop is not recommended for running the gateway on Windows:
network_mode: hostis not available there and RTP through its NAT is unreliable. Running natively is simpler.
sipgram doctor checks everything at once: config, directory permissions, SIP and RTP ports, DNS, registration on
the PBX, Telegram sessions, the bot, libopus and the schedule. Exit code 0 means nothing is broken. Use
--offline to skip the network checks, or docker compose run --rm sipgram doctor in Docker.
For a detailed log run sipgram -v --sip-trace <command>, or set logging.level: DEBUG and
logging.sip_trace: true. In Docker: docker compose logs -f.
registration failed: 401 Unauthorized or 403 Forbidden. Wrong username, password or auth_username,
the extension is not chan_pjsip, or the gateway address is banned by the FreePBX firewall (Connectivity, Firewall,
Intrusion Detection).
registration failed: 408 no response. UDP is not reaching the PBX: wrong server, port or transport, or a
firewall. Run sipgram -v --sip-trace check: a UDP send/receive error next to the outgoing REGISTER means the
packet never left the host.
Incoming calls do not arrive although registration works. The PBX sends the INVITE to the gateway's Contact.
Check asterisk -rx "pjsip show contacts". A private address there means Rewrite Contact should be on, or
sip.public_ip should be set.
No audio, or audio in one direction only.
- Nothing from Telegram to the phone: the PBX is not receiving our RTP, so either the SDP from the PBX has a private address (NAT Settings) or a firewall is in the way.
- Nothing from the phone to Telegram: our RTP port range is unreachable from the PBX, or
sip.public_ipis not set behind NAT. RTP Symmetric on the extension usually solves it, since the gateway sends RTP first. 488 Not Acceptable Herein the log means there is no common codec: enable opus or ulaw/alaw on the extension.
The call drops after about 30 seconds. The ACK is not getting through (no ACK for final response in the log),
which is normally a NAT problem with the address in Contact or Via.
USER_PRIVACY_RESTRICTED, 480 for the PBX. The user has calls restricted, and the gateway tells them so in the
chat. They should add the gateway account to their contacts or to the call exceptions.
INVITE from ... ignored in the log. The PBX sends calls from an address other than the one sip.server
resolves to (several interfaces, a cluster). Add that address or network to sip.allow_from.
cannot resolve user 123456789. The gateway has not seen this user yet. Send it any message from that account,
or list the user as @username or +phone.
FLOOD_WAIT_X or PEER_FLOOD. Telegram wants you to wait, or considers the activity spam. Use an account with
some history, call contacts only, do not run mass dialling.
The call is accepted but media goes CONNECTING and then FAILED. WebRTC/UDP is not getting to the Telegram relays. Check outgoing UDP from the host and any DPI or blocking of Telegram calls in your network.
The call connects but there is silence. The log ends with bridge stopped: sip->tg N frames, tg->sip M frames.
tg->sip 0 means NTgCalls is not producing frames; sip->tg 0 means there is no RTP from the PBX.
Delay or crackling. Raise calls.jitter_ms to 60 or 100 on a poor network between the gateway and the PBX, or
lower it to 20 for a local PBX.
"Access denied" in response to *1.... This is FreePBX, not the gateway: *1 during a call is reserved for
One Touch Record. Asterisk intercepts those two digits and, if on-demand recording is not allowed for the
extension, plays "Access denied" while the rest of the digits go through. Fix it in Applications, Extensions,
Recording, On Demand Recording, or in Admin, Feature Codes.
The digits are not heard by anyone but they do arrive. That is how dtmf: rfc2833 works, the same as on a real
SIP phone: the digit travels as a separate RTP event, IVRs see it and the person on the other end does not. Set
dtmf: inband on the extension if the tone itself has to be audible.
No buttons, everything comes from the gateway account. The bot writes only to users who pressed Start in it.
sipgram whoami shows its @name.
Recording does not start, recording is not available in the log. libopus is missing. The Docker image
installs libopus0; natively, apt install libopus0.
Calls do not come through although everything is registered. Check /schedule in the chat or sipgram doctor:
schedule.work_hours or quiet_hours are probably in effect. The time is local to the process, so set TZ in
docker-compose.yml, otherwise the container lives in UTC.
GROUPCALL_FORBIDDEN when starting a voice chat. The gateway account may not manage voice chats: give it the
"Manage video chats" admin right, or start the voice chat by hand and let the gateway join it.
A user gets busy (486) although they are free. Either telegram.max_calls is reached (gateway at capacity in
the log) or the user is in /dnd.
WinError 10048 on Windows. Another instance or a softphone is holding local_port or the RTP ports.
pip install -e ".[dev]"
pytest tests --ignore=tests/integration
cd tests/integration
docker compose up --build --abort-on-container-exit --exit-code-from tester
docker compose down125 unit tests, about 15 seconds: the SIP parser, digest against the RFC 2617 vector, SDP offer and answer, G.711 and Opus, RTP, the Ogg container, the config, the call logic on fake legs, the schedule and number lists, the Telegram call engine with conferences on a fake NTgCalls, localisation, the HTTP API and the state file housekeeping. Two of them run the real ntgcalls: two instances call each other inside the process over protocols 9 and 13, checking the key exchange, the emoji, audio both ways and the order of frames. They are skipped on Windows, where ntgcalls keeps the process from exiting.
24 end-to-end tests against andrius/asterisk (Asterisk 22.10) with the configs from tests/integration/asterisk/:
registration over UDP and TCP, wrong password, outgoing call to Echo() with a spectral check of the returned
tone, Milliwatt(), 486 Busy, 180 then answer, 183 with early media, CANCEL, BYE from the PBX, DTMF in both
directions, incoming calls through AMI Originate, hold and resume, REFER transfer and two simultaneous dialogs on
one registration. Both suites run in CI on every push.
sipgram/
__main__.py CLI: run / login / whoami / check / status / doctor
config.py YAML into dataclasses, ${ENV}, migration of the v0.1 format
gateway.py startup: gateway account, NTgCalls, bot, manager, state file for the healthcheck
manager.py CallManager: users, SIP accounts, parallel calls, hold/waiting/transfer, chat commands, call card
bridge.py CallBridge (Telegram to SIP: 20 ms ticker, jitter buffer, tones), SipSipBridge, GroupBridge
messages.py RU/EN strings, number and duration formatting
prefs.py per-user settings that survive restarts
alerts.py admin notifications with flap suppression
api.py HTTP API on aiohttp
history.py per-user call log with rotation
schedule.py schedule windows, number patterns, the screening decision
doctor.py the doctor command
sip/ own SIP stack: message parser, digest, SDP, codecs, libopus binding, RTP, RTCP, transports, account
tg/ Telethon account, NTgCalls engine, group calls and conferences, optional bot, notifications
audio/ PCM buffer, resampler, Ogg/Opus container, in-memory recorder, tone generator
A user has at most one Telegram call (UserState.tg). SIP legs attach to it: active (bridged to Telegram),
held (the PBX plays MOH) and waiting (a second incoming call still ringing). Transitions are serialised by
UserState.lock. Different users are fully independent: NTgCalls keys calls by peer id and SipAccount keys dialogs
by Call-ID, so parallel conversations need no extra synchronisation.
The SIP stack is a compact subset of RFC 3261 on asyncio, enough for the role of a phone behind a PBX: REGISTER with digest (MD5 and SHA-256, qop), INVITE/ACK/BYE/CANCEL in both directions, T1/T2 retransmissions for UDP, replies to OPTIONS/INFO/NOTIFY/UPDATE/MESSAGE, re-INVITE for hold and session timers, REFER with sipfrag NOTIFY, and UDP, TCP and TLS transports. It needs no compilation and behaves the same on Linux and Windows.
The Telegram side drives NTgCalls directly, with Telethon for MTProto, instead of going through pytgcalls. The
sequence is the same, plus phone.receivedCall, precise ring timeouts and explicit discardCall reasons, so the
PBX gets meaningful codes (486, 480, 603) and voicemail and follow-me behave correctly.
When the other party adds people, Telegram discards the private call with
phoneCallDiscardReasonMigrateConferenceCall. The gateway joins the conference by the slug in that reason, taking
the media of the private call along, and from then on keeps the conference's E2E chain itself: it applies blocks,
answers subchain requests and tells NTgCalls which SSRC belongs to whom. A messageActionConferenceCall invitation
from a user is handled as an incoming call.
NTgCalls 3 runs its calls on a thread pool, and two 10 ms frames handed over back to back could overtake each
other: on Windows that happened to 3 to 4% of frames. Frames therefore go through FrameSender strictly one at a
time.
Audio needs no resampling in the common case. The external frame rate of NTgCalls is set to the SIP codec rate, and
WebRTC inside NTgCalls resamples to Opus 48 kHz. Telegram to SIP is packetised by a 20 ms ticker with a jitter_ms
buffer; SIP to Telegram is handed over in 10 ms frames as the RTP arrives, and the jitter is absorbed by NetEq on
the client.
Call recording is encoded to Opus as it goes (20 kbit/s, about 2.5 KB/s) and kept in an Ogg container in memory, so a ten minute conversation costs about one and a half megabytes. When the bridge stops, the file is sent to the chat and dropped.
- One conversation per user, plus a held and a waiting call. Parallelism happens between users.
- How many simultaneous calls one Telegram account really sustains is up to Telegram. Keep
max_callssmall on the first runs. - Voice only. SIP codecs: Opus, G.711, L16. TLS signalling is supported, SRTP is not.
- A group voice chat needs the gateway account to be a member with the right to start one; without
calls.group_chat,/groupstarts a Telegram conference call instead. While the conversation is in a group call, DTMF cannot be sent from the chat for that leg. - Telegram conference calls are verified on a fake NTgCalls and against the protocol; there has been no live run with several participants yet.
- On Windows the process exits through
TerminateProcess, a limitation of the ntgcalls library.
PolyForm Noncommercial License 1.0.0 plus NOTICE.md, which adds the required copyright notice and the restrictions on redistributing forks and on the project name. The source is public but this is not an OSI-approved license: personal, hobby, educational and other noncommercial use is free, while commercial use needs a separate agreement. Pull requests are welcome.