Voice Chat System
The voice chat subsystem is the heart of Solstice's music streaming capability. It bridges the Go engine with Telegram's voice chat protocol through a Python daemon powered by PyTgCalls.
Architecture
┌───────────────────────────────────┐
│ Go Engine │
│ │
│ VoiceChatManager (vc.go) │
│ ├── StartDaemon() │
│ ├── Play(chatID, url, video) │
│ ├── Pause/Resume/Stop(chatID) │
│ ├── Seek(chatID, position) │
│ ├── Volume(chatID, level) │
│ └── OnStreamEnd callback │
│ │
│ HTTP listener on :5051 │
│ └── /stream_end endpoint │
└───────────────┬───────────────────┘
│ HTTP POST (JSON)
▼
┌───────────────────────────────────┐
│ Python Daemon │
│ (vc_daemon.py) │
│ │
│ aiohttp server on :5050 │
│ ├── POST /play │
│ ├── POST /pause │
│ ├── POST /resume │
│ ├── POST /stop │
│ ├── POST /seek │
│ └── POST /volume │
│ │
│ Pyrogram Client (MTProto) │
│ └── Joins/leaves voice chats │
│ │
│ PyTgCalls Engine │
│ └── Streams audio/video via │
│ FFmpeg → Telegram VC │
└───────────────────────────────────┘VoiceChatManager (Go Side)
File: internal/telegram/vc.go
The VoiceChatManager struct manages the lifecycle of the Python daemon and provides a clean Go API for voice chat operations.
Key Methods
type VoiceChatManager struct {
cmd *exec.Cmd
OnStreamEnd func(chatID int64) // Callback wired by main.go
}
func NewVoiceChatManager() *VoiceChatManager
func (vc *VoiceChatManager) StartDaemon()
func (vc *VoiceChatManager) Play(chatID int64, streamURL string, isVideo bool, title string) error
func (vc *VoiceChatManager) Pause(chatID int64) error
func (vc *VoiceChatManager) Resume(chatID int64) error
func (vc *VoiceChatManager) Stop(chatID int64) error
func (vc *VoiceChatManager) Seek(chatID int64, position int) error
func (vc *VoiceChatManager) Volume(chatID int64, level int) errorHow Commands Work
Each method sends an HTTP POST to http://localhost:5050/<endpoint> with a JSON body:
func (vc *VoiceChatManager) Play(chatID int64, streamURL string, isVideo bool, title string) error {
payload := map[string]interface{}{
"chat_id": chatID,
"stream_url": streamURL,
"video": isVideo,
"title": title,
}
body, _ := json.Marshal(payload)
resp, err := http.Post("http://localhost:5050/play", "application/json", bytes.NewBuffer(body))
// ... handle response
}Stream End Webhook
The Go engine also runs a lightweight HTTP server on :5051 that listens for stream-end notifications:
// In StartDaemon or init:
http.HandleFunc("/stream_end", func(w http.ResponseWriter, r *http.Request) {
var req struct{ ChatID int64 `json:"chat_id"` }
json.NewDecoder(r.Body).Decode(&req)
if vc.OnStreamEnd != nil {
go vc.OnStreamEnd(req.ChatID)
}
w.WriteHeader(200)
})
go http.ListenAndServe(":5051", nil)Auto-Stop / Alone Detection Webhook
The webhook server also listens for /vc_stopped notifications. This is triggered when the assistant is left alone in a voice chat:
- The Python daemon monitors
UpdatedGroupCallParticipantupdates. - If the assistant is the only user left in the voice chat, a 10-minute auto-stop countdown starts.
- If someone joins within the 10 minutes, the countdown is canceled and playback continues.
- If it remains empty after 10 minutes, the assistant leaves the call and triggers
/vc_stopped, clearing the queue and logging the event toLOGGER_ID.
Python Daemon (vc_daemon.py)
File: internal/telegram/vc_daemon.py
The daemon is a standalone asyncio application that:
- Initializes a Pyrogram client using
SESSION_STRING,API_ID, andAPI_HASH - Initializes a PyTgCalls instance attached to the Pyrogram client
- Starts an aiohttp HTTP server on port
5050 - Handles incoming requests from the Go engine
- Sends
stream_endnotifications back to Go when a stream finishes
Daemon Startup Sequence
async def main():
# 1. Initialize Pyrogram client
app = Client("solstice_vc", api_id=API_ID, api_hash=API_HASH,
session_string=SESSION_STRING)
await app.start()
# 2. Initialize PyTgCalls
pytgcalls = PyTgCalls(app)
await pytgcalls.start()
# 3. Register stream-end callback
@pytgcalls.on_stream_end()
async def on_stream_end(client, update):
chat_id = update.chat_id
# Notify Go engine
async with aiohttp.ClientSession() as session:
await session.post("http://localhost:5051/stream_end",
json={"chat_id": chat_id})
# 4. Start HTTP API server
web_app = web.Application()
web_app.router.add_post("/play", handle_play)
web_app.router.add_post("/pause", handle_pause)
web_app.router.add_post("/resume", handle_resume)
web_app.router.add_post("/stop", handle_stop)
web_app.router.add_post("/seek", handle_seek)
web_app.router.add_post("/volume", handle_volume)
runner = web.AppRunner(web_app)
await runner.setup()
site = web.TCPSite(runner, "localhost", 5050)
await site.start()Play Handler
async def handle_play(request):
data = await request.json()
chat_id = data["chat_id"]
stream_url = data["stream_url"]
is_video = data.get("video", False)
title = data.get("title", "")
if is_video:
stream = AudioVideoPiped(stream_url,
video_parameters=VideoParameters(...))
else:
stream = AudioPiped(stream_url,
audio_parameters=AudioParameters(...))
try:
await pytgcalls.join_group_call(chat_id, stream)
except AlreadyJoinedError:
await pytgcalls.change_stream(chat_id, stream)
return web.json_response({"status": "ok"})Stream Parameters
| Mode | Format | Quality |
|---|---|---|
| Audio | bestaudio[ext=m4a] / format 251/250 | 48kHz, stereo |
| Video | best[ext=mp4] / format 22/18 | 720p preferred |
Queue Management
File: internal/queue/queue.go
The QueueManager is a thread-safe, per-chat song queue implemented in Go.
Data Structures
type Song struct {
Title string
Link string
Duration string
Thumbnail string
StreamURL string
RequesterID int64
RequesterName string
Video bool
}
type QueueManager struct {
mu sync.Mutex
queues map[int64][]Song // chatID → ordered song list
}Operations
| Method | Description |
|---|---|
Add(chatID, song) | Append song to the chat's queue |
GetQueue(chatID) | Return the full queue (read-only copy) |
GetCurrent(chatID) | Return the first song (currently playing) |
Advance(chatID) | Pop the first song, return the next one |
Clear(chatID) | Empty the entire queue |
Shuffle(chatID) | Randomize queue order (preserves current song at index 0) |
Remove(chatID, index) | Remove a specific song by index |
GetActiveChats() | Return all chat IDs with non-empty queues |
Length(chatID) | Return queue length |
Thread Safety
All operations acquire sync.Mutex before modifying state. This is critical because:
- Multiple users can issue
/playcommands simultaneously - The stream-end webhook handler runs in a separate goroutine
- The
/shuffle,/skip, and/stopcommands can race with auto-advance
Stream Lifecycle
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ IDLE │────▶│ PLAYING │────▶│ PAUSED │────▶│ PLAYING │
│ │ │ │ │ │ │ │
│ No VC │ │ Stream │ │ Stream │ │ Stream │
│ session │ │ active │ │ suspended│ │ resumed │
└──────────┘ └────┬─────┘ └──────────┘ └──────────┘
│
│ stream ends
▼
┌──────────────┐
│ ADVANCING │
│ │
│ Pop queue │
│ Check next │
└──────┬───────┘
│
┌─────────┼──────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌─────────┐ ┌──────────┐
│ PLAYING │ │AUTOPLAY │ │ IDLE │
│ next │ │ fetch │ │ queue │
│ song │ │ related │ │ empty │
└──────────┘ └─────────┘ └──────────┘Autoplay
When enabled (/autoplay), Solstice fetches related songs when the queue empties:
- Take the last played song's YouTube video ID
- Call
youtube.GetRelatedVideos(videoID, 1)- fetches from YouTube Mix (RDplaylist) - Extract the stream URL for the first related video
- Add it to the queue and trigger playback
This creates an infinite, contextually relevant music stream.
Vote Skip
Normal (non-admin) users can vote to skip a song instead of directly skipping:
- User clicks the
👍 Vote Skip (N/T)button on the Now Playing card voteSkipStore.toggle()adds/removes their vote (thread-safe via mutex)- The button text updates to show current count:
👍 N/T - When
N >= T(threshold set via/setvotes), the song is auto-skipped - Vote data is cleared when a song ends or is manually skipped
type voteSkipStore struct {
mu sync.Mutex
votes map[int64]map[int]map[int64]bool // chatID → messageID → userID → voted
}