Advanced

gortc voice integration

gortc is a sister project that handles the WebRTC half of Telegram voice calls in pure Go. Paired with gogram on the signalling side, you get a complete native voice-call stack with no CGo and no external SFU.

What gortc is

gogram speaks MTProto, which means it can signal a group call into existence, announce participants, route SDP offers and answers, and mute/unmute. But signalling is only half the protocol. The actual audio travels over a separate WebRTC path with Opus encoding and SRTP encryption.

That second half lives in github.com/AmarnathCJD/gortc. It is a pion-based WebRTC stack tuned for Telegram's group-call SFU: it negotiates the right ICE candidates, handles the Opus codec, mixes streams, and feeds you decoded PCM frames if you want to do anything with the incoming audio.

The two libraries are designed to work together. gortc takes a *telegram.Client as input and uses it for every signalling call; you keep using gogram for everything that is not audio.

Adding gortc to a gogram project

go get github.com/AmarnathCJD/gortc
go get github.com/AmarnathCJD/gortc

Pure Go — no CGo, no system libraries. The build cost is real (gortc depends on pion which is a fairly large module) but ship-wise it is the same single-binary story as a plain gogram bot.

Wiring the two libraries

import (
	"github.com/amarnathcjd/gogram/telegram"
	"github.com/AmarnathCJD/gortc"
)

client, _ := telegram.NewClient(telegram.ClientConfig{
	AppID:   12345,
	AppHash: "...",
	Session: "userbot.session",
})
client.Connect()
client.Login("+15551234567")

// gortc takes a gogram client and uses it to drive the
// signalling layer of every call.
rtc := gortc.New(client)
import (
	"github.com/amarnathcjd/gogram/telegram"
	"github.com/AmarnathCJD/gortc"
)

client, _ := telegram.NewClient(telegram.ClientConfig{
	AppID:   12345,
	AppHash: "...",
	Session: "userbot.session",
})
client.Connect()
client.Login("+15551234567")

// gortc takes a gogram client and uses it to drive the
// signalling layer of every call.
rtc := gortc.New(client)

gortc.New wraps the gogram client. Internally it registers an update handler for updateGroupCallParticipantsand friends so it stays in sync with the call's participant list without you doing anything. From here on, signalling goes through client and audio goes through rtc.

Joining a group call

// gogram creates / queries the call object via MTProto:
call, err := client.StartGroupCall("@my_group", &telegram.StartGroupCallOptions{
	Title: "Voice room",
})
if err != nil { log.Fatal(err) }

// gortc handles the WebRTC negotiation and SRTP transport:
session, err := rtc.JoinGroupCall(ctx, call, &gortc.JoinOptions{
	Muted: false,
	Video: false,
})
if err != nil { log.Fatal(err) }
defer session.Close()
// gogram creates / queries the call object via MTProto:
call, err := client.StartGroupCall("@my_group", &telegram.StartGroupCallOptions{
	Title: "Voice room",
})
if err != nil { log.Fatal(err) }

// gortc handles the WebRTC negotiation and SRTP transport:
session, err := rtc.JoinGroupCall(ctx, call, &gortc.JoinOptions{
	Muted: false,
	Video: false,
})
if err != nil { log.Fatal(err) }
defer session.Close()

Internally the join flow does:

  1. gortc generates a WebRTC SDP offer.
  2. gortc asks gogram to send phone.joinGroupCall with that offer.
  3. The server responds with an SDP answer and a participant list.
  4. gortc applies the answer, ICE negotiates, the audio path opens.
  5. The returned *Session is your handle for the rest of the call.

Playing audio

// gortc reads Opus frames from anything that exposes them.
// Easiest path: a local file decoded on the fly via ffmpeg.
f, _ := os.Open("./hello.opus")
defer f.Close()

if err := session.Play(ctx, f); err != nil {
	log.Println("playback ended:", err)
}
// gortc reads Opus frames from anything that exposes them.
// Easiest path: a local file decoded on the fly via ffmpeg.
f, _ := os.Open("./hello.opus")
defer f.Close()

if err := session.Play(ctx, f); err != nil {
	log.Println("playback ended:", err)
}

session.Play takes any io.Reader of Opus frames and pushes them into the outgoing RTP stream. For other formats, run them through ffmpeg first (ffmpeg -i in.mp3 -c:a libopus -frame_duration 20 -f opus -) and pipe the output into session.Play; the function blocks until the source is drained.

Receiving audio

// Subscribe to decoded PCM frames from every participant.
sub := session.Subscribe()
defer sub.Close()

for frame := range sub.Frames() {
	fmt.Printf("user %d sent %d samples\n", frame.UserID, len(frame.PCM))
	// pipe to a transcript service, mix down, archive, etc.
}
// Subscribe to decoded PCM frames from every participant.
sub := session.Subscribe()
defer sub.Close()

for frame := range sub.Frames() {
	fmt.Printf("user %d sent %d samples\n", frame.UserID, len(frame.PCM))
	// pipe to a transcript service, mix down, archive, etc.
}

Subscribing yields decoded PCM frames per participant. From there it is your problem — pipe to a speech-to-text service, archive to disk, mix down into a recording, hand off to a model for live translation. Each frame carries a user id you can map back to a gogram User via the normal peer cache.

Leaving cleanly

session.Close()                       // drop the WebRTC peerconnection
client.LeaveGroupCall(call, session.SourceID())
session.Close()                       // drop the WebRTC peerconnection
client.LeaveGroupCall(call, session.SourceID())

Close the WebRTC peer connection first, then have gogram drop your participant record server-side. Skipping the second call leaves a ghost participant until the server's cleanup timer fires.

Practical tips

  • Voice chats only work for user accounts. Bots are blocked from joining group calls server-side. Run gortc on a userbot.
  • Network UDP must be open. WebRTC media flows over UDP. If your host blocks outbound UDP, fall back to a TURN relay (gortc supports the standard STUN/TURN URL list).
  • Watch the CPU. Opus encoding and decoding are not free. One participant is fine on any host; a hundred-participant call with stream subscription needs care.
  • Reconnections. If gogram's MTProto channel reconnects (network blip, DC migration), gortc's WebRTC path keeps going independently — the two transports are decoupled by design.