diff --git a/bridges/ai/chat.go b/bridges/ai/chat.go index 37adf897..46c85a65 100644 --- a/bridges/ai/chat.go +++ b/bridges/ai/chat.go @@ -743,16 +743,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) @@ -1054,17 +1057,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 := 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/backfill.go b/bridges/codex/backfill.go index 177a7e8c..bfe92fb1 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 - + if err := agentremote.ConfigureDMPortal(ctx, agentremote.ConfigureDMPortalParams{ + Portal: portal, + Title: title, + OtherUserID: codexGhostID, + Save: false, + }); err != nil { + return nil, false, err + } info := cc.composeCodexChatInfo(portal, title, true) - portal.Name = title - portal.NameSet = 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 b6157e0a..e10dcccf 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" @@ -1539,18 +1538,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 +1764,37 @@ 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 || portal == nil || strings.TrimSpace(message) == "" { return } - timing := agentremote.ResolveEventTiming(time.Now(), 0) - cc.sendViaPortal(portal, agentremote.BuildSystemNotice(strings.TrimSpace(message)), "", timing.Timestamp, timing.StreamOrder) + 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 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") + } + }() + return + } + if err := send(ctx); 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..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" @@ -183,10 +182,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..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" @@ -229,13 +228,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 +257,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..1b654449 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.buildOpenClawDMChatInfo(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, sender bridgev2.EventSender, 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 := 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") + } + } } 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..3f7b5a96 100644 --- a/bridges/openclaw/events.go +++ b/bridges/openclaw/events.go @@ -6,14 +6,11 @@ import ( "strings" "time" - "github.com/google/uuid" "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" @@ -146,29 +143,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 +176,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..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.sendNoticeViaPortal(ctx, portal, msg, mgr.approvalSenderForPortal(portal)) + 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.sendNoticeViaPortal(ctx, portal, "OpenClaw approval expired", m.approvalSenderForPortal(portal)) + 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.sendNoticeViaPortal(ctx, portal, openClawApprovalResolvedText(payload.Decision), m.approvalSenderForPortal(portal)) + m.client.sendSystemNotice(ctx, portal, m.approvalSenderForPortal(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..0e6e7d3c 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" @@ -304,21 +302,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 { - 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 - } + 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 configure openclaw dm portal: %w", err) + } + chatInfo := oc.buildOpenClawDMChatInfo(agentID, meta.OpenClawDMTargetAgentName, info) _, err = bridgesdk.EnsurePortalLifecycle(ctx, bridgesdk.PortalLifecycleOptions{ Login: oc.UserLogin, Portal: portal, @@ -337,35 +330,28 @@ func (oc *OpenClawClient) createConfiguredAgentDM(ctx context.Context, agent gat }, nil } -func (oc *OpenClawClient) syntheticDMPortalInfo(agentID, displayName string) *bridgev2.ChatInfo { +func (oc *OpenClawClient) buildOpenClawDMChatInfo(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..3746f071 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 := agentremote.SendSystemMessage(ctx, oc.UserLogin, portal, bridgev2.EventSender{}, 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..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" @@ -80,10 +79,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 +225,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/helpers.go b/helpers.go index 8ca168d5..df2c7362 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 - HumanUserID networkid.UserID - LoginID networkid.UserLoginID - BotUserID networkid.UserID - BotDisplayName string - CanBackfill bool + 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, - }, - Membership: event.MembershipJoin, + 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{ @@ -96,12 +103,17 @@ func BuildDMChatInfo(p DMChatInfoParams) *bridgev2.ChatInfo { } type LoginDMChatInfoParams struct { - Title string - Login *bridgev2.UserLogin - HumanUserIDPrefix string - BotUserID networkid.UserID - BotDisplayName string - CanBackfill bool + 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 } func BuildLoginDMChatInfo(p LoginDMChatInfoParams) *bridgev2.ChatInfo { @@ -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,32 +218,24 @@ 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, + }) 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. @@ -311,6 +380,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 +580,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 +595,7 @@ func BuildContinuationMessage( Extra: map[string]any{"com.beeper.continuation": true}, }}, }, - } + }) } // coalesceStrings returns the first non-empty string from the arguments. diff --git a/sdk/client.go b/sdk/client.go index f4b872bd..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,12 +77,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, senderForPortal(portal), msg) }, }) if cfg != nil && cfg.TurnManagement != nil { diff --git a/sdk/conversation.go b/sdk/conversation.go index 6a759c75..ba8ec271 100644 --- a/sdk/conversation.go +++ b/sdk/conversation.go @@ -223,9 +223,14 @@ 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 { + text = strings.TrimSpace(text) + if text == "" { + return nil + } return c.sendMessageContent(ctx, &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: text, + MsgType: event.MsgNotice, + Body: text, + Mentions: &event.Mentions{}, }) } 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)