diff --git a/shell/matrix/listener.go b/shell/matrix/listener.go index f6b4fd2..3f9657f 100644 --- a/shell/matrix/listener.go +++ b/shell/matrix/listener.go @@ -29,14 +29,15 @@ type MembershipNotifyFunc func(ctx context.Context, roomID, userID, membership s // Listener attaches to a mautrix syncer and dispatches events to an EventHandler. type Listener struct { - client *Client - cfg config.MatrixCfg - handler EventHandler - logger *slog.Logger - dmCache map[id.RoomID]bool - mu sync.RWMutex - interceptor InterceptFunc // if set and returns true, event is forwarded to orchestrator - membershipNotify MembershipNotifyFunc // if set, called on all StateMember events + client *Client + cfg config.MatrixCfg + handler EventHandler + logger *slog.Logger + dmCache map[id.RoomID]bool + mu sync.RWMutex + interceptor InterceptFunc // if set and returns true, event is forwarded to orchestrator + membershipNotify MembershipNotifyFunc // if set, called on all StateMember events + encryptedRelatesTo sync.Map // id.EventID → map[string]any: cache m.relates_to from encrypted events } // NewListener creates a Listener for the given client. @@ -66,6 +67,22 @@ func (l *Listener) SetMembershipNotify(fn MembershipNotifyFunc) { func (l *Listener) Run(ctx context.Context) error { syncer := l.client.raw.Syncer.(*mautrix.DefaultSyncer) + // Cache m.relates_to from encrypted events BEFORE they are decrypted. + // mautrix-go only copies m.relates_to into the decrypted content when + // EncryptedEventContent.RelatesTo != nil (i.e. the outer event has it). + // Older Element / matrix-js-sdk versions may not include it in the outer + // event, so we store it here and use it as a fallback for thread detection. + // Global listeners fire before type-specific ones, so this runs before + // CryptoHelper decrypts and dispatches the event.EventMessage. + syncer.OnEvent(func(ctx context.Context, evt *event.Event) { + if evt.Type != event.EventEncrypted { + return + } + if relatesTo, ok := evt.Content.Raw["m.relates_to"].(map[string]any); ok { + l.encryptedRelatesTo.Store(evt.ID, relatesTo) + } + }) + // Auto-join rooms when invited. Without this, the bot stays in "invited" // state and never receives m.room.message events. syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { @@ -154,7 +171,10 @@ func (l *Listener) Run(ctx context.Context) error { msgCtx.EventID = evt.ID.String() // Extract thread root from m.relates_to (Matrix thread support). - // Two methods: raw map (fast) + typed content fallback (robust for E2EE). + // Three methods in order of preference: + // 1. Raw map from decrypted content (fast path) + // 2. Typed parsed content (robust after E2EE decryption) + // 3. Encrypted event cache (for older clients that omit outer m.relates_to) if l.cfg.Threads.Enabled { if relatesTo, ok := evt.Content.Raw["m.relates_to"].(map[string]any); ok { if relType, _ := relatesTo["rel_type"].(string); relType == "m.thread" { @@ -163,7 +183,7 @@ func (l *Listener) Run(ctx context.Context) error { } } } - // Fallback: use typed content (more robust after E2EE decryption) + // Fallback 2: use typed content (more robust after E2EE decryption) if msgCtx.ThreadID == "" { if msgContent, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && msgContent.RelatesTo != nil { if threadParent := msgContent.RelatesTo.GetThreadParent(); threadParent != "" { @@ -172,6 +192,21 @@ func (l *Listener) Run(ctx context.Context) error { } } } + // Fallback 3: encrypted event cache — for E2EE messages where the outer + // m.relates_to was NOT propagated into the decrypted content by mautrix-go + // (happens with older Element / matrix-js-sdk versions). + if msgCtx.ThreadID == "" { + if cached, found := l.encryptedRelatesTo.LoadAndDelete(evt.ID); found { + if rt, ok := cached.(map[string]any); ok { + if relType, _ := rt["rel_type"].(string); relType == "m.thread" { + if threadRoot, _ := rt["event_id"].(string); threadRoot != "" { + msgCtx.ThreadID = threadRoot + l.logger.Debug("thread detected via encrypted event cache", "thread_id", msgCtx.ThreadID) + } + } + } + } + } } l.logger.Debug("message parsed",