fix: agent cleanup on disconnect + targeted audio input

- Agent disconnects custom room when all real participants leave
  (prevents zombie participants blocking auto-dispatch)
- Bot sends m.call.member state event on call detection
  (Element Call shows bot as joined)
- Use RoomInputOptions(participant_identity=...) to target real user
  audio input (framework agent-AJ_xxx participant was confusing RoomIO)
- Removed incorrect bot dispatch (Matrix room ID != LiveKit room name)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-02-15 13:41:53 +02:00
parent a0debf0bd8
commit ee4efd01ef
2 changed files with 159 additions and 5 deletions

69
bot.py
View File

@@ -10,6 +10,7 @@ from nio import (
InviteMemberEvent,
MegolmEvent,
SyncResponse,
UnknownEvent,
KeyVerificationStart,
KeyVerificationCancel,
KeyVerificationKey,
@@ -18,6 +19,9 @@ from nio import (
)
from livekit import api
BOT_DEVICE_ID = "AIBOT"
CALL_MEMBER_TYPE = "org.matrix.msc3401.call.member"
logger = logging.getLogger("matrix-ai-bot")
HOMESERVER = os.environ["MATRIX_HOMESERVER"]
@@ -47,6 +51,7 @@ class Bot:
)
self.lkapi = None
self.dispatched_rooms = set()
self.active_calls = set() # rooms where we've sent call member event
async def start(self):
# Restore existing session or create new one
@@ -80,6 +85,7 @@ class Bot:
self.lkapi = api.LiveKitAPI(LK_URL, LK_KEY, LK_SECRET)
self.client.add_event_callback(self.on_invite, InviteMemberEvent)
self.client.add_event_callback(self.on_megolm, MegolmEvent)
self.client.add_event_callback(self.on_unknown, UnknownEvent)
self.client.add_response_callback(self.on_sync, SyncResponse)
self.client.add_to_device_callback(self.on_key_verification, KeyVerificationStart)
self.client.add_to_device_callback(self.on_key_verification, KeyVerificationKey)
@@ -115,6 +121,69 @@ class Bot:
self.client.verify_device(device)
logger.info("Auto-trusted device %s of %s", device.device_id, user_id)
async def on_unknown(self, room, event: UnknownEvent):
"""Handle call member state events to join calls."""
if event.type != CALL_MEMBER_TYPE:
return
if event.sender == BOT_USER:
return # ignore our own events
# Non-empty content means someone started/is in a call
if event.source.get("content", {}):
room_id = room.room_id
if room_id in self.active_calls:
return
logger.info("Call detected in %s from %s, joining...", room_id, event.sender)
self.active_calls.add(room_id)
# Get the foci_preferred from the caller's event
content = event.source["content"]
foci = content.get("foci_preferred", [{
"type": "livekit",
"livekit_service_url": f"{HOMESERVER}/livekit-jwt-service",
"livekit_alias": room_id,
}])
# Send our own call member state event
call_content = {
"application": "m.call",
"call_id": "",
"scope": "m.room",
"device_id": BOT_DEVICE_ID,
"expires": 7200000,
"focus_active": {
"type": "livekit",
"focus_selection": "oldest_membership",
},
"foci_preferred": foci,
"m.call.intent": "audio",
}
state_key = f"_{BOT_USER}_{BOT_DEVICE_ID}_m.call"
try:
resp = await self.client.room_put_state(
room_id, CALL_MEMBER_TYPE, call_content, state_key=state_key,
)
logger.info("Sent call member event in %s: %s", room_id, resp)
except Exception:
logger.exception("Failed to send call member event in %s", room_id)
else:
# Empty content = someone left the call, check if anyone is still calling
room_id = room.room_id
if room_id in self.active_calls:
# Leave the call too
self.active_calls.discard(room_id)
state_key = f"_{BOT_USER}_{BOT_DEVICE_ID}_m.call"
try:
await self.client.room_put_state(
room_id, CALL_MEMBER_TYPE, {}, state_key=state_key,
)
logger.info("Left call in %s", room_id)
except Exception:
logger.exception("Failed to leave call in %s", room_id)
async def on_megolm(self, room, event: MegolmEvent):
"""Log undecryptable messages."""
logger.warning(