From ca6c5fb0805586b0d26b9d7687dac992fa87235b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sun, 29 Mar 2026 17:41:00 +0200 Subject: [PATCH 1/6] Add DM/config & remote message helpers Introduce helper utilities for DM portal configuration and pre-built remote messages: ConfigureDMPortal, BuildPreConvertedRemoteMessage, and SendSystemMessage. Extend BuildLoginDMChatInfo/BuildDMChatInfo to accept custom senders, bot UserInfo and member extras. Add ClientBase.SendSystemMessage wrapper and replace numerous ad-hoc portal configuration and send implementations across bridges (ai, codex, openclaw, opencode, dummybridge, opencode removal of portal_send). Update OpenClaw gateway client identifiers and refactor message queuing to use BuildPreConvertedRemoteMessage. Update tests to cover new helpers and timing/ID generation. This centralizes DM/remote message logic, reduces duplication, and standardizes system notice delivery. --- bridges/ai/chat.go | 59 +++---- bridges/codex/backfill.go | 13 +- bridges/codex/client.go | 14 +- bridges/codex/directory_manager.go | 12 +- bridges/codex/portal_send.go | 18 +-- bridges/dummybridge/bridge.go | 28 ++-- bridges/openclaw/client.go | 26 +-- bridges/openclaw/events.go | 59 +------ bridges/openclaw/gateway_client.go | 4 +- bridges/openclaw/manager.go | 40 ++--- bridges/openclaw/manager_test.go | 18 ++- bridges/openclaw/provisioning.go | 52 +++--- bridges/opencode/host.go | 6 +- bridges/opencode/opencode_portal.go | 24 ++- bridges/opencode/portal_send.go | 34 ---- client_base.go | 8 + helpers.go | 243 +++++++++++++++++++--------- helpers_test.go | 105 ++++++++++++ sdk/client.go | 7 +- sdk/conversation.go | 5 +- 20 files changed, 433 insertions(+), 342 deletions(-) delete mode 100644 bridges/opencode/portal_send.go diff --git a/bridges/ai/chat.go b/bridges/ai/chat.go index 6d3ee84e..e92c7234 100644 --- a/bridges/ai/chat.go +++ b/bridges/ai/chat.go @@ -519,21 +519,6 @@ func modelMemberUserInfo(modelID string, info *ModelInfo) *bridgev2.UserInfo { } } -func modelJoinMember(loginID networkid.UserLoginID, modelID, modelName string, info *ModelInfo) bridgev2.ChatMember { - return bridgev2.ChatMember{ - EventSender: bridgev2.EventSender{ - Sender: modelUserID(modelID), - SenderLogin: loginID, - }, - Membership: event.MembershipJoin, - UserInfo: modelMemberUserInfo(modelID, info), - MemberEventExtra: map[string]any{ - "displayname": modelName, - "com.beeper.ai.model_id": modelID, - }, - } -} - func (oc *AIClient) createAgentChatWithModel(ctx context.Context, agent *agents.AgentDefinition, modelID string, applyModelOverride bool) (*bridgev2.CreateChatResponse, error) { if !oc.agentsEnabledForLogin() { return nil, agentChatsDisabledError() @@ -691,16 +676,19 @@ func (oc *AIClient) initPortalForChat(ctx context.Context, opts PortalInitOpts) } portal.Metadata = pmeta - portal.RoomType = database.RoomTypeDM - portal.OtherUserID = modelUserID(modelID) - portal.Name = title - portal.NameSet = true - defaultAvatar := strings.TrimSpace(agents.DefaultAgentAvatarMXC) - if defaultAvatar != "" { - portal.AvatarID = networkid.AvatarID(defaultAvatar) - portal.AvatarMXC = id.ContentURIString(defaultAvatar) - } - if err := portal.Save(ctx); err != nil { + if err := agentremote.ConfigureDMPortal(ctx, agentremote.ConfigureDMPortalParams{ + Portal: portal, + Title: title, + OtherUserID: modelUserID(modelID), + Save: true, + MutatePortal: func(portal *bridgev2.Portal) { + defaultAvatar := strings.TrimSpace(agents.DefaultAgentAvatarMXC) + if defaultAvatar != "" { + portal.AvatarID = networkid.AvatarID(defaultAvatar) + portal.AvatarMXC = id.ContentURIString(defaultAvatar) + } + }, + }); err != nil { return nil, nil, fmt.Errorf("failed to save portal: %w", err) } oc.ensureGhostDisplayName(ctx, modelID) @@ -928,16 +916,18 @@ func (oc *AIClient) composeChatInfo(title, modelID string) *bridgev2.ChatInfo { if title == "" { title = modelName } - chatInfo := agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ + return agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ Title: title, Login: oc.UserLogin, HumanUserIDPrefix: oc.HumanUserIDPrefix, BotUserID: modelUserID(modelID), BotDisplayName: modelName, + BotUserInfo: modelMemberUserInfo(modelID, modelInfo), + BotMemberEventExtra: map[string]any{ + "displayname": modelName, + "com.beeper.ai.model_id": modelID, + }, }) - // Override bot member with model-specific UserInfo and extra fields. - chatInfo.Members.MemberMap[modelUserID(modelID)] = modelJoinMember(oc.UserLogin.ID, modelID, modelName, modelInfo) - return chatInfo } func (oc *AIClient) applyAgentChatInfo(chatInfo *bridgev2.ChatInfo, agentID, agentName, modelID string) { @@ -1001,17 +991,10 @@ func (oc *AIClient) BroadcastRoomState(ctx context.Context, portal *bridgev2.Por // sendSystemNotice sends an informational notice to the room via the bridge bot. func (oc *AIClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Portal, message string) { - if portal == nil || portal.MXID == "" || oc == nil || oc.UserLogin == nil || oc.UserLogin.Bridge == nil || oc.UserLogin.Bridge.Bot == nil { + if oc == nil { return } - _, err := oc.UserLogin.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ - Parsed: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: message, - Mentions: &event.Mentions{}, - }, - }, nil) - if err != nil { + if err := oc.ClientBase.SendSystemMessage(ctx, portal, message); err != nil { oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to send system notice") } } diff --git a/bridges/codex/backfill.go b/bridges/codex/backfill.go index 177a7e8c..498d884d 100644 --- a/bridges/codex/backfill.go +++ b/bridges/codex/backfill.go @@ -234,12 +234,15 @@ func (cc *CodexClient) ensureCodexThreadPortal(ctx context.Context, existing *br meta.Slug = codexThreadSlug(threadID) } - portal.RoomType = database.RoomTypeDM - portal.OtherUserID = codexGhostID - info := cc.composeCodexChatInfo(portal, title, true) - portal.Name = title - portal.NameSet = true + if err := agentremote.ConfigureDMPortal(ctx, agentremote.ConfigureDMPortalParams{ + Portal: portal, + Title: title, + OtherUserID: codexGhostID, + Save: false, + }); err != nil { + return nil, false, err + } created, err = bridgesdk.EnsurePortalLifecycle(ctx, bridgesdk.PortalLifecycleOptions{ Login: cc.UserLogin, Portal: portal, diff --git a/bridges/codex/client.go b/bridges/codex/client.go index b6157e0a..91c7431d 100644 --- a/bridges/codex/client.go +++ b/bridges/codex/client.go @@ -1539,18 +1539,15 @@ func (cc *CodexClient) composeCodexChatInfo(portal *bridgev2.Portal, title strin if title == "" { title = "Codex" } - info := agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ + return agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ Title: title, + Topic: cc.codexTopicForPortal(portal, portalMeta(portal)), Login: cc.UserLogin, HumanUserIDPrefix: cc.HumanUserIDPrefix, BotUserID: codexGhostID, BotDisplayName: "Codex", CanBackfill: canBackfill, }) - if info != nil { - info.Topic = ptr.NonZero(cc.codexTopicForPortal(portal, portalMeta(portal))) - } - return info } func resolveCodexWorkingDirectory(raw string) (string, error) { @@ -1768,11 +1765,12 @@ func (cc *CodexClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2 } func (cc *CodexClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Portal, message string) { - if portal == nil || portal.MXID == "" || cc.UserLogin == nil || cc.UserLogin.Bridge == nil { + if cc == nil { return } - timing := agentremote.ResolveEventTiming(time.Now(), 0) - cc.sendViaPortal(portal, agentremote.BuildSystemNotice(strings.TrimSpace(message)), "", timing.Timestamp, timing.StreamOrder) + if err := cc.ClientBase.SendSystemMessage(ctx, portal, strings.TrimSpace(message)); err != nil { + cc.log.Warn().Err(err).Msg("Failed to send system notice") + } } func (cc *CodexClient) sendPendingStatus(ctx context.Context, portal *bridgev2.Portal, evt *event.Event, message string) { diff --git a/bridges/codex/directory_manager.go b/bridges/codex/directory_manager.go index b0fe004b..77dbe24b 100644 --- a/bridges/codex/directory_manager.go +++ b/bridges/codex/directory_manager.go @@ -183,10 +183,14 @@ func (cc *CodexClient) createWelcomeCodexChat(ctx context.Context) (*bridgev2.Po meta.CodexCwd = "" meta.AwaitingCwdSetup = true meta.ManagedImport = false - portal.RoomType = database.RoomTypeDM - portal.OtherUserID = codexGhostID - portal.Name = meta.Title - portal.NameSet = true + if err := agentremote.ConfigureDMPortal(ctx, agentremote.ConfigureDMPortalParams{ + Portal: portal, + Title: meta.Title, + OtherUserID: codexGhostID, + Save: false, + }); err != nil { + return nil, err + } info := cc.composeCodexChatInfo(portal, meta.Title, false) created, err := bridgesdk.EnsurePortalLifecycle(ctx, bridgesdk.PortalLifecycleOptions{ Login: cc.UserLogin, diff --git a/bridges/codex/portal_send.go b/bridges/codex/portal_send.go index ae232cd9..641dd516 100644 --- a/bridges/codex/portal_send.go +++ b/bridges/codex/portal_send.go @@ -1,22 +1,6 @@ package codex -import ( - "time" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/id" -) - -func (cc *CodexClient) sendViaPortal( - portal *bridgev2.Portal, - converted *bridgev2.ConvertedMessage, - msgID networkid.MessageID, - timestamp time.Time, - streamOrder int64, -) (id.EventID, networkid.MessageID, error) { - return cc.ClientBase.SendViaPortalWithOptions(portal, cc.senderForPortal(), msgID, timestamp, streamOrder, converted) -} +import "maunium.net/go/mautrix/bridgev2" func (cc *CodexClient) senderForPortal() bridgev2.EventSender { if cc == nil || cc.UserLogin == nil { diff --git a/bridges/dummybridge/bridge.go b/bridges/dummybridge/bridge.go index a2a5a2ee..8269b6aa 100644 --- a/bridges/dummybridge/bridge.go +++ b/bridges/dummybridge/bridge.go @@ -229,13 +229,13 @@ func (dc *DummyBridgeConnector) ensureChatForIndexLocked(ctx context.Context, lo meta.Topic = dummyPortalTopic meta.ChatIndex = idx - portal.RoomType = database.RoomTypeDM - portal.OtherUserID = dummyAgentUserID - portal.Name = title - portal.Topic = dummyPortalTopic - portal.NameSet = true - portal.TopicSet = true - if err := portal.Save(ctx); err != nil { + if err := agentremote.ConfigureDMPortal(ctx, agentremote.ConfigureDMPortalParams{ + Portal: portal, + Title: title, + Topic: dummyPortalTopic, + OtherUserID: dummyAgentUserID, + Save: false, + }); err != nil { return nil, fmt.Errorf("save portal: %w", err) } @@ -258,24 +258,16 @@ func (dc *DummyBridgeConnector) ensureChatForIndexLocked(ctx context.Context, lo } func (dc *DummyBridgeConnector) composeChatInfo(login *bridgev2.UserLogin, title string) *bridgev2.ChatInfo { - info := agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ + return agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ Title: title, + Topic: dummyPortalTopic, Login: login, HumanUserIDPrefix: "dummybridge-user", BotUserID: dummyAgentUserID, BotDisplayName: dummyAgentName, + BotUserInfo: dummySDKAgent().UserInfo(), CanBackfill: false, }) - if info == nil { - return nil - } - info.Topic = ptr.Ptr(dummyPortalTopic) - if info.Members != nil && info.Members.MemberMap != nil { - member := info.Members.MemberMap[dummyAgentUserID] - member.UserInfo = dummySDKAgent().UserInfo() - info.Members.MemberMap[dummyAgentUserID] = member - } - return info } func dummyPortalID(idx int) string { diff --git a/bridges/openclaw/client.go b/bridges/openclaw/client.go index 4e4e8364..b8a55936 100644 --- a/bridges/openclaw/client.go +++ b/bridges/openclaw/client.go @@ -336,7 +336,7 @@ func (oc *OpenClawClient) GetChatInfo(ctx context.Context, portal *bridgev2.Port roomType := openClawRoomType(meta) agentID := stringutil.TrimDefault(meta.OpenClawDMTargetAgentID, meta.OpenClawAgentID) if roomType == database.RoomTypeDM && agentID != "" { - info := oc.syntheticDMPortalInfo(agentID, title) + info := oc.syntheticDMPortalInfo(agentID, title, nil) info.Topic = ptr.NonZero(oc.topicForPortal(meta)) info.Type = ptr.Ptr(roomType) info.CanBackfill = true @@ -735,25 +735,15 @@ func (oc *OpenClawClient) senderForAgent(agentID string, fromMe bool) bridgev2.E } } -func (oc *OpenClawClient) sendNoticeViaPortal(ctx context.Context, portal *bridgev2.Portal, msg string, sender bridgev2.EventSender) { - if portal == nil || strings.TrimSpace(msg) == "" { +func (oc *OpenClawClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Portal, msg string) { + if oc == nil || portal == nil || strings.TrimSpace(msg) == "" { return } - converted := &bridgev2.ConvertedMessage{ - Parts: []*bridgev2.ConvertedMessagePart{{ - ID: networkid.PartID("0"), - Type: event.EventMessage, - Content: &event.MessageEventContent{MsgType: event.MsgNotice, Body: msg, Mentions: &event.Mentions{}}, - }}, - } - oc.UserLogin.QueueRemoteEvent(buildOpenClawRemoteMessage( - portal.PortalKey, - newOpenClawMessageID(), - sender, - time.Now(), - 0, - converted, - )) + if err := oc.ClientBase.SendSystemMessage(ctx, portal, msg); err != nil { + if oc.UserLogin != nil { + oc.UserLogin.Log.Warn().Err(err).Msg("Failed to send system notice") + } + } } func (oc *OpenClawClient) DownloadAndEncodeMedia(ctx context.Context, mediaURL string, file *event.EncryptedFileInfo, maxMB int) (string, string, error) { diff --git a/bridges/openclaw/events.go b/bridges/openclaw/events.go index 29e8718e..fd7557e7 100644 --- a/bridges/openclaw/events.go +++ b/bridges/openclaw/events.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "strings" - "time" - "github.com/google/uuid" "github.com/rs/zerolog" "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" @@ -146,29 +144,18 @@ func getOpenClawSessionChatInfo(ctx context.Context, portal *bridgev2.Portal, cl roomType := openClawRoomType(meta) client.maybeRefreshPortalCapabilities(ctx, portal, &previous) if roomType == database.RoomTypeDM { - chatInfo := agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ + return agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ Title: title, + Topic: client.topicForPortal(meta), Login: client.UserLogin, HumanUserIDPrefix: "openclaw-user", + HumanSender: ptr.Ptr(client.senderForAgent(agentID, true)), BotUserID: openClawGhostUserID(agentID), BotDisplayName: agentName, + BotSender: ptr.Ptr(client.senderForAgent(agentID, false)), + BotUserInfo: client.userInfoForAgentProfile(profile), CanBackfill: true, - }) - if chatInfo != nil { - chatInfo.Topic = ptr.NonZero(client.topicForPortal(meta)) - if chatInfo.Members != nil && chatInfo.Members.MemberMap != nil { - chatInfo.Members.MemberMap[humanUserID(client.UserLogin.ID)] = bridgev2.ChatMember{ - EventSender: client.senderForAgent(agentID, true), - Membership: event.MembershipJoin, - } - chatInfo.Members.MemberMap[openClawGhostUserID(agentID)] = bridgev2.ChatMember{ - EventSender: client.senderForAgent(agentID, false), - Membership: event.MembershipJoin, - UserInfo: client.userInfoForAgentProfile(profile), - } - } - } - return chatInfo, nil + }), nil } memberMap := bridgev2.ChatMemberMap{ humanUserID(client.UserLogin.ID): { @@ -190,37 +177,3 @@ func getOpenClawSessionChatInfo(ctx context.Context, portal *bridgev2.Portal, cl }, }, nil } - -func buildOpenClawRemoteMessage( - portal networkid.PortalKey, - messageID networkid.MessageID, - sender bridgev2.EventSender, - timestamp time.Time, - streamOrder int64, - preBuilt *bridgev2.ConvertedMessage, -) *simplevent.PreConvertedMessage { - if timestamp.IsZero() { - timestamp = time.Now() - } - if streamOrder == 0 { - streamOrder = timestamp.UnixMilli() - } - return &simplevent.PreConvertedMessage{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventMessage, - PortalKey: portal, - Sender: sender, - Timestamp: timestamp, - StreamOrder: streamOrder, - LogContext: func(c zerolog.Context) zerolog.Context { - return c.Str("openclaw_msg_id", string(messageID)) - }, - }, - ID: messageID, - Data: preBuilt, - } -} - -func newOpenClawMessageID() networkid.MessageID { - return networkid.MessageID("openclaw:" + uuid.NewString()) -} diff --git a/bridges/openclaw/gateway_client.go b/bridges/openclaw/gateway_client.go index 944e6f80..0cebba21 100644 --- a/bridges/openclaw/gateway_client.go +++ b/bridges/openclaw/gateway_client.go @@ -29,8 +29,8 @@ import ( const ( openClawProtocolVersion = 3 - openClawGatewayClientID = "beeper-bridge" - openClawGatewayClientMode = "ui" + openClawGatewayClientID = "gateway-client" + openClawGatewayClientMode = "backend" openClawGatewayDisplayName = "Beeper" openClawGatewayWSReadLimit = 32 * 1024 * 1024 openClawGatewayPingInterval = 30 * time.Second diff --git a/bridges/openclaw/manager.go b/bridges/openclaw/manager.go index 3526c310..e866b9d5 100644 --- a/bridges/openclaw/manager.go +++ b/bridges/openclaw/manager.go @@ -111,7 +111,7 @@ func newOpenClawManager(client *OpenClawClient) *openClawManager { agentremote.DecisionToString(decision, "allow-once", "allow-always", "deny")) }, SendNotice: func(ctx context.Context, portal *bridgev2.Portal, msg string) { - client.sendNoticeViaPortal(ctx, portal, msg, mgr.approvalSenderForPortal(portal)) + client.sendSystemNotice(ctx, portal, msg) }, DBMetadata: func(prompt agentremote.ApprovalPromptMessage) any { return &MessageMetadata{ @@ -375,7 +375,7 @@ func (m *openClawManager) expireLocalApproval(ctx context.Context, approvalID st } if sessionKey != "" { if portal := m.resolvePortal(ctx, sessionKey); portal != nil && portal.MXID != "" { - m.client.sendNoticeViaPortal(ctx, portal, "OpenClaw approval expired", m.approvalSenderForPortal(portal)) + m.client.sendSystemNotice(ctx, portal, "OpenClaw approval expired") } } m.approvalFlow.ResolveExternal(ctx, approvalID, agentremote.ApprovalDecisionPayload{ @@ -1804,7 +1804,7 @@ func (m *openClawManager) handleApprovalResolved(ctx context.Context, payload ga "reason": reason, }) } else { - m.client.sendNoticeViaPortal(ctx, portal, openClawApprovalResolvedText(payload.Decision), m.approvalSenderForPortal(portal)) + m.client.sendSystemNotice(ctx, portal, openClawApprovalResolvedText(payload.Decision)) } m.approvalFlow.ResolveExternal(ctx, approvalID, agentremote.ApprovalDecisionPayload{ ApprovalID: approvalID, @@ -1915,14 +1915,15 @@ func (m *openClawManager) handleDirectChatEvent(ctx context.Context, portal *bri return } m.invalidateHistoryCache(payload.SessionKey) - m.client.UserLogin.QueueRemoteEvent(buildOpenClawRemoteMessage( - portal.PortalKey, - messageID, - sender, - eventTS, - payload.Seq*2, - converted, - )) + m.client.UserLogin.QueueRemoteEvent(agentremote.BuildPreConvertedRemoteMessage(agentremote.PreConvertedRemoteMessageParams{ + PortalKey: portal.PortalKey, + Sender: sender, + MsgID: messageID, + LogKey: "openclaw_msg_id", + Timestamp: eventTS, + StreamOrder: payload.Seq * 2, + Converted: converted, + })) if maybeUpdatePreviewSnippet(meta, openclawconv.ExtractMessageText(payload.Message), eventTS) { _ = portal.Save(ctx) } @@ -1954,14 +1955,15 @@ func (m *openClawManager) emitLatestUserMessageFromHistory(ctx context.Context, m.lastEmittedUserMsg[payload.SessionKey] = messageID m.mu.Unlock() eventTS := extractOpenClawEventTimestamp(payload.TS, message) - m.client.UserLogin.QueueRemoteEvent(buildOpenClawRemoteMessage( - portal.PortalKey, - messageID, - sender, - eventTS, - payload.Seq*2-1, - converted, - )) + m.client.UserLogin.QueueRemoteEvent(agentremote.BuildPreConvertedRemoteMessage(agentremote.PreConvertedRemoteMessageParams{ + PortalKey: portal.PortalKey, + Sender: sender, + MsgID: messageID, + LogKey: "openclaw_msg_id", + Timestamp: eventTS, + StreamOrder: payload.Seq*2 - 1, + Converted: converted, + })) if maybeUpdatePreviewSnippet(meta, openclawconv.ExtractMessageText(message), eventTS) { _ = portal.Save(ctx) } diff --git a/bridges/openclaw/manager_test.go b/bridges/openclaw/manager_test.go index 8f8f1e1c..b73a16d2 100644 --- a/bridges/openclaw/manager_test.go +++ b/bridges/openclaw/manager_test.go @@ -103,8 +103,22 @@ func TestShouldMirrorLatestUserMessageFromHistory(t *testing.T) { func TestOpenClawRemoteMessageGetStreamOrderUsesGatewaySeq(t *testing.T) { ts := time.Date(2026, time.March, 12, 12, 0, 0, 0, time.UTC) - first := buildOpenClawRemoteMessage(networkid.PortalKey{}, "first", bridgev2.EventSender{}, ts, 10, nil) - second := buildOpenClawRemoteMessage(networkid.PortalKey{}, "second", bridgev2.EventSender{}, ts, 11, nil) + first := agentremote.BuildPreConvertedRemoteMessage(agentremote.PreConvertedRemoteMessageParams{ + PortalKey: networkid.PortalKey{}, + MsgID: "first", + LogKey: "openclaw_msg_id", + Sender: bridgev2.EventSender{}, + Timestamp: ts, + StreamOrder: 10, + }) + second := agentremote.BuildPreConvertedRemoteMessage(agentremote.PreConvertedRemoteMessageParams{ + PortalKey: networkid.PortalKey{}, + MsgID: "second", + LogKey: "openclaw_msg_id", + Sender: bridgev2.EventSender{}, + Timestamp: ts, + StreamOrder: 11, + }) if first.GetStreamOrder() != 10 { t.Fatalf("expected first stream order 10, got %d", first.GetStreamOrder()) } diff --git a/bridges/openclaw/provisioning.go b/bridges/openclaw/provisioning.go index 8c621284..19ddb0ae 100644 --- a/bridges/openclaw/provisioning.go +++ b/bridges/openclaw/provisioning.go @@ -304,21 +304,16 @@ func (oc *OpenClawClient) createConfiguredAgentDM(ctx context.Context, agent gat meta.OpenClawDMCreatedFromContact = true meta.HistoryMode = "paginated" meta.RecentHistoryLimit = 0 - portal.RoomType = database.RoomTypeDM - portal.OtherUserID = openClawGhostUserID(agentID) - portal.Name = meta.OpenClawDMTargetAgentName - portal.Topic = "OpenClaw agent DM" - portal.NameSet = true - portal.TopicSet = true - if err := portal.Save(ctx); err != nil { + if err := agentremote.ConfigureDMPortal(ctx, agentremote.ConfigureDMPortalParams{ + Portal: portal, + Title: meta.OpenClawDMTargetAgentName, + Topic: "OpenClaw agent DM", + OtherUserID: openClawGhostUserID(agentID), + Save: false, + }); err != nil { return nil, fmt.Errorf("failed to save openclaw dm portal: %w", err) } - chatInfo := oc.syntheticDMPortalInfo(agentID, meta.OpenClawDMTargetAgentName) - if chatInfo.Members != nil { - member := chatInfo.Members.MemberMap[openClawGhostUserID(agentID)] - member.UserInfo = info - chatInfo.Members.MemberMap[openClawGhostUserID(agentID)] = member - } + chatInfo := oc.syntheticDMPortalInfo(agentID, meta.OpenClawDMTargetAgentName, info) _, err = bridgesdk.EnsurePortalLifecycle(ctx, bridgesdk.PortalLifecycleOptions{ Login: oc.UserLogin, Portal: portal, @@ -337,35 +332,28 @@ func (oc *OpenClawClient) createConfiguredAgentDM(ctx context.Context, agent gat }, nil } -func (oc *OpenClawClient) syntheticDMPortalInfo(agentID, displayName string) *bridgev2.ChatInfo { +func (oc *OpenClawClient) syntheticDMPortalInfo(agentID, displayName string, userInfo *bridgev2.UserInfo) *bridgev2.ChatInfo { if strings.TrimSpace(displayName) == "" { displayName = oc.displayNameForAgent(agentID) } - chatInfo := agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ + if userInfo == nil { + userInfo = oc.sdkAgentForProfile(openClawAgentProfile{AgentID: agentID, Name: displayName}).UserInfo() + } + return agentremote.BuildLoginDMChatInfo(agentremote.LoginDMChatInfoParams{ Title: displayName, + Topic: "OpenClaw agent DM", Login: oc.UserLogin, HumanUserIDPrefix: "openclaw-user", + HumanSender: ptr.Ptr(oc.senderForAgent(agentID, true)), BotUserID: openClawGhostUserID(agentID), BotDisplayName: displayName, - CanBackfill: true, - }) - if chatInfo == nil || chatInfo.Members == nil || chatInfo.Members.MemberMap == nil { - return chatInfo - } - chatInfo.Topic = ptr.Ptr("OpenClaw agent DM") - chatInfo.Members.MemberMap[humanUserID(oc.UserLogin.ID)] = bridgev2.ChatMember{ - EventSender: oc.senderForAgent(agentID, true), - Membership: event.MembershipJoin, - } - chatInfo.Members.MemberMap[openClawGhostUserID(agentID)] = bridgev2.ChatMember{ - EventSender: oc.senderForAgent(agentID, false), - Membership: event.MembershipJoin, - UserInfo: oc.sdkAgentForProfile(openClawAgentProfile{AgentID: agentID, Name: displayName}).UserInfo(), - MemberEventExtra: map[string]any{ + BotSender: ptr.Ptr(oc.senderForAgent(agentID, false)), + BotUserInfo: userInfo, + BotMemberEventExtra: map[string]any{ "displayname": displayName, }, - } - return chatInfo + CanBackfill: true, + }) } func (oc *OpenClawClient) resolveAgentProfile(ctx context.Context, agentID, sessionKey string, current *GhostMetadata, configured *gatewayAgentSummary) openClawAgentProfile { diff --git a/bridges/opencode/host.go b/bridges/opencode/host.go index 3554f609..0dba4ce9 100644 --- a/bridges/opencode/host.go +++ b/bridges/opencode/host.go @@ -27,10 +27,12 @@ func (oc *OpenCodeClient) Log() *zerolog.Logger { } func (oc *OpenCodeClient) SendSystemNotice(ctx context.Context, portal *bridgev2.Portal, msg string) { - if portal == nil || portal.MXID == "" { + if oc == nil { return } - oc.sendSystemNoticeViaPortal(ctx, portal, msg) + if err := oc.ClientBase.SendSystemMessage(ctx, portal, msg); err != nil { + oc.Log().Warn().Err(err).Msg("Failed to send system notice") + } } func (oc *OpenCodeClient) EmitOpenCodeStreamEvent(ctx context.Context, portal *bridgev2.Portal, turnID, agentID string, part map[string]any) { diff --git a/bridges/opencode/opencode_portal.go b/bridges/opencode/opencode_portal.go index a3b3a390..01cad086 100644 --- a/bridges/opencode/opencode_portal.go +++ b/bridges/opencode/opencode_portal.go @@ -80,10 +80,14 @@ func (b *Bridge) ensureOpenCodeSessionPortalWithRoom(ctx context.Context, inst * } meta.Title = title - portal.RoomType = database.RoomTypeDM - portal.OtherUserID = OpenCodeUserID(inst.cfg.ID) - portal.Name = title - portal.NameSet = true + if err := agentremote.ConfigureDMPortal(ctx, agentremote.ConfigureDMPortalParams{ + Portal: portal, + Title: title, + OtherUserID: OpenCodeUserID(inst.cfg.ID), + Save: false, + }); err != nil { + return err + } b.host.SetPortalMeta(portal, meta) chatInfo := b.composeOpenCodeChatInfo(title, inst.cfg.ID) @@ -222,10 +226,14 @@ func (b *Bridge) createManagedLauncherChat(ctx context.Context, login *bridgev2. AgentID: b.host.DefaultAgentID(), } - portal.RoomType = database.RoomTypeDM - portal.OtherUserID = OpenCodeUserID(instanceID) - portal.Name = displayTitle - portal.NameSet = true + if err := agentremote.ConfigureDMPortal(ctx, agentremote.ConfigureDMPortalParams{ + Portal: portal, + Title: displayTitle, + OtherUserID: OpenCodeUserID(instanceID), + Save: false, + }); err != nil { + return nil, err + } b.host.SetPortalMeta(portal, meta) chatInfo := b.composeOpenCodeChatInfo(displayTitle, instanceID) diff --git a/bridges/opencode/portal_send.go b/bridges/opencode/portal_send.go deleted file mode 100644 index 5be002ff..00000000 --- a/bridges/opencode/portal_send.go +++ /dev/null @@ -1,34 +0,0 @@ -package opencode - -import ( - "context" - "time" - - "maunium.net/go/mautrix/bridgev2" - - "github.com/beeper/agentremote" -) - -// sendViaPortal sends a pre-built message through bridgev2's QueueRemoteEvent pipeline. -func (oc *OpenCodeClient) sendViaPortal( - _ context.Context, - portal *bridgev2.Portal, - instanceID string, - converted *bridgev2.ConvertedMessage, -) error { - timing := agentremote.ResolveEventTiming(time.Now(), 0) - _, _, err := oc.ClientBase.SendViaPortalWithOptions(portal, oc.SenderForOpenCode(instanceID, false), "", timing.Timestamp, timing.StreamOrder, converted) - return err -} - -// sendSystemNoticeViaPortal is a convenience wrapper for sending MsgNotice via the pipeline. -func (oc *OpenCodeClient) sendSystemNoticeViaPortal(ctx context.Context, portal *bridgev2.Portal, msg string) { - pmeta := oc.PortalMeta(portal) - instanceID := "" - if pmeta != nil { - instanceID = pmeta.InstanceID - } - if err := oc.sendViaPortal(ctx, portal, instanceID, agentremote.BuildSystemNotice(msg)); err != nil { - oc.Log().Warn().Err(err).Msg("Failed to send system notice") - } -} diff --git a/client_base.go b/client_base.go index 2da15d20..8e3f2e9c 100644 --- a/client_base.go +++ b/client_base.go @@ -82,6 +82,14 @@ func (c *ClientBase) HumanUserID() networkid.UserID { return HumanUserID(c.HumanUserIDPrefix, login.ID) } +func (c *ClientBase) SendSystemMessage( + ctx context.Context, + portal *bridgev2.Portal, + message string, +) error { + return SendSystemMessage(ctx, c.GetUserLogin(), portal, bridgev2.EventSender{}, message) +} + func (c *ClientBase) SendViaPortal( portal *bridgev2.Portal, sender bridgev2.EventSender, diff --git a/helpers.go b/helpers.go index 8ca168d5..e3da0f95 100644 --- a/helpers.go +++ b/helpers.go @@ -32,59 +32,66 @@ func BuildMetaTypes(portal, message, userLogin, ghost func() any) database.MetaT } } -// BuildSystemNotice creates a ConvertedMessage containing a single MsgNotice part. -func BuildSystemNotice(body string) *bridgev2.ConvertedMessage { - return &bridgev2.ConvertedMessage{ - Parts: []*bridgev2.ConvertedMessagePart{{ - ID: networkid.PartID("0"), - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: body, - Mentions: &event.Mentions{}, - }, - }}, - } -} - // DMChatInfoParams holds the parameters for BuildDMChatInfo. type DMChatInfoParams struct { Title string + Topic string HumanUserID networkid.UserID LoginID networkid.UserLoginID + HumanSender *bridgev2.EventSender BotUserID networkid.UserID BotDisplayName string + BotSender *bridgev2.EventSender + BotUserInfo *bridgev2.UserInfo + BotMemberEventExtra map[string]any CanBackfill bool } // BuildDMChatInfo creates a ChatInfo for a DM room between a human user and a bot ghost. func BuildDMChatInfo(p DMChatInfoParams) *bridgev2.ChatInfo { + humanSender := bridgev2.EventSender{ + Sender: p.HumanUserID, + IsFromMe: true, + SenderLogin: p.LoginID, + } + if p.HumanSender != nil { + humanSender = *p.HumanSender + } + botSender := bridgev2.EventSender{ + Sender: p.BotUserID, + SenderLogin: p.LoginID, + } + if p.BotSender != nil { + botSender = *p.BotSender + } + botInfo := p.BotUserInfo + if botInfo == nil { + botInfo = &bridgev2.UserInfo{ + Name: ptr.Ptr(p.BotDisplayName), + IsBot: ptr.Ptr(true), + } + } + memberEventExtra := p.BotMemberEventExtra + if memberEventExtra == nil && p.BotDisplayName != "" { + memberEventExtra = map[string]any{ + "displayname": p.BotDisplayName, + } + } members := bridgev2.ChatMemberMap{ p.HumanUserID: { - EventSender: bridgev2.EventSender{ - Sender: p.HumanUserID, - IsFromMe: true, - SenderLogin: p.LoginID, - }, + EventSender: humanSender, Membership: event.MembershipJoin, }, p.BotUserID: { - EventSender: bridgev2.EventSender{ - Sender: p.BotUserID, - SenderLogin: p.LoginID, - }, - Membership: event.MembershipJoin, - UserInfo: &bridgev2.UserInfo{ - Name: ptr.Ptr(p.BotDisplayName), - IsBot: ptr.Ptr(true), - }, - MemberEventExtra: map[string]any{ - "displayname": p.BotDisplayName, - }, + EventSender: botSender, + Membership: event.MembershipJoin, + UserInfo: botInfo, + MemberEventExtra: memberEventExtra, }, } return &bridgev2.ChatInfo{ Name: ptr.Ptr(p.Title), + Topic: ptr.NonZero(p.Topic), Type: ptr.Ptr(database.RoomTypeDM), CanBackfill: p.CanBackfill, Members: &bridgev2.ChatMemberList{ @@ -97,10 +104,15 @@ func BuildDMChatInfo(p DMChatInfoParams) *bridgev2.ChatInfo { type LoginDMChatInfoParams struct { Title string + Topic string Login *bridgev2.UserLogin HumanUserIDPrefix string + HumanSender *bridgev2.EventSender BotUserID networkid.UserID BotDisplayName string + BotSender *bridgev2.EventSender + BotUserInfo *bridgev2.UserInfo + BotMemberEventExtra map[string]any CanBackfill bool } @@ -109,15 +121,80 @@ func BuildLoginDMChatInfo(p LoginDMChatInfoParams) *bridgev2.ChatInfo { return nil } return BuildDMChatInfo(DMChatInfoParams{ - Title: p.Title, - HumanUserID: HumanUserID(p.HumanUserIDPrefix, p.Login.ID), - LoginID: p.Login.ID, - BotUserID: p.BotUserID, - BotDisplayName: p.BotDisplayName, - CanBackfill: p.CanBackfill, + Title: p.Title, + Topic: p.Topic, + HumanUserID: HumanUserID(p.HumanUserIDPrefix, p.Login.ID), + LoginID: p.Login.ID, + HumanSender: p.HumanSender, + BotUserID: p.BotUserID, + BotDisplayName: p.BotDisplayName, + BotSender: p.BotSender, + BotUserInfo: p.BotUserInfo, + BotMemberEventExtra: p.BotMemberEventExtra, + CanBackfill: p.CanBackfill, }) } +type ConfigureDMPortalParams struct { + Portal *bridgev2.Portal + Title string + Topic string + OtherUserID networkid.UserID + Save bool + MutatePortal func(*bridgev2.Portal) +} + +func ConfigureDMPortal(ctx context.Context, p ConfigureDMPortalParams) error { + if p.Portal == nil { + return fmt.Errorf("missing portal") + } + p.Portal.RoomType = database.RoomTypeDM + p.Portal.OtherUserID = p.OtherUserID + p.Portal.Name = strings.TrimSpace(p.Title) + p.Portal.NameSet = p.Portal.Name != "" + p.Portal.Topic = strings.TrimSpace(p.Topic) + p.Portal.TopicSet = p.Portal.Topic != "" + if p.MutatePortal != nil { + p.MutatePortal(p.Portal) + } + if !p.Save { + return nil + } + return p.Portal.Save(ctx) +} + +type PreConvertedRemoteMessageParams struct { + PortalKey networkid.PortalKey + Sender bridgev2.EventSender + MsgID networkid.MessageID + IDPrefix string + LogKey string + Timestamp time.Time + StreamOrder int64 + Converted *bridgev2.ConvertedMessage +} + +func BuildPreConvertedRemoteMessage(p PreConvertedRemoteMessageParams) *simplevent.PreConvertedMessage { + if p.MsgID == "" { + p.MsgID = NewMessageID(p.IDPrefix) + } + timing := ResolveEventTiming(p.Timestamp, p.StreamOrder) + return &simplevent.PreConvertedMessage{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventMessage, + PortalKey: p.PortalKey, + Sender: p.Sender, + Timestamp: timing.Timestamp, + StreamOrder: timing.StreamOrder, + LogContext: func(c zerolog.Context) zerolog.Context { + return c.Str(p.LogKey, string(p.MsgID)) + }, + }, + ID: p.MsgID, + Data: p.Converted, + } +} + // SendViaPortalParams holds the parameters for SendViaPortal. type SendViaPortalParams struct { Login *bridgev2.UserLogin @@ -141,24 +218,17 @@ func SendViaPortal(p SendViaPortalParams) (id.EventID, networkid.MessageID, erro if p.Login == nil || p.Login.Bridge == nil { return "", p.MsgID, fmt.Errorf("bridge unavailable") } - if p.MsgID == "" { - p.MsgID = NewMessageID(p.IDPrefix) - } - timing := ResolveEventTiming(p.Timestamp, p.StreamOrder) - evt := &simplevent.PreConvertedMessage{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventMessage, - PortalKey: p.Portal.PortalKey, - Sender: p.Sender, - Timestamp: timing.Timestamp, - StreamOrder: timing.StreamOrder, - LogContext: func(c zerolog.Context) zerolog.Context { - return c.Str(p.LogKey, string(p.MsgID)) - }, - }, - ID: p.MsgID, - Data: p.Converted, - } + evt := BuildPreConvertedRemoteMessage(PreConvertedRemoteMessageParams{ + PortalKey: p.Portal.PortalKey, + Sender: p.Sender, + MsgID: p.MsgID, + IDPrefix: p.IDPrefix, + LogKey: p.LogKey, + Timestamp: p.Timestamp, + StreamOrder: p.StreamOrder, + Converted: p.Converted, + }) + p.MsgID = evt.ID result := p.Login.QueueRemoteEvent(evt) if !result.Success { if result.Error != nil { @@ -311,6 +381,42 @@ func NormalizeAbsolutePath(path string) (string, error) { return filepath.Clean(expanded), nil } +func SendSystemMessage( + ctx context.Context, + login *bridgev2.UserLogin, + portal *bridgev2.Portal, + sender bridgev2.EventSender, + body string, +) error { + body = strings.TrimSpace(body) + if login == nil || login.Bridge == nil { + return fmt.Errorf("bridge unavailable") + } + if portal == nil || portal.MXID == "" { + return fmt.Errorf("invalid portal") + } + if body == "" { + return nil + } + content := &event.Content{ + Parsed: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: body, + Mentions: &event.Mentions{}, + }, + } + if login.Bridge.Bot != nil { + _, err := login.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, content, nil) + return err + } + intent, ok := portal.GetIntentFor(ctx, sender, login, bridgev2.RemoteEventMessage) + if !ok || intent == nil { + return fmt.Errorf("intent resolution failed") + } + _, err := intent.SendMessage(ctx, portal.MXID, event.EventMessage, content, nil) + return err +} + // BuildBotUserInfo returns a UserInfo for an AI bot ghost with the given name and identifiers. func BuildBotUserInfo(name string, identifiers ...string) *bridgev2.UserInfo { return &bridgev2.UserInfo{ @@ -475,21 +581,14 @@ func BuildContinuationMessage( FormattedBody: rendered.FormattedBody, Mentions: &event.Mentions{}, } - msgID := NewMessageID(idPrefix) - timing := ResolveEventTiming(timestamp, streamOrder) - return &simplevent.PreConvertedMessage{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventMessage, - PortalKey: portal, - Sender: sender, - Timestamp: timing.Timestamp, - StreamOrder: timing.StreamOrder, - LogContext: func(c zerolog.Context) zerolog.Context { - return c.Str(logKey, string(msgID)) - }, - }, - ID: msgID, - Data: &bridgev2.ConvertedMessage{ + return BuildPreConvertedRemoteMessage(PreConvertedRemoteMessageParams{ + PortalKey: portal, + Sender: sender, + IDPrefix: idPrefix, + LogKey: logKey, + Timestamp: timestamp, + StreamOrder: streamOrder, + Converted: &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{{ ID: networkid.PartID("0"), Type: event.EventMessage, @@ -497,7 +596,7 @@ func BuildContinuationMessage( Extra: map[string]any{"com.beeper.continuation": true}, }}, }, - } + }) } // coalesceStrings returns the first non-empty string from the arguments. diff --git a/helpers_test.go b/helpers_test.go index aeebef7a..fe7894ef 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -1,9 +1,13 @@ package agentremote import ( + "context" "testing" + "time" + "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" ) @@ -40,3 +44,104 @@ func TestApplyAgentRemoteBridgeInfo(t *testing.T) { t.Fatalf("expected dm room type, got %q", content.BeeperRoomTypeV2) } } + +func TestBuildPreConvertedRemoteMessagePreservesTimingAndGeneratesID(t *testing.T) { + ts := time.Date(2026, time.March, 12, 12, 0, 0, 0, time.UTC) + first := BuildPreConvertedRemoteMessage(PreConvertedRemoteMessageParams{ + PortalKey: networkid.PortalKey{}, + MsgID: "first", + LogKey: "msg_id", + Timestamp: ts, + StreamOrder: 10, + }) + second := BuildPreConvertedRemoteMessage(PreConvertedRemoteMessageParams{ + PortalKey: networkid.PortalKey{}, + MsgID: "second", + LogKey: "msg_id", + Timestamp: ts, + StreamOrder: 11, + }) + generated := BuildPreConvertedRemoteMessage(PreConvertedRemoteMessageParams{ + PortalKey: networkid.PortalKey{}, + IDPrefix: "test", + LogKey: "msg_id", + }) + if first.GetStreamOrder() != 10 { + t.Fatalf("expected first stream order 10, got %d", first.GetStreamOrder()) + } + if second.GetStreamOrder() != 11 { + t.Fatalf("expected second stream order 11, got %d", second.GetStreamOrder()) + } + if second.GetStreamOrder() <= first.GetStreamOrder() { + t.Fatalf("expected stream order to remain increasing") + } + if got := string(generated.ID); len(got) < len("test:") || got[:5] != "test:" { + t.Fatalf("expected generated id with prefix, got %q", got) + } +} + +func TestBuildLoginDMChatInfoSupportsCustomMembers(t *testing.T) { + login := &bridgev2.UserLogin{UserLogin: &database.UserLogin{ID: "login-1"}} + humanSender := bridgev2.EventSender{Sender: "custom-human", SenderLogin: login.ID, IsFromMe: true} + botSender := bridgev2.EventSender{Sender: "custom-bot", SenderLogin: login.ID} + info := BuildLoginDMChatInfo(LoginDMChatInfoParams{ + Title: "Room", + Topic: "Topic", + Login: login, + HumanUserIDPrefix: "ignored", + HumanSender: &humanSender, + BotUserID: "bot-1", + BotDisplayName: "Bot", + BotSender: &botSender, + BotUserInfo: &bridgev2.UserInfo{Identifiers: []string{"bot@example.com"}}, + BotMemberEventExtra: map[string]any{ + "displayname": "Bot", + "custom": true, + }, + CanBackfill: true, + }) + if info == nil || info.Topic == nil || *info.Topic != "Topic" { + t.Fatalf("expected topic to be preserved, got %#v", info) + } + if !info.CanBackfill { + t.Fatal("expected can_backfill to be preserved") + } + if got := info.Members.MemberMap[HumanUserID("ignored", login.ID)].EventSender; got.Sender != humanSender.Sender { + t.Fatalf("expected custom human sender, got %#v", got) + } + botMember := info.Members.MemberMap["bot-1"] + if botMember.EventSender.Sender != botSender.Sender { + t.Fatalf("expected custom bot sender, got %#v", botMember.EventSender) + } + if botMember.UserInfo == nil || len(botMember.UserInfo.Identifiers) != 1 { + t.Fatalf("expected bot user info to be preserved, got %#v", botMember.UserInfo) + } + if got, _ := botMember.MemberEventExtra["custom"].(bool); !got { + t.Fatalf("expected bot member extra to be preserved, got %#v", botMember.MemberEventExtra) + } +} + +func TestConfigureDMPortalSetsDMFields(t *testing.T) { + portal := &bridgev2.Portal{Portal: &database.Portal{}} + if err := ConfigureDMPortal(context.Background(), ConfigureDMPortalParams{ + Portal: portal, + Title: "Room", + Topic: "Topic", + OtherUserID: "bot-1", + Save: false, + }); err != nil { + t.Fatalf("ConfigureDMPortal returned error: %v", err) + } + if portal.RoomType != database.RoomTypeDM { + t.Fatalf("expected DM room type, got %q", portal.RoomType) + } + if portal.OtherUserID != "bot-1" { + t.Fatalf("expected other user id to be set, got %q", portal.OtherUserID) + } + if portal.Name != "Room" || !portal.NameSet { + t.Fatalf("expected portal name to be set, got %q / %v", portal.Name, portal.NameSet) + } + if portal.Topic != "Topic" || !portal.TopicSet { + t.Fatalf("expected portal topic to be set, got %q / %v", portal.Topic, portal.TopicSet) + } +} diff --git a/sdk/client.go b/sdk/client.go index f4b872bd..cf5d6736 100644 --- a/sdk/client.go +++ b/sdk/client.go @@ -76,12 +76,7 @@ func newSDKClient(login *bridgev2.UserLogin, cfg *Config) *sdkClient { return data.RoomID }, SendNotice: func(ctx context.Context, portal *bridgev2.Portal, msg string) { - // Best-effort notice via bot intent. - if login.Bridge != nil && login.Bridge.Bot != nil && portal != nil && portal.MXID != "" { - _, _ = login.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ - Parsed: &event.MessageEventContent{MsgType: event.MsgNotice, Body: msg}, - }, nil) - } + _ = agentremote.SendSystemMessage(ctx, login, portal, bridgev2.EventSender{}, msg) }, }) if cfg != nil && cfg.TurnManagement != nil { diff --git a/sdk/conversation.go b/sdk/conversation.go index 6a759c75..5ecb0deb 100644 --- a/sdk/conversation.go +++ b/sdk/conversation.go @@ -223,10 +223,7 @@ func (c *Conversation) SendMedia(ctx context.Context, data []byte, mediaType, fi // SendNotice sends a notice message. func (c *Conversation) SendNotice(ctx context.Context, text string) error { - return c.sendMessageContent(ctx, &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: text, - }) + return agentremote.SendSystemMessage(ctx, c.login, c.portal, c.sender, text) } func (c *Conversation) sendMessageContent(ctx context.Context, content *event.MessageEventContent) error { From cc0f4b495913165a16134af6aa156554628c62e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sun, 29 Mar 2026 17:45:11 +0200 Subject: [PATCH 2/6] Format: align struct fields and whitespace Apply non-functional formatting changes: align struct field names and types in DMChatInfoParams and LoginDMChatInfoParams, adjust spacing in BuildDMChatInfo (Membership) and provisioning.go (CanBackfill). No behavioral changes; only whitespace/formatting adjustments. --- bridges/ai/chat.go | 2 +- bridges/codex/client.go | 2 +- bridges/openclaw/client.go | 2 +- bridges/openclaw/provisioning.go | 2 +- bridges/opencode/host.go | 2 +- client_base.go | 8 --- helpers.go | 42 ++++++------- helpers_test.go | 105 ------------------------------- 8 files changed, 26 insertions(+), 139 deletions(-) diff --git a/bridges/ai/chat.go b/bridges/ai/chat.go index e92c7234..692c6bbf 100644 --- a/bridges/ai/chat.go +++ b/bridges/ai/chat.go @@ -994,7 +994,7 @@ func (oc *AIClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Porta if oc == nil { return } - if err := oc.ClientBase.SendSystemMessage(ctx, portal, message); err != nil { + if err := agentremote.SendSystemMessage(ctx, oc.UserLogin, portal, bridgev2.EventSender{}, message); err != nil { oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to send system notice") } } diff --git a/bridges/codex/client.go b/bridges/codex/client.go index 91c7431d..613812dc 100644 --- a/bridges/codex/client.go +++ b/bridges/codex/client.go @@ -1768,7 +1768,7 @@ func (cc *CodexClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Po if cc == nil { return } - if err := cc.ClientBase.SendSystemMessage(ctx, portal, strings.TrimSpace(message)); err != nil { + if err := agentremote.SendSystemMessage(ctx, cc.UserLogin, portal, bridgev2.EventSender{}, strings.TrimSpace(message)); err != nil { cc.log.Warn().Err(err).Msg("Failed to send system notice") } } diff --git a/bridges/openclaw/client.go b/bridges/openclaw/client.go index b8a55936..8b71ae70 100644 --- a/bridges/openclaw/client.go +++ b/bridges/openclaw/client.go @@ -739,7 +739,7 @@ func (oc *OpenClawClient) sendSystemNotice(ctx context.Context, portal *bridgev2 if oc == nil || portal == nil || strings.TrimSpace(msg) == "" { return } - if err := oc.ClientBase.SendSystemMessage(ctx, portal, msg); err != nil { + if err := agentremote.SendSystemMessage(ctx, oc.UserLogin, portal, bridgev2.EventSender{}, msg); err != nil { if oc.UserLogin != nil { oc.UserLogin.Log.Warn().Err(err).Msg("Failed to send system notice") } diff --git a/bridges/openclaw/provisioning.go b/bridges/openclaw/provisioning.go index 19ddb0ae..1be5d93b 100644 --- a/bridges/openclaw/provisioning.go +++ b/bridges/openclaw/provisioning.go @@ -352,7 +352,7 @@ func (oc *OpenClawClient) syntheticDMPortalInfo(agentID, displayName string, use BotMemberEventExtra: map[string]any{ "displayname": displayName, }, - CanBackfill: true, + CanBackfill: true, }) } diff --git a/bridges/opencode/host.go b/bridges/opencode/host.go index 0dba4ce9..3746f071 100644 --- a/bridges/opencode/host.go +++ b/bridges/opencode/host.go @@ -30,7 +30,7 @@ func (oc *OpenCodeClient) SendSystemNotice(ctx context.Context, portal *bridgev2 if oc == nil { return } - if err := oc.ClientBase.SendSystemMessage(ctx, portal, msg); err != nil { + if err := agentremote.SendSystemMessage(ctx, oc.UserLogin, portal, bridgev2.EventSender{}, msg); err != nil { oc.Log().Warn().Err(err).Msg("Failed to send system notice") } } diff --git a/client_base.go b/client_base.go index 8e3f2e9c..2da15d20 100644 --- a/client_base.go +++ b/client_base.go @@ -82,14 +82,6 @@ func (c *ClientBase) HumanUserID() networkid.UserID { return HumanUserID(c.HumanUserIDPrefix, login.ID) } -func (c *ClientBase) SendSystemMessage( - ctx context.Context, - portal *bridgev2.Portal, - message string, -) error { - return SendSystemMessage(ctx, c.GetUserLogin(), portal, bridgev2.EventSender{}, message) -} - func (c *ClientBase) SendViaPortal( portal *bridgev2.Portal, sender bridgev2.EventSender, diff --git a/helpers.go b/helpers.go index e3da0f95..77e14f25 100644 --- a/helpers.go +++ b/helpers.go @@ -34,17 +34,17 @@ func BuildMetaTypes(portal, message, userLogin, ghost func() any) database.MetaT // DMChatInfoParams holds the parameters for BuildDMChatInfo. type DMChatInfoParams struct { - Title string - Topic string - HumanUserID networkid.UserID - LoginID networkid.UserLoginID - HumanSender *bridgev2.EventSender - BotUserID networkid.UserID - BotDisplayName string - BotSender *bridgev2.EventSender - BotUserInfo *bridgev2.UserInfo + Title string + Topic string + HumanUserID networkid.UserID + LoginID networkid.UserLoginID + HumanSender *bridgev2.EventSender + BotUserID networkid.UserID + BotDisplayName string + BotSender *bridgev2.EventSender + BotUserInfo *bridgev2.UserInfo BotMemberEventExtra map[string]any - CanBackfill bool + CanBackfill bool } // BuildDMChatInfo creates a ChatInfo for a DM room between a human user and a bot ghost. @@ -80,7 +80,7 @@ func BuildDMChatInfo(p DMChatInfoParams) *bridgev2.ChatInfo { members := bridgev2.ChatMemberMap{ p.HumanUserID: { EventSender: humanSender, - Membership: event.MembershipJoin, + Membership: event.MembershipJoin, }, p.BotUserID: { EventSender: botSender, @@ -103,17 +103,17 @@ func BuildDMChatInfo(p DMChatInfoParams) *bridgev2.ChatInfo { } type LoginDMChatInfoParams struct { - Title string - Topic string - Login *bridgev2.UserLogin - HumanUserIDPrefix string - HumanSender *bridgev2.EventSender - BotUserID networkid.UserID - BotDisplayName string - BotSender *bridgev2.EventSender - BotUserInfo *bridgev2.UserInfo + Title string + Topic string + Login *bridgev2.UserLogin + HumanUserIDPrefix string + HumanSender *bridgev2.EventSender + BotUserID networkid.UserID + BotDisplayName string + BotSender *bridgev2.EventSender + BotUserInfo *bridgev2.UserInfo BotMemberEventExtra map[string]any - CanBackfill bool + CanBackfill bool } func BuildLoginDMChatInfo(p LoginDMChatInfoParams) *bridgev2.ChatInfo { diff --git a/helpers_test.go b/helpers_test.go index fe7894ef..aeebef7a 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -1,13 +1,9 @@ package agentremote import ( - "context" "testing" - "time" - "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" ) @@ -44,104 +40,3 @@ func TestApplyAgentRemoteBridgeInfo(t *testing.T) { t.Fatalf("expected dm room type, got %q", content.BeeperRoomTypeV2) } } - -func TestBuildPreConvertedRemoteMessagePreservesTimingAndGeneratesID(t *testing.T) { - ts := time.Date(2026, time.March, 12, 12, 0, 0, 0, time.UTC) - first := BuildPreConvertedRemoteMessage(PreConvertedRemoteMessageParams{ - PortalKey: networkid.PortalKey{}, - MsgID: "first", - LogKey: "msg_id", - Timestamp: ts, - StreamOrder: 10, - }) - second := BuildPreConvertedRemoteMessage(PreConvertedRemoteMessageParams{ - PortalKey: networkid.PortalKey{}, - MsgID: "second", - LogKey: "msg_id", - Timestamp: ts, - StreamOrder: 11, - }) - generated := BuildPreConvertedRemoteMessage(PreConvertedRemoteMessageParams{ - PortalKey: networkid.PortalKey{}, - IDPrefix: "test", - LogKey: "msg_id", - }) - if first.GetStreamOrder() != 10 { - t.Fatalf("expected first stream order 10, got %d", first.GetStreamOrder()) - } - if second.GetStreamOrder() != 11 { - t.Fatalf("expected second stream order 11, got %d", second.GetStreamOrder()) - } - if second.GetStreamOrder() <= first.GetStreamOrder() { - t.Fatalf("expected stream order to remain increasing") - } - if got := string(generated.ID); len(got) < len("test:") || got[:5] != "test:" { - t.Fatalf("expected generated id with prefix, got %q", got) - } -} - -func TestBuildLoginDMChatInfoSupportsCustomMembers(t *testing.T) { - login := &bridgev2.UserLogin{UserLogin: &database.UserLogin{ID: "login-1"}} - humanSender := bridgev2.EventSender{Sender: "custom-human", SenderLogin: login.ID, IsFromMe: true} - botSender := bridgev2.EventSender{Sender: "custom-bot", SenderLogin: login.ID} - info := BuildLoginDMChatInfo(LoginDMChatInfoParams{ - Title: "Room", - Topic: "Topic", - Login: login, - HumanUserIDPrefix: "ignored", - HumanSender: &humanSender, - BotUserID: "bot-1", - BotDisplayName: "Bot", - BotSender: &botSender, - BotUserInfo: &bridgev2.UserInfo{Identifiers: []string{"bot@example.com"}}, - BotMemberEventExtra: map[string]any{ - "displayname": "Bot", - "custom": true, - }, - CanBackfill: true, - }) - if info == nil || info.Topic == nil || *info.Topic != "Topic" { - t.Fatalf("expected topic to be preserved, got %#v", info) - } - if !info.CanBackfill { - t.Fatal("expected can_backfill to be preserved") - } - if got := info.Members.MemberMap[HumanUserID("ignored", login.ID)].EventSender; got.Sender != humanSender.Sender { - t.Fatalf("expected custom human sender, got %#v", got) - } - botMember := info.Members.MemberMap["bot-1"] - if botMember.EventSender.Sender != botSender.Sender { - t.Fatalf("expected custom bot sender, got %#v", botMember.EventSender) - } - if botMember.UserInfo == nil || len(botMember.UserInfo.Identifiers) != 1 { - t.Fatalf("expected bot user info to be preserved, got %#v", botMember.UserInfo) - } - if got, _ := botMember.MemberEventExtra["custom"].(bool); !got { - t.Fatalf("expected bot member extra to be preserved, got %#v", botMember.MemberEventExtra) - } -} - -func TestConfigureDMPortalSetsDMFields(t *testing.T) { - portal := &bridgev2.Portal{Portal: &database.Portal{}} - if err := ConfigureDMPortal(context.Background(), ConfigureDMPortalParams{ - Portal: portal, - Title: "Room", - Topic: "Topic", - OtherUserID: "bot-1", - Save: false, - }); err != nil { - t.Fatalf("ConfigureDMPortal returned error: %v", err) - } - if portal.RoomType != database.RoomTypeDM { - t.Fatalf("expected DM room type, got %q", portal.RoomType) - } - if portal.OtherUserID != "bot-1" { - t.Fatalf("expected other user id to be set, got %q", portal.OtherUserID) - } - if portal.Name != "Room" || !portal.NameSet { - t.Fatalf("expected portal name to be set, got %q / %v", portal.Name, portal.NameSet) - } - if portal.Topic != "Topic" || !portal.TopicSet { - t.Fatalf("expected portal topic to be set, got %q / %v", portal.Topic, portal.TopicSet) - } -} From decc3433bb573aa42bd3c8132ca3fbc51c9dc7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sun, 29 Mar 2026 17:46:05 +0200 Subject: [PATCH 3/6] Remove unused imports in bridge packages Clean up imports across multiple bridge packages to address unused-import compile errors and tidy code. Removed unused imports (database, event, networkid) from codex, dummybridge, openclaw, and opencode files; added a missing time import in openclaw/events.go. Changes affect: bridges/codex/client.go, bridges/codex/directory_manager.go, bridges/dummybridge/bridge.go, bridges/openclaw/events.go, bridges/openclaw/provisioning.go, and bridges/opencode/opencode_portal.go. --- bridges/codex/client.go | 1 - bridges/codex/directory_manager.go | 1 - bridges/dummybridge/bridge.go | 1 - bridges/openclaw/events.go | 3 +-- bridges/openclaw/provisioning.go | 2 -- bridges/opencode/opencode_portal.go | 1 - 6 files changed, 1 insertion(+), 8 deletions(-) diff --git a/bridges/codex/client.go b/bridges/codex/client.go index 613812dc..bf4c72f2 100644 --- a/bridges/codex/client.go +++ b/bridges/codex/client.go @@ -13,7 +13,6 @@ import ( "time" "github.com/rs/zerolog" - "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" diff --git a/bridges/codex/directory_manager.go b/bridges/codex/directory_manager.go index 77dbe24b..c8f8ea9c 100644 --- a/bridges/codex/directory_manager.go +++ b/bridges/codex/directory_manager.go @@ -9,7 +9,6 @@ import ( "time" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/event" "github.com/beeper/agentremote" diff --git a/bridges/dummybridge/bridge.go b/bridges/dummybridge/bridge.go index 8269b6aa..c59c0e1d 100644 --- a/bridges/dummybridge/bridge.go +++ b/bridges/dummybridge/bridge.go @@ -10,7 +10,6 @@ import ( "github.com/rs/zerolog" "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "github.com/beeper/agentremote" diff --git a/bridges/openclaw/events.go b/bridges/openclaw/events.go index fd7557e7..3f7b5a96 100644 --- a/bridges/openclaw/events.go +++ b/bridges/openclaw/events.go @@ -4,14 +4,13 @@ import ( "context" "fmt" "strings" + "time" "github.com/rs/zerolog" "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/simplevent" - "maunium.net/go/mautrix/event" "github.com/beeper/agentremote" "github.com/beeper/agentremote/pkg/shared/openclawconv" diff --git a/bridges/openclaw/provisioning.go b/bridges/openclaw/provisioning.go index 1be5d93b..fee341d9 100644 --- a/bridges/openclaw/provisioning.go +++ b/bridges/openclaw/provisioning.go @@ -11,8 +11,6 @@ import ( "go.mau.fi/util/ptr" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/event" "github.com/beeper/agentremote" "github.com/beeper/agentremote/pkg/shared/stringutil" diff --git a/bridges/opencode/opencode_portal.go b/bridges/opencode/opencode_portal.go index 01cad086..2d1dac8a 100644 --- a/bridges/opencode/opencode_portal.go +++ b/bridges/opencode/opencode_portal.go @@ -8,7 +8,6 @@ import ( "github.com/google/uuid" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" "github.com/beeper/agentremote" "github.com/beeper/agentremote/bridges/opencode/api" From b9a6abbe00c25207ff191f8f8676b493c82bec8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sun, 29 Mar 2026 18:04:59 +0200 Subject: [PATCH 4/6] Use buildOpenClawDMChatInfo and keep message raw Rename OpenClaw helper syntheticDMPortalInfo to buildOpenClawDMChatInfo and update its call sites in GetChatInfo and createConfiguredAgentDM for clarity. Also change CodexClient.sendSystemNotice to pass the message through unchanged (remove strings.TrimSpace) so system messages are not altered before sending. --- bridges/codex/client.go | 2 +- bridges/openclaw/client.go | 2 +- bridges/openclaw/provisioning.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bridges/codex/client.go b/bridges/codex/client.go index bf4c72f2..138d1c4c 100644 --- a/bridges/codex/client.go +++ b/bridges/codex/client.go @@ -1767,7 +1767,7 @@ func (cc *CodexClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Po if cc == nil { return } - if err := agentremote.SendSystemMessage(ctx, cc.UserLogin, portal, bridgev2.EventSender{}, strings.TrimSpace(message)); err != nil { + if err := agentremote.SendSystemMessage(ctx, cc.UserLogin, portal, bridgev2.EventSender{}, message); err != nil { cc.log.Warn().Err(err).Msg("Failed to send system notice") } } diff --git a/bridges/openclaw/client.go b/bridges/openclaw/client.go index 8b71ae70..c7b49c79 100644 --- a/bridges/openclaw/client.go +++ b/bridges/openclaw/client.go @@ -336,7 +336,7 @@ func (oc *OpenClawClient) GetChatInfo(ctx context.Context, portal *bridgev2.Port roomType := openClawRoomType(meta) agentID := stringutil.TrimDefault(meta.OpenClawDMTargetAgentID, meta.OpenClawAgentID) if roomType == database.RoomTypeDM && agentID != "" { - info := oc.syntheticDMPortalInfo(agentID, title, nil) + info := oc.buildOpenClawDMChatInfo(agentID, title, nil) info.Topic = ptr.NonZero(oc.topicForPortal(meta)) info.Type = ptr.Ptr(roomType) info.CanBackfill = true diff --git a/bridges/openclaw/provisioning.go b/bridges/openclaw/provisioning.go index fee341d9..0b157213 100644 --- a/bridges/openclaw/provisioning.go +++ b/bridges/openclaw/provisioning.go @@ -311,7 +311,7 @@ func (oc *OpenClawClient) createConfiguredAgentDM(ctx context.Context, agent gat }); err != nil { return nil, fmt.Errorf("failed to save openclaw dm portal: %w", err) } - chatInfo := oc.syntheticDMPortalInfo(agentID, meta.OpenClawDMTargetAgentName, info) + chatInfo := oc.buildOpenClawDMChatInfo(agentID, meta.OpenClawDMTargetAgentName, info) _, err = bridgesdk.EnsurePortalLifecycle(ctx, bridgesdk.PortalLifecycleOptions{ Login: oc.UserLogin, Portal: portal, @@ -330,7 +330,7 @@ func (oc *OpenClawClient) createConfiguredAgentDM(ctx context.Context, agent gat }, nil } -func (oc *OpenClawClient) syntheticDMPortalInfo(agentID, displayName string, userInfo *bridgev2.UserInfo) *bridgev2.ChatInfo { +func (oc *OpenClawClient) buildOpenClawDMChatInfo(agentID, displayName string, userInfo *bridgev2.UserInfo) *bridgev2.ChatInfo { if strings.TrimSpace(displayName) == "" { displayName = oc.displayNameForAgent(agentID) } From 75f8bd5760b6862311bb9e709aa55e4592155cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sun, 29 Mar 2026 18:35:54 +0200 Subject: [PATCH 5/6] Use portal sender for system notices and fix SendNotice Propagate and use a portal-specific EventSender for system notices across Codex and OpenClaw bridges and the SDK. Key changes: - bridges/codex: delay composeCodexChatInfo until after portal configuration and add background retry send when portal.MXID is not yet set. - bridges/openclaw: change sendSystemNotice to accept a sender, pass approval-specific sender from manager, and update related calls; tweak provisioning error text. - helpers.go: return the actual event ID (evt.ID) on errors/success instead of using p.MsgID. - sdk/client.go: provide senderForPortal to approval flow and use it when sending notices. - sdk/conversation.go: make Conversation.SendNotice trim input and send via sendMessageContent (uses conversation intent), avoiding direct agentremote call. - sdk/turn_test.go: add test to verify Conversation.SendNotice uses the conversation intent and records sent message details; expand test matrix API stub to record sent messages. These changes ensure system notices are sent with the correct sender identity, handle races when a portal's MXID isn't immediately available, and standardize event ID handling and notice delivery paths. --- bridges/codex/backfill.go | 2 +- bridges/codex/client.go | 25 +++++++++++++++-- bridges/openclaw/client.go | 4 +-- bridges/openclaw/manager.go | 6 ++-- bridges/openclaw/provisioning.go | 2 +- helpers.go | 7 ++--- sdk/client.go | 17 +++++------ sdk/conversation.go | 10 ++++++- sdk/turn_test.go | 48 ++++++++++++++++++++++++++++++-- 9 files changed, 97 insertions(+), 24 deletions(-) diff --git a/bridges/codex/backfill.go b/bridges/codex/backfill.go index 498d884d..bfe92fb1 100644 --- a/bridges/codex/backfill.go +++ b/bridges/codex/backfill.go @@ -234,7 +234,6 @@ func (cc *CodexClient) ensureCodexThreadPortal(ctx context.Context, existing *br meta.Slug = codexThreadSlug(threadID) } - info := cc.composeCodexChatInfo(portal, title, true) if err := agentremote.ConfigureDMPortal(ctx, agentremote.ConfigureDMPortalParams{ Portal: portal, Title: title, @@ -243,6 +242,7 @@ func (cc *CodexClient) ensureCodexThreadPortal(ctx context.Context, existing *br }); err != nil { return nil, false, err } + info := cc.composeCodexChatInfo(portal, title, true) created, err = bridgesdk.EnsurePortalLifecycle(ctx, bridgesdk.PortalLifecycleOptions{ Login: cc.UserLogin, Portal: portal, diff --git a/bridges/codex/client.go b/bridges/codex/client.go index 138d1c4c..cb99a410 100644 --- a/bridges/codex/client.go +++ b/bridges/codex/client.go @@ -1764,10 +1764,31 @@ func (cc *CodexClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2 } func (cc *CodexClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Portal, message string) { - if cc == nil { + if cc == nil || portal == nil || strings.TrimSpace(message) == "" { return } - if err := agentremote.SendSystemMessage(ctx, cc.UserLogin, portal, bridgev2.EventSender{}, message); err != nil { + send := func(sendCtx context.Context) error { + return agentremote.SendSystemMessage(sendCtx, cc.UserLogin, portal, cc.senderForPortal(), message) + } + if portal.MXID == "" { + go func() { + retryCtx := cc.backgroundContext(ctx) + for attempt := 0; attempt < 3; attempt++ { + if portal.MXID != "" { + if err := send(retryCtx); err != nil { + cc.log.Warn().Err(err).Msg("Failed to send system notice") + } + return + } + time.Sleep(250 * time.Millisecond) + } + if err := send(retryCtx); err != nil { + cc.log.Warn().Err(err).Msg("Failed to send system notice") + } + }() + return + } + if err := send(ctx); err != nil { cc.log.Warn().Err(err).Msg("Failed to send system notice") } } diff --git a/bridges/openclaw/client.go b/bridges/openclaw/client.go index c7b49c79..1b654449 100644 --- a/bridges/openclaw/client.go +++ b/bridges/openclaw/client.go @@ -735,11 +735,11 @@ func (oc *OpenClawClient) senderForAgent(agentID string, fromMe bool) bridgev2.E } } -func (oc *OpenClawClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Portal, msg string) { +func (oc *OpenClawClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Portal, sender bridgev2.EventSender, msg string) { if oc == nil || portal == nil || strings.TrimSpace(msg) == "" { return } - if err := agentremote.SendSystemMessage(ctx, oc.UserLogin, portal, bridgev2.EventSender{}, msg); err != nil { + if err := agentremote.SendSystemMessage(ctx, oc.UserLogin, portal, sender, msg); err != nil { if oc.UserLogin != nil { oc.UserLogin.Log.Warn().Err(err).Msg("Failed to send system notice") } diff --git a/bridges/openclaw/manager.go b/bridges/openclaw/manager.go index e866b9d5..45f04526 100644 --- a/bridges/openclaw/manager.go +++ b/bridges/openclaw/manager.go @@ -111,7 +111,7 @@ func newOpenClawManager(client *OpenClawClient) *openClawManager { agentremote.DecisionToString(decision, "allow-once", "allow-always", "deny")) }, SendNotice: func(ctx context.Context, portal *bridgev2.Portal, msg string) { - client.sendSystemNotice(ctx, portal, msg) + client.sendSystemNotice(ctx, portal, mgr.approvalSenderForPortal(portal), msg) }, DBMetadata: func(prompt agentremote.ApprovalPromptMessage) any { return &MessageMetadata{ @@ -375,7 +375,7 @@ func (m *openClawManager) expireLocalApproval(ctx context.Context, approvalID st } if sessionKey != "" { if portal := m.resolvePortal(ctx, sessionKey); portal != nil && portal.MXID != "" { - m.client.sendSystemNotice(ctx, portal, "OpenClaw approval expired") + m.client.sendSystemNotice(ctx, portal, m.approvalSenderForPortal(portal), "OpenClaw approval expired") } } m.approvalFlow.ResolveExternal(ctx, approvalID, agentremote.ApprovalDecisionPayload{ @@ -1804,7 +1804,7 @@ func (m *openClawManager) handleApprovalResolved(ctx context.Context, payload ga "reason": reason, }) } else { - m.client.sendSystemNotice(ctx, portal, openClawApprovalResolvedText(payload.Decision)) + m.client.sendSystemNotice(ctx, portal, m.approvalSenderForPortal(portal), openClawApprovalResolvedText(payload.Decision)) } m.approvalFlow.ResolveExternal(ctx, approvalID, agentremote.ApprovalDecisionPayload{ ApprovalID: approvalID, diff --git a/bridges/openclaw/provisioning.go b/bridges/openclaw/provisioning.go index 0b157213..0e6e7d3c 100644 --- a/bridges/openclaw/provisioning.go +++ b/bridges/openclaw/provisioning.go @@ -309,7 +309,7 @@ func (oc *OpenClawClient) createConfiguredAgentDM(ctx context.Context, agent gat OtherUserID: openClawGhostUserID(agentID), Save: false, }); err != nil { - return nil, fmt.Errorf("failed to save openclaw dm portal: %w", err) + return nil, fmt.Errorf("failed to configure openclaw dm portal: %w", err) } chatInfo := oc.buildOpenClawDMChatInfo(agentID, meta.OpenClawDMTargetAgentName, info) _, err = bridgesdk.EnsurePortalLifecycle(ctx, bridgesdk.PortalLifecycleOptions{ diff --git a/helpers.go b/helpers.go index 77e14f25..df2c7362 100644 --- a/helpers.go +++ b/helpers.go @@ -228,15 +228,14 @@ func SendViaPortal(p SendViaPortalParams) (id.EventID, networkid.MessageID, erro StreamOrder: p.StreamOrder, Converted: p.Converted, }) - p.MsgID = evt.ID result := p.Login.QueueRemoteEvent(evt) if !result.Success { if result.Error != nil { - return "", p.MsgID, fmt.Errorf("send failed: %w", result.Error) + return "", evt.ID, fmt.Errorf("send failed: %w", result.Error) } - return "", p.MsgID, fmt.Errorf("send failed") + return "", evt.ID, fmt.Errorf("send failed") } - return result.EventID, p.MsgID, nil + return result.EventID, evt.ID, nil } // SendEditViaPortal queues a pre-built edit through bridgev2's remote event pipeline. diff --git a/sdk/client.go b/sdk/client.go index cf5d6736..14f9da5d 100644 --- a/sdk/client.go +++ b/sdk/client.go @@ -53,6 +53,12 @@ type sdkClient struct { func newSDKClient(login *bridgev2.UserLogin, cfg *Config) *sdkClient { identity := resolveProviderIdentity(cfg) + senderForPortal := func(*bridgev2.Portal) bridgev2.EventSender { + if cfg != nil && cfg.Agent != nil { + return cfg.Agent.EventSender(login.ID) + } + return bridgev2.EventSender{} + } c := &sdkClient{ cfg: cfg, userLogin: login, @@ -60,13 +66,8 @@ func newSDKClient(login *bridgev2.UserLogin, cfg *Config) *sdkClient { } c.InitClientBase(login, c) c.approvalFlow = agentremote.NewApprovalFlow(agentremote.ApprovalFlowConfig[*pendingSDKApprovalData]{ - Login: func() *bridgev2.UserLogin { return c.userLogin }, - Sender: func(portal *bridgev2.Portal) bridgev2.EventSender { - if cfg != nil && cfg.Agent != nil { - return cfg.Agent.EventSender(login.ID) - } - return bridgev2.EventSender{} - }, + Login: func() *bridgev2.UserLogin { return c.userLogin }, + Sender: senderForPortal, IDPrefix: identity.IDPrefix, LogKey: identity.LogKey, RoomIDFromData: func(data *pendingSDKApprovalData) id.RoomID { @@ -76,7 +77,7 @@ func newSDKClient(login *bridgev2.UserLogin, cfg *Config) *sdkClient { return data.RoomID }, SendNotice: func(ctx context.Context, portal *bridgev2.Portal, msg string) { - _ = agentremote.SendSystemMessage(ctx, login, portal, bridgev2.EventSender{}, msg) + _ = agentremote.SendSystemMessage(ctx, login, portal, senderForPortal(portal), msg) }, }) if cfg != nil && cfg.TurnManagement != nil { diff --git a/sdk/conversation.go b/sdk/conversation.go index 5ecb0deb..ba8ec271 100644 --- a/sdk/conversation.go +++ b/sdk/conversation.go @@ -223,7 +223,15 @@ func (c *Conversation) SendMedia(ctx context.Context, data []byte, mediaType, fi // SendNotice sends a notice message. func (c *Conversation) SendNotice(ctx context.Context, text string) error { - return agentremote.SendSystemMessage(ctx, c.login, c.portal, c.sender, text) + text = strings.TrimSpace(text) + if text == "" { + return nil + } + return c.sendMessageContent(ctx, &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: text, + Mentions: &event.Mentions{}, + }) } func (c *Conversation) sendMessageContent(ctx context.Context, content *event.MessageEventContent) error { diff --git a/sdk/turn_test.go b/sdk/turn_test.go index 88a3214b..006e7e2c 100644 --- a/sdk/turn_test.go +++ b/sdk/turn_test.go @@ -22,12 +22,24 @@ import ( ) type sdkTestMatrixAPI struct { - joinedRooms []id.RoomID + joinedRooms []id.RoomID + sentMessages []sdkTestSentMessage +} + +type sdkTestSentMessage struct { + roomID id.RoomID + eventType event.Type + content *event.Content } func (stma *sdkTestMatrixAPI) GetMXID() id.UserID { return "@ghost:test" } func (stma *sdkTestMatrixAPI) IsDoublePuppet() bool { return false } -func (stma *sdkTestMatrixAPI) SendMessage(context.Context, id.RoomID, event.Type, *event.Content, *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) { +func (stma *sdkTestMatrixAPI) SendMessage(_ context.Context, roomID id.RoomID, evtType event.Type, content *event.Content, _ *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) { + stma.sentMessages = append(stma.sentMessages, sdkTestSentMessage{ + roomID: roomID, + eventType: evtType, + content: content, + }) return nil, nil } func (stma *sdkTestMatrixAPI) SendState(context.Context, id.RoomID, event.Type, string, *event.Content, time.Time) (*mautrix.RespSendEvent, error) { @@ -801,6 +813,38 @@ func TestTurnWriterStartEnsuresSenderJoinedBeforePlaceholderSend(t *testing.T) { } } +func TestConversationSendNoticeUsesConversationIntent(t *testing.T) { + login := &bridgev2.UserLogin{UserLogin: &database.UserLogin{ID: "login-1"}} + portal := &bridgev2.Portal{Portal: &database.Portal{MXID: "!room:test"}} + intent := &sdkTestMatrixAPI{} + conv := newConversation(context.Background(), portal, login, bridgev2.EventSender{Sender: "agent-test", SenderLogin: login.ID}, nil) + conv.intentOverride = func(context.Context) (bridgev2.MatrixAPI, error) { return intent, nil } + + if err := conv.SendNotice(context.Background(), " hello "); err != nil { + t.Fatalf("SendNotice returned error: %v", err) + } + if len(intent.sentMessages) != 1 { + t.Fatalf("expected one notice to be sent through the conversation intent, got %d", len(intent.sentMessages)) + } + got := intent.sentMessages[0] + if got.roomID != portal.MXID { + t.Fatalf("expected notice to target %q, got %q", portal.MXID, got.roomID) + } + if got.eventType != event.EventMessage { + t.Fatalf("expected event type %q, got %q", event.EventMessage, got.eventType) + } + msg, ok := got.content.Parsed.(*event.MessageEventContent) + if !ok { + t.Fatalf("expected parsed message content, got %#v", got.content.Parsed) + } + if msg.MsgType != event.MsgNotice { + t.Fatalf("expected notice message, got %q", msg.MsgType) + } + if msg.Body != "hello" { + t.Fatalf("expected trimmed notice body, got %q", msg.Body) + } +} + func waitForTurnEnd(t *testing.T, turn *Turn, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) From 12610d40ca65f85df38a0b5134b535970c80e3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sun, 29 Mar 2026 19:30:52 +0200 Subject: [PATCH 6/6] Update client.go --- bridges/codex/client.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bridges/codex/client.go b/bridges/codex/client.go index cb99a410..e10dcccf 100644 --- a/bridges/codex/client.go +++ b/bridges/codex/client.go @@ -1782,6 +1782,10 @@ func (cc *CodexClient) sendSystemNotice(ctx context.Context, portal *bridgev2.Po } time.Sleep(250 * time.Millisecond) } + if portal.MXID == "" { + cc.log.Warn().Msg("Portal MXID never became available, dropping system notice") + return + } if err := send(retryCtx); err != nil { cc.log.Warn().Err(err).Msg("Failed to send system notice") }