Find a file
2026-06-14 02:36:51 -04:00
.github/workflows slopfixing so i can host it easy from ghcr 2026-06-13 22:09:30 -04:00
pjsua chore: Release 2026-05-25 10:15:45 -07:00
sipcord-bridge auto answer 2026-06-14 02:36:51 -04:00
wav big one 2026-03-20 16:08:41 -07:00
.dockerignore slopfixing so i can host it easy from ghcr 2026-06-13 22:09:30 -04:00
.env.example trying to get intercom working 2026-06-14 02:27:47 -04:00
.gitignore big one 2026-03-20 16:08:41 -07:00
.gitmodules slopfixing so i can host it easy from ghcr 2026-06-13 22:09:30 -04:00
Cargo.lock lock 2026-05-25 10:15:35 -07:00
Cargo.toml slopfixing so i can host it easy from ghcr 2026-06-13 22:09:30 -04:00
config.toml big one 2026-03-20 16:08:41 -07:00
Dockerfile fix: collect pjproject headers in Docker build 2026-03-22 10:21:11 -07:00
LICENSE.md more formatting 2026-03-20 16:14:57 -07:00
README.md auto answer 2026-06-14 02:36:51 -04:00

SIPcord Bridge

This is a slice of the code that powers SIPcord that you can use to self host something similar. It's not the full SIPcord package but rather the core functionality used in SIPcord with ways to build your own backend adapter. SIPcord itself uses this as a component of the full build so the code is the same that runs on the public bridges.

This means you have to build the call routing backend yourself. I am including a static-router backend which you can use to map extensions in a TOML file like this

[extensions]
1000 = { guild = "123456789012345620", channel = "987654321012345620" }
2000 = { guild = "123456789012345620", channel = "111222333444555620" }

but if you want more fancy routing you have to build it. You can easily use sipcord-bridge as a library and provide your own routers by implementing the Backend trait.

This was written a mix between myself and claude, sure, some of it's big slop but the parts I care about are not.

Can you help me set this up?

No. I am not providing support for this as my goal is to run sipcord.net, not support self hosting. If you want to run this self hosted, feel free to use this code but you are on your own here.

I have a feature request!

PR's welcome. No really, feel free to implement it and contribute.

Self-host setup notes

These notes cover the static-router Docker setup. The bridge maps inbound SIP extension digits to Discord voice channels, and can also place outbound calls from Discord into a PBX extension when outbound SIP target settings are enabled.

Prerequisites

  • A Discord bot with voice permissions. Create one at https://discord.com/developers/applications, enable the Message Content intent, and grab the bot token.
  • A Docker host reachable from your PBX or SIP clients. SIP uses port 5060 and RTP uses UDP 10000-15000 by default.
  • Docker (recommended) or Rust nightly toolchain if building from source.

1. Invite the bot to your server

Use this URL format, replacing YOUR_CLIENT_ID:

https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&scope=bot&permissions=36700160

The bot needs Connect + Speak permissions in voice channels.

2. Get Discord channel IDs

Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Right-click a voice channel and click "Copy Channel ID". Do the same for the server (guild) by right-clicking the server name.

3. Create the dialplan

Create a dialplan.toml mapping extensions to Discord channels:

[extensions]
1000 = { guild = "123456789012345678", channel = "987654321012345678" }
2000 = { guild = "123456789012345678", channel = "111222333444555666" }

Each extension is what you'll dial from your SIP phone. Pick any numbers you like.

Create a directory for your deployment:

mkdir sipcord && cd sipcord

Create a .env file:

DISCORD_BOT_TOKEN=your_bot_token_here
SIP_PUBLIC_HOST=192.168.0.100
RTP_PUBLIC_IP=192.168.0.100
DISCORD_OUTBOUND_SIP_HOST=192.168.0.25
DISCORD_OUTBOUND_SIP_PORT=5060
DISCORD_OUTBOUND_SIP_TRANSPORT=udp
DISCORD_OUTBOUND_EXTENSION_PREFIX=

Set both IPs to the address other SIP devices use to reach the bridge. For example, if FreePBX is 192.168.0.25 and this container runs on an OMV host at 192.168.0.100, use 192.168.0.100. Do not use 0.0.0.0 here; this value is advertised in SIP Contact/SDP headers, and callers must be able to route back to it.

Set DISCORD_OUTBOUND_SIP_HOST to the PBX or SIP server that should receive Discord-originated extension calls. For a FreePBX box at 192.168.0.25, that means DISCORD_OUTBOUND_SIP_HOST=192.168.0.25.

Discord-originated calls dial the requested extension directly by default. The bridge also attaches common auto-answer headers (Call-Info: <uri>;answer-after=0 and Alert-Info: <http://127.0.0.1>;info=Ring Answer) to Discord-originated outbound calls, and appends intercom=true to the SIP URI, so auto-answer phones can behave more like FreePBX intercom targets.

Create a docker-compose.yml:

services:
  sipcord-bridge:
    image: ghcr.io/legop3/sipcord-bridge:latest
    container_name: sipcord-bridge
    restart: always
    network_mode: host
    env_file:
      - .env
    volumes:
      - ./dialplan.toml:/app/dialplan.toml:ro
      # Uncomment to persist data across restarts:
      # - ./data:/var/lib/sipcord

Place your dialplan.toml in the same directory, then:

docker compose up -d
docker logs -f sipcord-bridge

You should see it load the dialplan and start listening.

For a LAN deployment on an OMV host at 192.168.0.100, startup should include lines like:

Static router running on 192.168.0.100:5060
Public host Contact rewriting enabled: 192.168.0.100:5060
Account RTP config: ... public_addr=192.168.0.100

Images are published by GitHub Actions to ghcr.io/legop3/sipcord-bridge on pushes to master, version tags like v2.1.2, and manual workflow runs. If the package is private, make it public in the GitHub package settings or log in to GHCR from your OMV host before pulling.

4b. FreePBX trunk example

Create a PJSIP trunk that points at the Docker host running the bridge. For example, if FreePBX is 192.168.0.25 and the bridge container is on 192.168.0.100, the trunk should point at 192.168.0.100.

PJSIP trunk, General:

Trunk Name: sipcord
SIP Server: 192.168.0.100
SIP Server Port: 5060
Authentication: Outbound
Registration: None
Username: sipcord
Secret: any-random-string

PJSIP trunk, Advanced:

Client URI: sip:sipcord@192.168.0.100:5060
Server URI: sip:192.168.0.100:5060
From Domain: 192.168.0.100
Contact User: sipcord
Transport: UDP
Direct Media: No
RTP Symmetric: Yes
Force rport: Yes
Rewrite Contact: Yes

The bridge challenges inbound SIP requests, but the static router does not make authorization decisions from the username/password. Configure outbound credentials in FreePBX so it can answer the SIP digest challenge; the bridge routes by the dialed extension in dialplan.toml.

Create an outbound route such as:

Route Name: sipcord
Trunk Sequence: sipcord
Dial pattern prefix: 8
Dial pattern match: 1101

With that route, dialing 81101 from a FreePBX extension sends 1101 to the bridge, which matches:

[extensions]
1101 = { guild = "668249361339383808", channel = "931737080176979968" }

To debug routing from FreePBX:

asterisk -rvvv
pjsip set logger host 192.168.0.100

You should see an INVITE sip:1101@192.168.0.100:5060, followed by the digest challenge, a second INVITE with auth, a 200 OK, and an ACK. If the call ends after about 32 seconds, check that SIP_PUBLIC_HOST and RTP_PUBLIC_IP are set to the bridge host address, not the FreePBX address and not 0.0.0.0.

4c. Discord -> extension calling

If DISCORD_OUTBOUND_SIP_HOST is set, the bot registers a /call slash command in each guild it is connected to.

Usage:

/call extension:1101

Behavior:

  • The user running /call must already be in a Discord voice channel.
  • The bot uses that voice channel as the bridge destination.
  • The bridge dials the requested extension through the configured PBX target.
  • It dials the requested extension directly, for example sip:1101@192.168.0.25:5060;transport=udp.
  • Discord-originated outbound calls also include auto-answer headers and append intercom=true to the SIP URI so phones configured for that behavior can answer immediately.
  • When the SIP side answers, the phone call is connected to the Discord voice channel where the command was run.

Current scope:

  • /call is implemented for the static self-host backend.
  • It dials a configured PBX/SIP host by extension.
  • It does not yet include a Discord /hangup command or rich status updates back into Discord after the initial slash command reply.

4d. Build from source

Requires Rust nightly (for portable_simd) and system dependencies for pjproject (OpenSSL, Opus, libtiff, etc). See the Dockerfile for the full list.

cargo run --release -p sipcord-bridge

The binary reads config.toml from the working directory (or CONFIG_PATH), the dialplan from ./dialplan.toml (or DIALPLAN_PATH), and sound files from ./wav/ (or SOUNDS_DIR).

5. Configure a direct SIP phone

Point your SIP client at the bridge host on port 5060. The static router routes by dialed extension after the SIP digest handshake.

Example Oink (or any softphone) setup:

  • SIP Server: 192.168.0.100
  • Port: 5060
  • Transport: UDP
  • Username/Password: anything

Dial 1000 (or whatever you put in dialplan.toml) and you should hear the bot join the Discord voice channel.

Environment variables reference

Variable Default Description
DISCORD_BOT_TOKEN (required) Discord bot token
SIP_PUBLIC_HOST (required) Routable IP/hostname advertised in SIP Contact headers
SIP_PORT 5060 SIP listening port
RTP_PORT_START 10000 Start of RTP port range
RTP_PORT_END 15000 End of RTP port range
RTP_PUBLIC_IP (local address if unset) Routable IP advertised in SDP for RTP media
DISCORD_OUTBOUND_SIP_HOST (disabled if unset) PBX/SIP host used by Discord /call
DISCORD_OUTBOUND_SIP_PORT 5060 Port for Discord-originated outbound SIP calls
DISCORD_OUTBOUND_SIP_TRANSPORT udp Transport for Discord-originated outbound SIP calls: udp, tcp, or tls
DISCORD_OUTBOUND_EXTENSION_PREFIX "" Optional prefix prepended before the requested extension for Discord-originated calls
CONFIG_PATH ./config.toml Path to config.toml
DIALPLAN_PATH ./dialplan.toml Path to dialplan.toml
SOUNDS_DIR ./wav Path to sound files directory
DATA_DIR /var/lib/sipcord Persistent data directory
DEV_MODE false Enable dev mode logging
RUST_LOG sipcord_bridge=info,pjsip=warn Log level filter

NAT / Firewall notes

SIP_PUBLIC_HOST is not a bind-all setting. It is written into SIP headers, so it must be the address peers should call back. On a LAN, use the Docker host's LAN IP. Across NAT, use the public IP or hostname.

If your server is behind NAT, you need to:

  • Forward UDP port 5060 (SIP signaling)
  • Forward UDP ports 10000-15000 (RTP media)
  • Set SIP_PUBLIC_HOST to your public IP
  • Set RTP_PUBLIC_IP to the public RTP address

For servers with both a public and private interface (e.g. behind a load balancer), you can set SIP_LOCAL_HOST and SIP_LOCAL_CIDR so local clients get the private IP in Contact headers:

SIP_LOCAL_HOST=192.168.1.100
SIP_LOCAL_CIDR=192.168.1.0/24

Fax support

The bridge can receive faxes (both G.711 passthrough and T.38 UDPTL). Received faxes are demodulated via SpanDSP and posted as PNG images to a Discord text channel. To set up fax, add a mapping with a text channel ID in your dialplan — the bridge routes faxes to text channels and voice calls to voice channels automatically.

Acknowledgements

License

Code is AGPLv3

Dusthillguy track is whatever dusthillguy wishes