refactor(runtime): drop non-session legacy context compatibility

This commit is contained in:
Hoshina 2026-04-01 20:56:48 +08:00
parent ca9652e120
commit 59dee895fc
45 changed files with 1083 additions and 1427 deletions

View file

@ -610,12 +610,6 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) {
if payload.SourceTool != "async_followup" { if payload.SourceTool != "async_followup" {
t.Fatalf("expected source tool async_followup, got %q", payload.SourceTool) t.Fatalf("expected source tool async_followup, got %q", payload.SourceTool)
} }
if payload.Channel != "cli" {
t.Fatalf("expected channel cli, got %q", payload.Channel)
}
if payload.ChatID != "direct" {
t.Fatalf("expected chat id direct, got %q", payload.ChatID)
}
if payload.ContentLen != len("background result") { if payload.ContentLen != len("background result") {
t.Fatalf("expected content len %d, got %d", len("background result"), payload.ContentLen) t.Fatalf("expected content len %d, got %d", len("background result"), payload.ContentLen)
} }

View file

@ -116,8 +116,6 @@ const (
// TurnStartPayload describes the start of a turn. // TurnStartPayload describes the start of a turn.
type TurnStartPayload struct { type TurnStartPayload struct {
Channel string
ChatID string
UserMessage string UserMessage string
MediaCount int MediaCount int
} }
@ -217,8 +215,6 @@ type SteeringInjectedPayload struct {
// FollowUpQueuedPayload describes an async follow-up queued back into the inbound bus. // FollowUpQueuedPayload describes an async follow-up queued back into the inbound bus.
type FollowUpQueuedPayload struct { type FollowUpQueuedPayload struct {
SourceTool string SourceTool string
Channel string
ChatID string
ContentLen int ContentLen int
} }

View file

@ -94,8 +94,6 @@ type LLMHookRequest struct {
Messages []providers.Message `json:"messages,omitempty"` Messages []providers.Message `json:"messages,omitempty"`
Tools []providers.ToolDefinition `json:"tools,omitempty"` Tools []providers.ToolDefinition `json:"tools,omitempty"`
Options map[string]any `json:"options,omitempty"` Options map[string]any `json:"options,omitempty"`
Channel string `json:"channel,omitempty"`
ChatID string `json:"chat_id,omitempty"`
GracefulTerminal bool `json:"graceful_terminal,omitempty"` GracefulTerminal bool `json:"graceful_terminal,omitempty"`
} }
@ -117,8 +115,6 @@ type LLMHookResponse struct {
Context *TurnContext `json:"context,omitempty"` Context *TurnContext `json:"context,omitempty"`
Model string `json:"model"` Model string `json:"model"`
Response *providers.LLMResponse `json:"response,omitempty"` Response *providers.LLMResponse `json:"response,omitempty"`
Channel string `json:"channel,omitempty"`
ChatID string `json:"chat_id,omitempty"`
} }
func (r *LLMHookResponse) Clone() *LLMHookResponse { func (r *LLMHookResponse) Clone() *LLMHookResponse {
@ -137,8 +133,6 @@ type ToolCallHookRequest struct {
Context *TurnContext `json:"context,omitempty"` Context *TurnContext `json:"context,omitempty"`
Tool string `json:"tool"` Tool string `json:"tool"`
Arguments map[string]any `json:"arguments,omitempty"` Arguments map[string]any `json:"arguments,omitempty"`
Channel string `json:"channel,omitempty"`
ChatID string `json:"chat_id,omitempty"`
} }
func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest { func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
@ -157,8 +151,6 @@ type ToolApprovalRequest struct {
Context *TurnContext `json:"context,omitempty"` Context *TurnContext `json:"context,omitempty"`
Tool string `json:"tool"` Tool string `json:"tool"`
Arguments map[string]any `json:"arguments,omitempty"` Arguments map[string]any `json:"arguments,omitempty"`
Channel string `json:"channel,omitempty"`
ChatID string `json:"chat_id,omitempty"`
} }
func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest { func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest {
@ -179,8 +171,6 @@ type ToolResultHookResponse struct {
Arguments map[string]any `json:"arguments,omitempty"` Arguments map[string]any `json:"arguments,omitempty"`
Result *tools.ToolResult `json:"result,omitempty"` Result *tools.ToolResult `json:"result,omitempty"`
Duration time.Duration `json:"duration"` Duration time.Duration `json:"duration"`
Channel string `json:"channel,omitempty"`
ChatID string `json:"chat_id,omitempty"`
} }
func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse { func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse {

View file

@ -107,14 +107,6 @@ const (
defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit." defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit."
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
handledToolResponseSummary = "Requested output delivered via tool attachment." handledToolResponseSummary = "Requested output delivered via tool attachment."
sessionKeyAgentPrefix = "agent:"
sessionKeyOpaquePrefix = "sk_"
metadataKeyAccountID = "account_id"
metadataKeyGuildID = "guild_id"
metadataKeyTeamID = "team_id"
metadataKeyReplyToMessage = "reply_to_message_id"
metadataKeyParentPeerKind = "parent_peer_kind"
metadataKeyParentPeerID = "parent_peer_id"
) )
func NewAgentLoop( func NewAgentLoop(
@ -234,9 +226,9 @@ func registerSharedTools(
messageTool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error { messageTool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel, Context: outboundCtx,
ChatID: chatID,
Content: content, Content: content,
ReplyToMessageID: replyToMessageID, ReplyToMessageID: replyToMessageID,
}) })
@ -657,8 +649,7 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
} }
al.bus.PublishOutbound(ctx, bus.OutboundMessage{ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: channel, Context: bus.NewOutboundContext(channel, chatID, ""),
ChatID: chatID,
Content: response, Content: response,
}) })
logger.InfoCF("agent", "Published outbound response", logger.InfoCF("agent", "Published outbound response",
@ -714,11 +705,7 @@ func outboundContextFromInbound(
channel, chatID, replyToMessageID string, channel, chatID, replyToMessageID string,
) bus.InboundContext { ) bus.InboundContext {
if inbound == nil { if inbound == nil {
return bus.ContextFromLegacyOutbound(bus.OutboundMessage{ return bus.NewOutboundContext(channel, chatID, replyToMessageID)
Channel: channel,
ChatID: chatID,
ReplyToMessageID: replyToMessageID,
})
} }
outboundCtx := *cloneInboundContext(inbound) outboundCtx := *cloneInboundContext(inbound)
@ -736,8 +723,6 @@ func outboundContextFromInbound(
func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage { func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage {
return bus.OutboundMessage{ return bus.OutboundMessage{
Channel: ts.channel,
ChatID: ts.chatID,
Context: outboundContextFromInbound( Context: outboundContextFromInbound(
ts.opts.InboundContext, ts.opts.InboundContext,
ts.channel, ts.channel,
@ -894,8 +879,6 @@ func (al *AgentLoop) logEvent(evt Event) {
switch payload := evt.Payload.(type) { switch payload := evt.Payload.(type) {
case TurnStartPayload: case TurnStartPayload:
fields["channel"] = payload.Channel
fields["chat_id"] = payload.ChatID
fields["user_len"] = len(payload.UserMessage) fields["user_len"] = len(payload.UserMessage)
fields["media_count"] = payload.MediaCount fields["media_count"] = payload.MediaCount
case TurnEndPayload: case TurnEndPayload:
@ -948,8 +931,6 @@ func (al *AgentLoop) logEvent(evt Event) {
fields["total_content_len"] = payload.TotalContentLen fields["total_content_len"] = payload.TotalContentLen
case FollowUpQueuedPayload: case FollowUpQueuedPayload:
fields["source_tool"] = payload.SourceTool fields["source_tool"] = payload.SourceTool
fields["channel"] = payload.Channel
fields["chat_id"] = payload.ChatID
fields["content_len"] = payload.ContentLen fields["content_len"] = payload.ContentLen
case InterruptReceivedPayload: case InterruptReceivedPayload:
fields["interrupt_kind"] = payload.Kind fields["interrupt_kind"] = payload.Kind
@ -1292,8 +1273,7 @@ func (al *AgentLoop) sendTranscriptionFeedback(
} }
err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{ err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{
Channel: channel, Context: bus.NewOutboundContext(channel, chatID, messageID),
ChatID: chatID,
Content: feedbackMsg, Content: feedbackMsg,
ReplyToMessageID: messageID, ReplyToMessageID: messageID,
}) })
@ -1369,13 +1349,15 @@ func (al *AgentLoop) ProcessDirectWithChannel(
} }
msg := bus.InboundMessage{ msg := bus.InboundMessage{
Channel: channel, Context: bus.InboundContext{
SenderID: "cron", Channel: channel,
ChatID: chatID, ChatID: chatID,
ChatType: "direct",
SenderID: "cron",
},
Content: content, Content: content,
SessionKey: sessionKey, SessionKey: sessionKey,
} }
msg.Context = bus.ContextFromLegacyInbound(msg)
return al.processMessage(ctx, msg) return al.processMessage(ctx, msg)
} }
@ -1481,7 +1463,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Channel: msg.Channel, Channel: msg.Channel,
ChatID: msg.ChatID, ChatID: msg.ChatID,
MessageID: msg.MessageID, MessageID: msg.MessageID,
ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage), ReplyToMessageID: msg.Context.ReplyToMessageID,
SenderID: msg.SenderID, SenderID: msg.SenderID,
SenderDisplayName: msg.Sender.DisplayName, SenderDisplayName: msg.Sender.DisplayName,
UserMessage: msg.Content, UserMessage: msg.Content,
@ -1515,18 +1497,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
registry := al.GetRegistry() registry := al.GetRegistry()
inboundCtx := normalizedInboundContext(msg) inboundCtx := normalizedInboundContext(msg)
channel := strings.TrimSpace(inboundCtx.Channel) route := registry.ResolveRoute(inboundCtx)
if channel == "" {
channel = msg.Channel
}
route := registry.ResolveRoute(routing.RouteInput{
Channel: channel,
AccountID: routeAccountID(msg),
Peer: extractPeer(msg),
ParentPeer: extractParentPeer(msg),
GuildID: routeGuildID(msg),
TeamID: routeTeamID(msg),
})
agent, ok := registry.GetAgent(route.AgentID) agent, ok := registry.GetAgent(route.AgentID)
if !ok { if !ok {
@ -1551,8 +1522,7 @@ func resolveScopeKey(routeSessionKey, msgSessionKey string) string {
} }
func isExplicitSessionKey(sessionKey string) bool { func isExplicitSessionKey(sessionKey string) bool {
sessionKey = strings.TrimSpace(strings.ToLower(sessionKey)) return session.IsExplicitSessionKey(sessionKey)
return strings.HasPrefix(sessionKey, sessionKeyAgentPrefix) || strings.HasPrefix(sessionKey, sessionKeyOpaquePrefix)
} }
func buildSessionAliases(canonicalKey string, keys ...string) []string { func buildSessionAliases(canonicalKey string, keys ...string) []string {
@ -1621,8 +1591,7 @@ func (al *AgentLoop) requeueInboundMessage(msg bus.InboundMessage) error {
pubCtx, cancel := context.WithTimeout(context.Background(), time.Second) pubCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel() defer cancel()
return al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ return al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: msg.Channel, Context: msg.Context,
ChatID: msg.ChatID,
Content: msg.Content, Content: msg.Content,
}) })
} }
@ -1679,7 +1648,7 @@ func (al *AgentLoop) processSystemMessage(
} }
// Use the origin session for context // Use the origin session for context
sessionKey := routing.BuildAgentMainSessionKey(agent.ID) sessionKey := session.BuildMainSessionKey(agent.ID)
return al.runAgentLoop(ctx, agent, processOptions{ return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: sessionKey, SessionKey: sessionKey,
@ -1739,8 +1708,6 @@ func (al *AgentLoop) runAgentLoop(
if opts.SendResponse && result.finalContent != "" { if opts.SendResponse && result.finalContent != "" {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Context: outboundContextFromInbound( Context: outboundContextFromInbound(
opts.InboundContext, opts.InboundContext,
opts.Channel, opts.Channel,
@ -1796,8 +1763,7 @@ func (al *AgentLoop) handleReasoning(
defer pubCancel() defer pubCancel()
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channelName, Context: bus.NewOutboundContext(channelName, channelID, ""),
ChatID: channelID,
Content: reasoningContent, Content: reasoningContent,
}); err != nil { }); err != nil {
// Treat context.DeadlineExceeded / context.Canceled as expected // Treat context.DeadlineExceeded / context.Canceled as expected
@ -1851,8 +1817,6 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
EventKindTurnStart, EventKindTurnStart,
ts.eventMeta("runTurn", "turn.start"), ts.eventMeta("runTurn", "turn.start"),
TurnStartPayload{ TurnStartPayload{
Channel: ts.channel,
ChatID: ts.chatID,
UserMessage: ts.userMessage, UserMessage: ts.userMessage,
MediaCount: len(ts.media), MediaCount: len(ts.media),
}, },
@ -2085,8 +2049,6 @@ turnLoop:
Messages: callMessages, Messages: callMessages,
Tools: providerToolDefs, Tools: providerToolDefs,
Options: llmOpts, Options: llmOpts,
Channel: ts.channel,
ChatID: ts.chatID,
GracefulTerminal: gracefulTerminal, GracefulTerminal: gracefulTerminal,
}) })
switch decision.normalizedAction() { switch decision.normalizedAction() {
@ -2314,8 +2276,6 @@ turnLoop:
Context: cloneTurnContext(ts.turnCtx), Context: cloneTurnContext(ts.turnCtx),
Model: llmModel, Model: llmModel,
Response: response, Response: response,
Channel: ts.channel,
ChatID: ts.chatID,
}) })
switch decision.normalizedAction() { switch decision.normalizedAction() {
case HookActionContinue, HookActionModify: case HookActionContinue, HookActionModify:
@ -2346,7 +2306,7 @@ turnLoop:
reasoningContent = response.ReasoningContent reasoningContent = response.ReasoningContent
} }
go al.handleReasoning( go al.handleReasoning(
turnCtx, ctx,
reasoningContent, reasoningContent,
ts.channel, ts.channel,
al.targetReasoningChannelID(ts.channel), al.targetReasoningChannelID(ts.channel),
@ -2467,8 +2427,6 @@ turnLoop:
Context: cloneTurnContext(ts.turnCtx), Context: cloneTurnContext(ts.turnCtx),
Tool: toolName, Tool: toolName,
Arguments: toolArgs, Arguments: toolArgs,
Channel: ts.channel,
ChatID: ts.chatID,
}) })
switch decision.normalizedAction() { switch decision.normalizedAction() {
case HookActionContinue, HookActionModify: case HookActionContinue, HookActionModify:
@ -2514,8 +2472,6 @@ turnLoop:
Context: cloneTurnContext(ts.turnCtx), Context: cloneTurnContext(ts.turnCtx),
Tool: toolName, Tool: toolName,
Arguments: toolArgs, Arguments: toolArgs,
Channel: ts.channel,
ChatID: ts.chatID,
}) })
if !approval.Approved { if !approval.Approved {
allResponsesHandled = false allResponsesHandled = false
@ -2605,8 +2561,6 @@ turnLoop:
ts.scope.meta(toolIteration, "runTurn", "turn.follow_up.queued"), ts.scope.meta(toolIteration, "runTurn", "turn.follow_up.queued"),
FollowUpQueuedPayload{ FollowUpQueuedPayload{
SourceTool: asyncToolName, SourceTool: asyncToolName,
Channel: ts.channel,
ChatID: ts.chatID,
ContentLen: len(content), ContentLen: len(content),
}, },
) )
@ -2614,10 +2568,13 @@ turnLoop:
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
_ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{
Channel: "system", Context: bus.InboundContext{
SenderID: fmt.Sprintf("async:%s", asyncToolName), Channel: "system",
ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID),
Content: content, ChatType: "direct",
SenderID: fmt.Sprintf("async:%s", asyncToolName),
},
Content: content,
}) })
} }
@ -2652,8 +2609,6 @@ turnLoop:
Arguments: toolArgs, Arguments: toolArgs,
Result: toolResult, Result: toolResult,
Duration: toolDuration, Duration: toolDuration,
Channel: ts.channel,
ChatID: ts.chatID,
}) })
switch decision.normalizedAction() { switch decision.normalizedAction() {
case HookActionContinue, HookActionModify: case HookActionContinue, HookActionModify:
@ -2692,9 +2647,13 @@ turnLoop:
parts = append(parts, part) parts = append(parts, part)
} }
outboundMedia := bus.OutboundMediaMessage{ outboundMedia := bus.OutboundMediaMessage{
Channel: ts.channel, Context: outboundContextFromInbound(
ChatID: ts.chatID, ts.opts.InboundContext,
Parts: parts, ts.channel,
ts.chatID,
ts.opts.ReplyToMessageID,
),
Parts: parts,
} }
if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) {
if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil {
@ -3758,84 +3717,6 @@ func mapCommandError(result commands.ExecuteResult) string {
return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err)
} }
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
if msg.Peer.Kind != "" {
peerID := msg.Peer.ID
if peerID == "" {
if msg.Peer.Kind == "direct" {
peerID = msg.SenderID
} else {
peerID = msg.ChatID
}
}
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
}
inboundCtx := normalizedInboundContext(msg)
peerKind := strings.TrimSpace(inboundCtx.ChatType)
if peerKind == "" {
return nil
}
peerID := strings.TrimSpace(inboundCtx.ChatID)
if peerKind == "direct" && peerID == "" {
peerID = strings.TrimSpace(inboundCtx.SenderID)
}
if peerID == "" {
return nil
}
return &routing.RoutePeer{Kind: peerKind, ID: peerID}
}
func inboundMetadata(msg bus.InboundMessage, key string) string {
if msg.Metadata == nil {
return ""
}
return msg.Metadata[key]
}
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
inboundCtx := normalizedInboundContext(msg)
if topicID := strings.TrimSpace(inboundCtx.TopicID); topicID != "" {
return &routing.RoutePeer{Kind: "topic", ID: topicID}
}
parentKind := inboundMetadata(msg, metadataKeyParentPeerKind)
parentID := inboundMetadata(msg, metadataKeyParentPeerID)
if parentKind == "" || parentID == "" {
return nil
}
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
}
func routeAccountID(msg bus.InboundMessage) string {
if accountID := strings.TrimSpace(normalizedInboundContext(msg).Account); accountID != "" {
return accountID
}
return inboundMetadata(msg, metadataKeyAccountID)
}
func routeGuildID(msg bus.InboundMessage) string {
inboundCtx := normalizedInboundContext(msg)
if strings.EqualFold(strings.TrimSpace(inboundCtx.SpaceType), "guild") {
return strings.TrimSpace(inboundCtx.SpaceID)
}
return inboundMetadata(msg, metadataKeyGuildID)
}
func routeTeamID(msg bus.InboundMessage) string {
inboundCtx := normalizedInboundContext(msg)
switch strings.ToLower(strings.TrimSpace(inboundCtx.SpaceType)) {
case "team", "workspace":
if spaceID := strings.TrimSpace(inboundCtx.SpaceID); spaceID != "" {
return spaceID
}
}
return inboundMetadata(msg, metadataKeyTeamID)
}
// isNativeSearchProvider reports whether the given LLM provider implements // isNativeSearchProvider reports whether the given LLM provider implements
// NativeSearchCapable and returns true for SupportsNativeSearch. // NativeSearchCapable and returns true for SupportsNativeSearch.
func isNativeSearchProvider(p providers.LLMProvider) bool { func isNativeSearchProvider(p providers.LLMProvider) bool {

View file

@ -140,7 +140,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
provider := &recordingProvider{} provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "discord", Channel: "discord",
SenderID: "discord:123", SenderID: "discord:123",
Sender: bus.SenderInfo{ Sender: bus.SenderInfo{
@ -148,7 +148,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
}, },
ChatID: "group-1", ChatID: "group-1",
Content: "hello", Content: "hello",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() error = %v", err) t.Fatalf("processMessage() error = %v", err)
} }
@ -199,12 +199,12 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) {
provider := &recordingProvider{} provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
SenderID: "telegram:123", SenderID: "telegram:123",
ChatID: "chat-1", ChatID: "chat-1",
Content: "/use shell explain how to list files", Content: "/use shell explain how to list files",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() error = %v", err) t.Fatalf("processMessage() error = %v", err)
} }
@ -289,12 +289,12 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) {
provider := &recordingProvider{} provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
SenderID: "telegram:123", SenderID: "telegram:123",
ChatID: "chat-1", ChatID: "chat-1",
Content: "/use shell", Content: "/use shell",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() arm error = %v", err) t.Fatalf("processMessage() arm error = %v", err)
} }
@ -302,12 +302,12 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) {
t.Fatalf("arm response = %q, want armed confirmation", response) t.Fatalf("arm response = %q, want armed confirmation", response)
} }
response, err = al.processMessage(context.Background(), bus.InboundMessage{ response, err = al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
SenderID: "telegram:123", SenderID: "telegram:123",
ChatID: "chat-1", ChatID: "chat-1",
Content: "explain how to list files", Content: "explain how to list files",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() follow-up error = %v", err) t.Fatalf("processMessage() follow-up error = %v", err)
} }
@ -620,12 +620,12 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
path: imagePath, path: imagePath,
}) })
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
ChatID: "chat1", ChatID: "chat1",
SenderID: "user1", SenderID: "user1",
Content: "take a screenshot of the screen and send it to me", Content: "take a screenshot of the screen and send it to me",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() error = %v", err) t.Fatalf("processMessage() error = %v", err)
} }
@ -662,21 +662,21 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
if defaultAgent == nil { if defaultAgent == nil {
t.Fatal("expected default agent") t.Fatal("expected default agent")
} }
route, _, err := al.resolveMessageRoute(bus.InboundMessage{ route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
ChatID: "chat1", ChatID: "chat1",
SenderID: "user1", SenderID: "user1",
Content: "take a screenshot of the screen and send it to me", Content: "take a screenshot of the screen and send it to me",
}) }))
if err != nil { if err != nil {
t.Fatalf("resolveMessageRoute() error = %v", err) t.Fatalf("resolveMessageRoute() error = %v", err)
} }
sessionKey := resolveScopeKey(al.allocateRouteSession(route, bus.InboundMessage{ sessionKey := resolveScopeKey(al.allocateRouteSession(route, testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
ChatID: "chat1", ChatID: "chat1",
SenderID: "user1", SenderID: "user1",
Content: "take a screenshot of the screen and send it to me", Content: "take a screenshot of the screen and send it to me",
}).SessionKey, "") })).SessionKey, "")
history := defaultAgent.Sessions.GetHistory(sessionKey) history := defaultAgent.Sessions.GetHistory(sessionKey)
if len(history) == 0 { if len(history) == 0 {
t.Fatal("expected session history to be saved") t.Fatal("expected session history to be saved")
@ -720,12 +720,12 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes
loop: al, loop: al,
}) })
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
ChatID: "chat1", ChatID: "chat1",
SenderID: "user1", SenderID: "user1",
Content: "take a screenshot of the screen and send it to me", Content: "take a screenshot of the screen and send it to me",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() error = %v", err) t.Fatalf("processMessage() error = %v", err)
} }
@ -740,41 +740,6 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes
} }
} }
func TestExtractPeer_UsesInboundContextWhenLegacyPeerMissing(t *testing.T) {
msg := bus.InboundMessage{
Context: bus.InboundContext{
Channel: "slack",
ChatID: "C001",
ChatType: "channel",
SenderID: "U001",
},
}
peer := extractPeer(msg)
if peer == nil {
t.Fatal("expected peer from inbound context")
}
if peer.Kind != "channel" || peer.ID != "C001" {
t.Fatalf("peer = %+v, want channel/C001", peer)
}
}
func TestExtractParentPeer_UsesInboundContextTopicID(t *testing.T) {
msg := bus.InboundMessage{
Context: bus.InboundContext{
TopicID: "thread-42",
},
}
parentPeer := extractParentPeer(msg)
if parentPeer == nil {
t.Fatal("expected parent peer from topic context")
}
if parentPeer.Kind != "topic" || parentPeer.ID != "thread-42" {
t.Fatalf("parent peer = %+v, want topic/thread-42", parentPeer)
}
}
func TestAppendEventContextFields_IncludesInboundRouteAndScope(t *testing.T) { func TestAppendEventContextFields_IncludesInboundRouteAndScope(t *testing.T) {
fields := map[string]any{} fields := map[string]any{}
@ -872,7 +837,7 @@ func TestResolveMessageRoute_UsesInboundContextAccountAndSpace(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "ok"}) al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "ok"})
route, _, err := al.resolveMessageRoute(bus.InboundMessage{ route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{
Context: bus.InboundContext{ Context: bus.InboundContext{
Channel: "slack", Channel: "slack",
Account: "workspace-a", Account: "workspace-a",
@ -883,7 +848,7 @@ func TestResolveMessageRoute_UsesInboundContextAccountAndSpace(t *testing.T) {
SpaceType: "workspace", SpaceType: "workspace",
}, },
Content: "hello", Content: "hello",
}) }))
if err != nil { if err != nil {
t.Fatalf("resolveMessageRoute() error = %v", err) t.Fatalf("resolveMessageRoute() error = %v", err)
} }
@ -926,12 +891,12 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) {
path: imagePath, path: imagePath,
}) })
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
ChatID: "chat1", ChatID: "chat1",
SenderID: "user1", SenderID: "user1",
Content: "take a screenshot of the screen and send it to me", Content: "take a screenshot of the screen and send it to me",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() error = %v", err) t.Fatalf("processMessage() error = %v", err)
} }
@ -1518,13 +1483,39 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms
timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout)
defer cancel() defer cancel()
response, err := h.al.processMessage(timeoutCtx, msg) response, err := h.al.processMessage(timeoutCtx, testInboundMessage(msg))
if err != nil { if err != nil {
tb.Fatalf("processMessage failed: %v", err) tb.Fatalf("processMessage failed: %v", err)
} }
return response return response
} }
func testInboundMessage(msg bus.InboundMessage) bus.InboundMessage {
if msg.Context.Channel == "" &&
msg.Context.Account == "" &&
msg.Context.ChatID == "" &&
msg.Context.ChatType == "" &&
msg.Context.TopicID == "" &&
msg.Context.SpaceID == "" &&
msg.Context.SpaceType == "" &&
msg.Context.SenderID == "" &&
msg.Context.MessageID == "" &&
!msg.Context.Mentioned &&
msg.Context.ReplyToMessageID == "" &&
msg.Context.ReplyToSenderID == "" &&
len(msg.Context.ReplyHandles) == 0 &&
len(msg.Context.Raw) == 0 {
msg.Context = bus.InboundContext{
Channel: msg.Channel,
ChatID: msg.ChatID,
ChatType: "direct",
SenderID: msg.SenderID,
MessageID: msg.MessageID,
}
}
return bus.NormalizeInboundMessage(msg)
}
const responseTimeout = 3 * time.Second const responseTimeout = 3 * time.Second
func TestProcessMessage_UsesRouteSessionKey(t *testing.T) { func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
@ -1550,20 +1541,16 @@ func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
msg := bus.InboundMessage{ msg := bus.InboundMessage{
Channel: "telegram", Context: bus.InboundContext{
SenderID: "user1", Channel: "telegram",
ChatID: "chat1", ChatID: "chat1",
Content: "hello", ChatType: "direct",
Peer: bus.Peer{ SenderID: "user1",
Kind: "direct",
ID: "user1",
}, },
Content: "hello",
} }
route := al.registry.ResolveRoute(routing.RouteInput{ route := al.registry.ResolveRoute(bus.NormalizeInboundMessage(msg).Context)
Channel: msg.Channel,
Peer: extractPeer(msg),
})
sessionKey := al.allocateRouteSession(route, msg).SessionKey sessionKey := al.allocateRouteSession(route, msg).SessionKey
defaultAgent := al.registry.GetDefaultAgent() defaultAgent := al.registry.GetDefaultAgent()
@ -1610,21 +1597,22 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
helper := testHelper{al: al} helper := testHelper{al: al}
baseMsg := bus.InboundMessage{ baseMsg := bus.InboundMessage{
Channel: "whatsapp", Context: bus.InboundContext{
SenderID: "user1", Channel: "whatsapp",
ChatID: "chat1", ChatID: "chat1",
Peer: bus.Peer{ ChatType: "direct",
Kind: "direct", SenderID: "user1",
ID: "user1",
}, },
} }
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: baseMsg.Channel, Context: bus.InboundContext{
SenderID: baseMsg.SenderID, Channel: baseMsg.Context.Channel,
ChatID: baseMsg.ChatID, ChatID: baseMsg.Context.ChatID,
Content: "/show channel", ChatType: baseMsg.Context.ChatType,
Peer: baseMsg.Peer, SenderID: baseMsg.Context.SenderID,
},
Content: "/show channel",
}) })
if showResp != "Current Channel: whatsapp" { if showResp != "Current Channel: whatsapp" {
t.Fatalf("unexpected /show reply: %q", showResp) t.Fatalf("unexpected /show reply: %q", showResp)
@ -1634,11 +1622,13 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
} }
fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: baseMsg.Channel, Context: bus.InboundContext{
SenderID: baseMsg.SenderID, Channel: baseMsg.Context.Channel,
ChatID: baseMsg.ChatID, ChatID: baseMsg.Context.ChatID,
Content: "/foo", ChatType: baseMsg.Context.ChatType,
Peer: baseMsg.Peer, SenderID: baseMsg.Context.SenderID,
},
Content: "/foo",
}) })
if fooResp != "LLM reply" { if fooResp != "LLM reply" {
t.Fatalf("unexpected /foo reply: %q", fooResp) t.Fatalf("unexpected /foo reply: %q", fooResp)
@ -1648,11 +1638,13 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
} }
newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: baseMsg.Channel, Context: bus.InboundContext{
SenderID: baseMsg.SenderID, Channel: baseMsg.Context.Channel,
ChatID: baseMsg.ChatID, ChatID: baseMsg.Context.ChatID,
Content: "/new", ChatType: baseMsg.Context.ChatType,
Peer: baseMsg.Peer, SenderID: baseMsg.Context.SenderID,
},
Content: "/new",
}) })
if newResp != "LLM reply" { if newResp != "LLM reply" {
t.Fatalf("unexpected /new reply: %q", newResp) t.Fatalf("unexpected /new reply: %q", newResp)
@ -1705,10 +1697,6 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
SenderID: "user1", SenderID: "user1",
ChatID: "chat1", ChatID: "chat1",
Content: "/switch model to deepseek", Content: "/switch model to deepseek",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}) })
if !strings.Contains(switchResp, "Switched model from local to deepseek") { if !strings.Contains(switchResp, "Switched model from local to deepseek") {
t.Fatalf("unexpected /switch reply: %q", switchResp) t.Fatalf("unexpected /switch reply: %q", switchResp)
@ -1719,10 +1707,6 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
SenderID: "user1", SenderID: "user1",
ChatID: "chat1", ChatID: "chat1",
Content: "/show model", Content: "/show model",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}) })
if !strings.Contains(showResp, "Current Model: deepseek (Provider: openrouter)") { if !strings.Contains(showResp, "Current Model: deepseek (Provider: openrouter)") {
t.Fatalf("unexpected /show model reply after switch: %q", showResp) t.Fatalf("unexpected /show model reply after switch: %q", showResp)
@ -1770,10 +1754,6 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
SenderID: "user1", SenderID: "user1",
ChatID: "chat1", ChatID: "chat1",
Content: "/switch model to missing", Content: "/switch model to missing",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}) })
if switchResp != `model "missing" not found in model_list or providers` { if switchResp != `model "missing" not found in model_list or providers` {
t.Fatalf("unexpected /switch error reply: %q", switchResp) t.Fatalf("unexpected /switch error reply: %q", switchResp)
@ -1784,10 +1764,6 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
SenderID: "user1", SenderID: "user1",
ChatID: "chat1", ChatID: "chat1",
Content: "/show model", Content: "/show model",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}) })
if !strings.Contains(showResp, "Current Model: local (Provider: openai)") { if !strings.Contains(showResp, "Current Model: local (Provider: openai)") {
t.Fatalf("unexpected /show model reply after rejected switch: %q", showResp) t.Fatalf("unexpected /show model reply after rejected switch: %q", showResp)
@ -1854,10 +1830,6 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
SenderID: "user1", SenderID: "user1",
ChatID: "chat1", ChatID: "chat1",
Content: "hello before switch", Content: "hello before switch",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}) })
if firstResp != "local reply" { if firstResp != "local reply" {
t.Fatalf("unexpected response before switch: %q", firstResp) t.Fatalf("unexpected response before switch: %q", firstResp)
@ -1877,10 +1849,6 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
SenderID: "user1", SenderID: "user1",
ChatID: "chat1", ChatID: "chat1",
Content: "/switch model to deepseek", Content: "/switch model to deepseek",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}) })
if !strings.Contains(switchResp, "Switched model from local to deepseek") { if !strings.Contains(switchResp, "Switched model from local to deepseek") {
t.Fatalf("unexpected /switch reply: %q", switchResp) t.Fatalf("unexpected /switch reply: %q", switchResp)
@ -1891,10 +1859,6 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
SenderID: "user1", SenderID: "user1",
ChatID: "chat1", ChatID: "chat1",
Content: "hello after switch", Content: "hello after switch",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}) })
if secondResp != "remote reply" { if secondResp != "remote reply" {
t.Fatalf("unexpected response after switch: %q", secondResp) t.Fatalf("unexpected response after switch: %q", secondResp)
@ -1984,10 +1948,6 @@ func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) {
SenderID: "user1", SenderID: "user1",
ChatID: "chat1", ChatID: "chat1",
Content: "hi", Content: "hi",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
}) })
if resp != "light reply" { if resp != "light reply" {
t.Fatalf("response = %q, want %q", resp, "light reply") t.Fatalf("response = %q, want %q", resp, "light reply")
@ -2260,22 +2220,16 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
if defaultAgent == nil { if defaultAgent == nil {
t.Fatal("No default agent found") t.Fatal("No default agent found")
} }
route := al.registry.ResolveRoute(routing.RouteInput{ route := al.registry.ResolveRoute(bus.InboundContext{
Channel: "test", Channel: "test",
Peer: &routing.RoutePeer{ ChatType: "direct",
Kind: "direct", SenderID: "cron",
ID: "cron",
},
}) })
history := defaultAgent.Sessions.GetHistory(al.allocateRouteSession(route, bus.InboundMessage{ history := defaultAgent.Sessions.GetHistory(al.allocateRouteSession(route, testInboundMessage(bus.InboundMessage{
Channel: "test", Channel: "test",
SenderID: "cron", SenderID: "cron",
ChatID: "chat1", ChatID: "chat1",
Peer: bus.Peer{ })).SessionKey)
Kind: "direct",
ID: "cron",
},
}).SessionKey)
if len(history) != 4 { if len(history) != 4 {
t.Fatalf("history len = %d, want 4", len(history)) t.Fatalf("history len = %d, want 4", len(history))
} }
@ -2533,8 +2487,7 @@ func TestHandleReasoning(t *testing.T) {
for i := 0; ; i++ { for i := 0; ; i++ {
fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond) fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{ err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{
Channel: "filler", Context: bus.NewOutboundContext("filler", "filler", ""),
ChatID: "filler",
Content: fmt.Sprintf("filler-%d", i), Content: fmt.Sprintf("filler-%d", i),
}) })
fillCancel() fillCancel()
@ -2608,12 +2561,12 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T
chManager.RegisterChannel("telegram", &fakeChannel{id: "reason-chat"}) chManager.RegisterChannel("telegram", &fakeChannel{id: "reason-chat"})
al.SetChannelManager(chManager) al.SetChannelManager(chManager)
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
SenderID: "user1", SenderID: "user1",
ChatID: "chat1", ChatID: "chat1",
Content: "hello", Content: "hello",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() error = %v", err) t.Fatalf("processMessage() error = %v", err)
} }
@ -2629,6 +2582,9 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T
if outbound.ChatID != "reason-chat" { if outbound.ChatID != "reason-chat" {
t.Fatalf("reasoning chatID = %q, want %q", outbound.ChatID, "reason-chat") t.Fatalf("reasoning chatID = %q, want %q", outbound.ChatID, "reason-chat")
} }
if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "reason-chat" {
t.Fatalf("unexpected reasoning context: %+v", outbound.Context)
}
if outbound.Content != "thinking trace" { if outbound.Content != "thinking trace" {
t.Fatalf("reasoning content = %q, want %q", outbound.Content, "thinking trace") t.Fatalf("reasoning content = %q, want %q", outbound.Content, "thinking trace")
} }
@ -2714,12 +2670,12 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
provider := &toolFeedbackProvider{filePath: heartbeatFile} provider := &toolFeedbackProvider{filePath: heartbeatFile}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "telegram", Channel: "telegram",
SenderID: "user-1", SenderID: "user-1",
ChatID: "chat-1", ChatID: "chat-1",
Content: "check tool feedback", Content: "check tool feedback",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() error = %v", err) t.Fatalf("processMessage() error = %v", err)
} }
@ -2735,6 +2691,9 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
if outbound.ChatID != "chat-1" { if outbound.ChatID != "chat-1" {
t.Fatalf("tool feedback chatID = %q, want %q", outbound.ChatID, "chat-1") t.Fatalf("tool feedback chatID = %q, want %q", outbound.ChatID, "chat-1")
} }
if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "chat-1" {
t.Fatalf("unexpected tool feedback context: %+v", outbound.Context)
}
if !strings.Contains(outbound.Content, "`read_file`") { if !strings.Contains(outbound.Content, "`read_file`") {
t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content) t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content)
} }
@ -3157,13 +3116,13 @@ func TestProcessMessage_ContextOverflowRecovery(t *testing.T) {
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"}) agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"})
} }
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "test", Channel: "test",
ChatID: "chat1", ChatID: "chat1",
SenderID: "user1", SenderID: "user1",
SessionKey: "test-session", SessionKey: "test-session",
Content: "trigger recovery", Content: "trigger recovery",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() error = %v", err) t.Fatalf("processMessage() error = %v", err)
} }
@ -3199,12 +3158,12 @@ func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) {
return &providers.LLMResponse{Content: "Anthropic recovery success"}, nil return &providers.LLMResponse{Content: "Anthropic recovery success"}, nil
} }
response, err := al.processMessage(context.Background(), bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "test", Channel: "test",
ChatID: "chat1", ChatID: "chat1",
SenderID: "user1", SenderID: "user1",
Content: "hello", Content: "hello",
}) }))
if err != nil { if err != nil {
t.Fatalf("processMessage() error = %v", err) t.Fatalf("processMessage() error = %v", err)
} }

View file

@ -3,6 +3,7 @@ package agent
import ( import (
"sync" "sync"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
@ -64,9 +65,9 @@ func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
return agent, ok return agent, ok
} }
// ResolveRoute determines which agent handles the message. // ResolveRoute determines which agent handles the normalized inbound context.
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute { func (r *AgentRegistry) ResolveRoute(inbound bus.InboundContext) routing.ResolvedRoute {
return r.resolver.ResolveRoute(input) return r.resolver.ResolveRoute(inbound)
} }
// ListAgentIDs returns all registered agent IDs. // ListAgentIDs returns all registered agent IDs.

View file

@ -8,7 +8,6 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
) )
@ -332,7 +331,7 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance {
return agent return agent
} }
if parsed := routing.ParseAgentSessionKey(sessionKey); parsed != nil { if parsed := session.ParseLegacyAgentSessionKey(sessionKey); parsed != nil {
if agent, ok := registry.GetAgent(parsed.AgentID); ok { if agent, ok := registry.GetAgent(parsed.AgentID); ok {
return agent return agent
} }

View file

@ -366,14 +366,13 @@ func TestDrainBusToSteering_RequeuesDifferentScopeMessage(t *testing.T) {
al := NewAgentLoop(cfg, msgBus, &mockProvider{}) al := NewAgentLoop(cfg, msgBus, &mockProvider{})
activeMsg := bus.InboundMessage{ activeMsg := bus.InboundMessage{
Channel: "telegram", Context: bus.InboundContext{
SenderID: "user1", Channel: "telegram",
ChatID: "chat1", ChatID: "chat1",
Content: "active turn", ChatType: "direct",
Peer: bus.Peer{ SenderID: "user1",
Kind: "direct",
ID: "user1",
}, },
Content: "active turn",
} }
activeScope, activeAgentID, ok := al.resolveSteeringTarget(activeMsg) activeScope, activeAgentID, ok := al.resolveSteeringTarget(activeMsg)
if !ok { if !ok {
@ -381,14 +380,13 @@ func TestDrainBusToSteering_RequeuesDifferentScopeMessage(t *testing.T) {
} }
otherMsg := bus.InboundMessage{ otherMsg := bus.InboundMessage{
Channel: "telegram", Context: bus.InboundContext{
SenderID: "user2", Channel: "telegram",
ChatID: "chat2", ChatID: "chat2",
Content: "other session", ChatType: "direct",
Peer: bus.Peer{ SenderID: "user2",
Kind: "direct",
ID: "user2",
}, },
Content: "other session",
} }
otherScope, _, ok := al.resolveSteeringTarget(otherMsg) otherScope, _, ok := al.resolveSteeringTarget(otherMsg)
if !ok { if !ok {
@ -425,7 +423,7 @@ func TestDrainBusToSteering_RequeuesDifferentScopeMessage(t *testing.T) {
case <-ctx.Done(): case <-ctx.Done():
t.Fatalf("timeout waiting for requeued message on outbound bus") t.Fatalf("timeout waiting for requeued message on outbound bus")
case requeued := <-msgBus.OutboundChan(): case requeued := <-msgBus.OutboundChan():
if requeued.Channel != otherMsg.Channel || requeued.ChatID != otherMsg.ChatID || if requeued.Context.Channel != otherMsg.Context.Channel || requeued.Context.ChatID != otherMsg.Context.ChatID ||
requeued.Content != otherMsg.Content { requeued.Content != otherMsg.Content {
t.Fatalf("requeued message mismatch: got %+v want %+v", requeued, otherMsg) t.Fatalf("requeued message mismatch: got %+v want %+v", requeued, otherMsg)
} }
@ -842,24 +840,22 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) {
}() }()
first := bus.InboundMessage{ first := bus.InboundMessage{
Channel: "test", Context: bus.InboundContext{
SenderID: "user1", Channel: "test",
ChatID: "chat1", ChatID: "chat1",
Content: "first message", ChatType: "direct",
Peer: bus.Peer{ SenderID: "user1",
Kind: "direct",
ID: "user1",
}, },
Content: "first message",
} }
late := bus.InboundMessage{ late := bus.InboundMessage{
Channel: "test", Context: bus.InboundContext{
SenderID: "user1", Channel: "test",
ChatID: "chat1", ChatID: "chat1",
Content: "late append", ChatType: "direct",
Peer: bus.Peer{ SenderID: "user1",
Kind: "direct",
ID: "user1",
}, },
Content: "late append",
} }
pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second)
@ -950,7 +946,7 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.
}, },
} }
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID) sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
provider := &blockingDirectProvider{ provider := &blockingDirectProvider{
firstStarted: make(chan struct{}), firstStarted: make(chan struct{}),
releaseFirst: make(chan struct{}), releaseFirst: make(chan struct{}),
@ -1117,7 +1113,7 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
}, },
} }
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID) sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
al.SetMediaStore(store) al.SetMediaStore(store)
@ -1225,7 +1221,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) {
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
al.RegisterTool(tool1) al.RegisterTool(tool1)
al.RegisterTool(tool2) al.RegisterTool(tool2)
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID) sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
sub := al.SubscribeEvents(32) sub := al.SubscribeEvents(32)
defer al.UnsubscribeEvents(sub.ID) defer al.UnsubscribeEvents(sub.ID)
@ -1379,7 +1375,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
started := make(chan struct{}) started := make(chan struct{})
al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started}) al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started})
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID) sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
defaultAgent := al.registry.GetDefaultAgent() defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil { if defaultAgent == nil {

View file

@ -12,6 +12,12 @@ import (
// ErrBusClosed is returned when publishing to a closed MessageBus. // ErrBusClosed is returned when publishing to a closed MessageBus.
var ErrBusClosed = errors.New("message bus closed") var ErrBusClosed = errors.New("message bus closed")
var (
ErrMissingInboundContext = errors.New("inbound message context is required")
ErrMissingOutboundContext = errors.New("outbound message context is required")
ErrMissingOutboundMediaContext = errors.New("outbound media context is required")
)
const defaultBusBufferSize = 64 const defaultBusBufferSize = 64
// StreamDelegate is implemented by the channel Manager to provide streaming // StreamDelegate is implemented by the channel Manager to provide streaming
@ -80,6 +86,9 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error
} }
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
if msg.Context.isZero() {
return ErrMissingInboundContext
}
msg = NormalizeInboundMessage(msg) msg = NormalizeInboundMessage(msg)
return publish(ctx, mb, mb.inbound, msg) return publish(ctx, mb, mb.inbound, msg)
} }
@ -89,6 +98,9 @@ func (mb *MessageBus) InboundChan() <-chan InboundMessage {
} }
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
if msg.Context.isZero() {
return ErrMissingOutboundContext
}
msg = NormalizeOutboundMessage(msg) msg = NormalizeOutboundMessage(msg)
return publish(ctx, mb, mb.outbound, msg) return publish(ctx, mb, mb.outbound, msg)
} }
@ -98,6 +110,9 @@ func (mb *MessageBus) OutboundChan() <-chan OutboundMessage {
} }
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
if msg.Context.isZero() {
return ErrMissingOutboundMediaContext
}
msg = NormalizeOutboundMediaMessage(msg) msg = NormalizeOutboundMediaMessage(msg)
return publish(ctx, mb, mb.outboundMedia, msg) return publish(ctx, mb, mb.outboundMedia, msg)
} }

View file

@ -14,10 +14,13 @@ func TestPublishConsume(t *testing.T) {
ctx := context.Background() ctx := context.Background()
msg := InboundMessage{ msg := InboundMessage{
Channel: "test", Context: InboundContext{
SenderID: "user1", Channel: "test",
ChatID: "chat1", ChatID: "chat1",
Content: "hello", ChatType: "direct",
SenderID: "user1",
},
Content: "hello",
} }
if err := mb.PublishInbound(ctx, msg); err != nil { if err := mb.PublishInbound(ctx, msg); err != nil {
@ -45,25 +48,25 @@ func TestPublishConsume(t *testing.T) {
} }
} }
func TestPublishInbound_NormalizesLegacyFieldsIntoContext(t *testing.T) { func TestPublishInbound_NormalizesContext(t *testing.T) {
mb := NewMessageBus() mb := NewMessageBus()
defer mb.Close() defer mb.Close()
msg := InboundMessage{ msg := InboundMessage{
Channel: "slack", Context: InboundContext{
SenderID: "U123", Channel: "slack",
ChatID: "C456/1712", Account: "workspace-a",
Content: "hello", ChatID: "C456/1712",
MessageID: "1712.01", ChatType: "group",
Peer: Peer{Kind: "group", ID: "C456"}, TopicID: "1712",
Metadata: map[string]string{ SpaceID: "T001",
"account_id": "workspace-a", SpaceType: "team",
"team_id": "T001", SenderID: "U123",
"reply_to_message_id": "1700.01", MessageID: "1712.01",
"is_mentioned": "true", ReplyToMessageID: "1700.01",
"parent_peer_kind": "topic", Mentioned: true,
"parent_peer_id": "1712",
}, },
Content: "hello",
} }
if err := mb.PublishInbound(context.Background(), msg); err != nil { if err := mb.PublishInbound(context.Background(), msg); err != nil {
@ -94,7 +97,7 @@ func TestPublishInbound_NormalizesLegacyFieldsIntoContext(t *testing.T) {
} }
} }
func TestPublishInbound_MirrorsContextIntoLegacyFields(t *testing.T) { func TestPublishInbound_MirrorsContextIntoConvenienceFields(t *testing.T) {
mb := NewMessageBus() mb := NewMessageBus()
defer mb.Close() defer mb.Close()
@ -132,27 +135,8 @@ func TestPublishInbound_MirrorsContextIntoLegacyFields(t *testing.T) {
if got.MessageID != "777" { if got.MessageID != "777" {
t.Fatalf("expected legacy message ID 777, got %q", got.MessageID) t.Fatalf("expected legacy message ID 777, got %q", got.MessageID)
} }
if got.Peer.Kind != "group" || got.Peer.ID != "-1001" { if got.Context.Account != "bot-a" || got.Context.SpaceID != "guild-9" || got.Context.TopicID != "42" {
t.Fatalf("expected legacy peer group/-1001, got %q/%q", got.Peer.Kind, got.Peer.ID) t.Fatalf("unexpected normalized context: %+v", got.Context)
}
if got.Metadata["account_id"] != "bot-a" {
t.Fatalf("expected mirrored account_id bot-a, got %q", got.Metadata["account_id"])
}
if got.Metadata["guild_id"] != "guild-9" {
t.Fatalf("expected mirrored guild_id guild-9, got %q", got.Metadata["guild_id"])
}
if got.Metadata["parent_peer_kind"] != "topic" || got.Metadata["parent_peer_id"] != "42" {
t.Fatalf(
"expected mirrored topic parent peer, got %q/%q",
got.Metadata["parent_peer_kind"],
got.Metadata["parent_peer_id"],
)
}
if got.Metadata["reply_to_message_id"] != "666" {
t.Fatalf("expected mirrored reply_to_message_id 666, got %q", got.Metadata["reply_to_message_id"])
}
if got.Metadata["is_mentioned"] != "true" {
t.Fatalf("expected mirrored is_mentioned true, got %q", got.Metadata["is_mentioned"])
} }
} }
@ -163,8 +147,10 @@ func TestPublishOutboundSubscribe(t *testing.T) {
ctx := context.Background() ctx := context.Background()
msg := OutboundMessage{ msg := OutboundMessage{
Channel: "telegram", Context: InboundContext{
ChatID: "123", Channel: "telegram",
ChatID: "123",
},
Content: "world", Content: "world",
} }
@ -179,6 +165,9 @@ func TestPublishOutboundSubscribe(t *testing.T) {
if got.Content != "world" { if got.Content != "world" {
t.Fatalf("expected content 'world', got %q", got.Content) t.Fatalf("expected content 'world', got %q", got.Content)
} }
if got.Context.Channel != "telegram" || got.Context.ChatID != "123" {
t.Fatalf("expected normalized outbound context, got %+v", got.Context)
}
} }
func TestPublishOutbound_MirrorsContextToLegacyFields(t *testing.T) { func TestPublishOutbound_MirrorsContextToLegacyFields(t *testing.T) {
@ -241,6 +230,19 @@ func TestPublishOutboundMedia_MirrorsContextToLegacyFields(t *testing.T) {
} }
} }
func TestNewOutboundContext_NormalizesReplyAddress(t *testing.T) {
ctx := NewOutboundContext(" telegram ", " chat-42 ", " msg-9 ")
if ctx.Channel != "telegram" {
t.Fatalf("expected channel telegram, got %q", ctx.Channel)
}
if ctx.ChatID != "chat-42" {
t.Fatalf("expected chat_id chat-42, got %q", ctx.ChatID)
}
if ctx.ReplyToMessageID != "msg-9" {
t.Fatalf("expected reply_to_message_id msg-9, got %q", ctx.ReplyToMessageID)
}
}
func TestPublishInbound_ContextCancel(t *testing.T) { func TestPublishInbound_ContextCancel(t *testing.T) {
mb := NewMessageBus() mb := NewMessageBus()
defer mb.Close() defer mb.Close()
@ -248,7 +250,15 @@ func TestPublishInbound_ContextCancel(t *testing.T) {
// Fill the buffer // Fill the buffer
ctx := context.Background() ctx := context.Background()
for i := range defaultBusBufferSize { for i := range defaultBusBufferSize {
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { if err := mb.PublishInbound(ctx, InboundMessage{
Context: InboundContext{
Channel: "test",
ChatID: "chat-fill",
ChatType: "direct",
SenderID: "user-fill",
},
Content: "fill",
}); err != nil {
t.Fatalf("fill failed at %d: %v", i, err) t.Fatalf("fill failed at %d: %v", i, err)
} }
} }
@ -257,7 +267,15 @@ func TestPublishInbound_ContextCancel(t *testing.T) {
cancelCtx, cancel := context.WithCancel(context.Background()) cancelCtx, cancel := context.WithCancel(context.Background())
cancel() cancel()
err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"}) err := mb.PublishInbound(cancelCtx, InboundMessage{
Context: InboundContext{
Channel: "test",
ChatID: "chat-overflow",
ChatType: "direct",
SenderID: "user-overflow",
},
Content: "overflow",
})
if err == nil { if err == nil {
t.Fatal("expected error from canceled context, got nil") t.Fatal("expected error from canceled context, got nil")
} }
@ -270,7 +288,15 @@ func TestPublishInbound_BusClosed(t *testing.T) {
mb := NewMessageBus() mb := NewMessageBus()
mb.Close() mb.Close()
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) err := mb.PublishInbound(context.Background(), InboundMessage{
Context: InboundContext{
Channel: "test",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
Content: "test",
})
if err != ErrBusClosed { if err != ErrBusClosed {
t.Fatalf("expected ErrBusClosed, got %v", err) t.Fatalf("expected ErrBusClosed, got %v", err)
} }
@ -280,7 +306,13 @@ func TestPublishOutbound_BusClosed(t *testing.T) {
mb := NewMessageBus() mb := NewMessageBus()
mb.Close() mb.Close()
err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"}) err := mb.PublishOutbound(context.Background(), OutboundMessage{
Context: InboundContext{
Channel: "test",
ChatID: "chat1",
},
Content: "test",
})
if err != ErrBusClosed { if err != ErrBusClosed {
t.Fatalf("expected ErrBusClosed, got %v", err) t.Fatalf("expected ErrBusClosed, got %v", err)
} }
@ -292,14 +324,30 @@ func TestConsumeInbound_ContextCancel(t *testing.T) {
defer mb.Close() defer mb.Close()
for i := range defaultBusBufferSize { for i := range defaultBusBufferSize {
if err := mb.PublishInbound(context.Background(), InboundMessage{Content: "fill"}); err != nil { if err := mb.PublishInbound(context.Background(), InboundMessage{
Context: InboundContext{
Channel: "test",
ChatID: "chat-fill",
ChatType: "direct",
SenderID: "user-fill",
},
Content: "fill",
}); err != nil {
t.Fatalf("fill failed at %d: %v", i, err) t.Fatalf("fill failed at %d: %v", i, err)
} }
} }
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() defer cancel()
mb.PublishInbound(ctx, InboundMessage{Content: "ContextCancel"}) mb.PublishInbound(ctx, InboundMessage{
Context: InboundContext{
Channel: "test",
ChatID: "chat-cancel",
ChatType: "direct",
SenderID: "user-cancel",
},
Content: "ContextCancel",
})
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -393,7 +441,15 @@ func TestPublishInbound_FullBuffer(t *testing.T) {
// Fill the buffer // Fill the buffer
for i := range defaultBusBufferSize { for i := range defaultBusBufferSize {
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { if err := mb.PublishInbound(ctx, InboundMessage{
Context: InboundContext{
Channel: "test",
ChatID: "chat-fill",
ChatType: "direct",
SenderID: "user-fill",
},
Content: "fill",
}); err != nil {
t.Fatalf("fill failed at %d: %v", i, err) t.Fatalf("fill failed at %d: %v", i, err)
} }
} }
@ -402,7 +458,15 @@ func TestPublishInbound_FullBuffer(t *testing.T) {
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel() defer cancel()
err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"}) err := mb.PublishInbound(timeoutCtx, InboundMessage{
Context: InboundContext{
Channel: "test",
ChatID: "chat-overflow",
ChatType: "direct",
SenderID: "user-overflow",
},
Content: "overflow",
})
if err == nil { if err == nil {
t.Fatal("expected error when buffer is full and context times out") t.Fatal("expected error when buffer is full and context times out")
} }
@ -420,7 +484,15 @@ func TestCloseIdempotent(t *testing.T) {
mb.Close() mb.Close()
// After close, publish should return ErrBusClosed // After close, publish should return ErrBusClosed
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) err := mb.PublishInbound(context.Background(), InboundMessage{
Context: InboundContext{
Channel: "test",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
Content: "test",
})
if err != ErrBusClosed { if err != ErrBusClosed {
t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err) t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
} }

View file

@ -2,92 +2,19 @@ package bus
import "strings" import "strings"
const ( // NormalizeInboundMessage ensures the inbound context is normalized and keeps
metadataKeyAccountID = "account_id" // convenience mirrors in sync for runtime consumers.
metadataKeyGuildID = "guild_id"
metadataKeyTeamID = "team_id"
metadataKeyReplyToMessage = "reply_to_message_id"
metadataKeyReplyToSender = "reply_to_sender_id"
metadataKeyParentPeerKind = "parent_peer_kind"
metadataKeyParentPeerID = "parent_peer_id"
metadataKeyIsMentioned = "is_mentioned"
)
// ContextFromLegacyInbound builds a normalized inbound context from the legacy
// top-level fields on InboundMessage. This keeps older producers working while
// new producers migrate to writing Context directly.
func ContextFromLegacyInbound(msg InboundMessage) InboundContext {
ctx := InboundContext{
Channel: strings.TrimSpace(msg.Channel),
ChatID: strings.TrimSpace(msg.ChatID),
ChatType: normalizeKind(msg.Peer.Kind),
SenderID: firstNonEmpty(
strings.TrimSpace(msg.SenderID),
strings.TrimSpace(msg.Sender.CanonicalID),
strings.TrimSpace(msg.Sender.PlatformID),
),
MessageID: strings.TrimSpace(msg.MessageID),
Raw: cloneStringMap(msg.Metadata),
}
if account := metadataValue(msg.Metadata, metadataKeyAccountID); account != "" {
ctx.Account = account
}
if replyToMsgID := metadataValue(msg.Metadata, metadataKeyReplyToMessage); replyToMsgID != "" {
ctx.ReplyToMessageID = replyToMsgID
}
if replyToSenderID := metadataValue(msg.Metadata, metadataKeyReplyToSender); replyToSenderID != "" {
ctx.ReplyToSenderID = replyToSenderID
}
if isTruthy(metadataValue(msg.Metadata, metadataKeyIsMentioned)) {
ctx.Mentioned = true
}
parentKind := normalizeKind(metadataValue(msg.Metadata, metadataKeyParentPeerKind))
parentID := metadataValue(msg.Metadata, metadataKeyParentPeerID)
if parentKind == "topic" && parentID != "" {
ctx.TopicID = parentID
}
switch {
case metadataValue(msg.Metadata, metadataKeyGuildID) != "":
ctx.SpaceType = "guild"
ctx.SpaceID = metadataValue(msg.Metadata, metadataKeyGuildID)
case metadataValue(msg.Metadata, metadataKeyTeamID) != "":
ctx.SpaceType = "team"
ctx.SpaceID = metadataValue(msg.Metadata, metadataKeyTeamID)
}
return normalizeInboundContext(ctx)
}
// NormalizeInboundMessage ensures the normalized Context is present and mirrors
// missing legacy fields from it so older consumers continue to work during the
// migration period.
func NormalizeInboundMessage(msg InboundMessage) InboundMessage { func NormalizeInboundMessage(msg InboundMessage) InboundMessage {
if msg.Context.isZero() { msg.Context = normalizeInboundContext(msg.Context)
msg.Context = ContextFromLegacyInbound(msg) msg.Channel = msg.Context.Channel
} else { msg.SenderID = msg.Context.SenderID
msg.Context = normalizeInboundContext(msg.Context) msg.ChatID = msg.Context.ChatID
}
if msg.Channel == "" {
msg.Channel = msg.Context.Channel
}
if msg.SenderID == "" {
msg.SenderID = msg.Context.SenderID
}
if msg.ChatID == "" {
msg.ChatID = msg.Context.ChatID
}
if msg.MessageID == "" { if msg.MessageID == "" {
msg.MessageID = msg.Context.MessageID msg.MessageID = msg.Context.MessageID
} }
if msg.Peer.Kind == "" { if msg.Context.MessageID == "" {
msg.Peer = peerFromContext(msg.Context) msg.Context.MessageID = msg.MessageID
} }
msg.Metadata = mergeLegacyMetadata(msg.Metadata, msg.Context)
return msg return msg
} }
@ -125,110 +52,6 @@ func normalizeInboundContext(ctx InboundContext) InboundContext {
return ctx return ctx
} }
func peerFromContext(ctx InboundContext) Peer {
kind := normalizeKind(ctx.ChatType)
if kind == "" {
return Peer{}
}
switch kind {
case "direct":
return Peer{
Kind: "direct",
ID: firstNonEmpty(strings.TrimSpace(ctx.SenderID), strings.TrimSpace(ctx.ChatID)),
}
case "group", "channel":
return Peer{
Kind: kind,
ID: strings.TrimSpace(ctx.ChatID),
}
default:
return Peer{
Kind: kind,
ID: strings.TrimSpace(ctx.ChatID),
}
}
}
func mergeLegacyMetadata(existing map[string]string, ctx InboundContext) map[string]string {
merged := cloneStringMap(existing)
if len(merged) == 0 {
merged = cloneStringMap(ctx.Raw)
} else {
for k, v := range ctx.Raw {
if _, ok := merged[k]; !ok {
merged[k] = v
}
}
}
if ctx.Account != "" {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyAccountID, ctx.Account)
}
if ctx.ReplyToMessageID != "" {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyReplyToMessage, ctx.ReplyToMessageID)
}
if ctx.ReplyToSenderID != "" {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyReplyToSender, ctx.ReplyToSenderID)
}
if ctx.Mentioned {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyIsMentioned, "true")
}
if ctx.TopicID != "" {
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyParentPeerKind, "topic")
setMissing(merged, metadataKeyParentPeerID, ctx.TopicID)
}
switch normalizeKind(ctx.SpaceType) {
case "guild":
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyGuildID, ctx.SpaceID)
case "team", "workspace":
if merged == nil {
merged = make(map[string]string)
}
setMissing(merged, metadataKeyTeamID, ctx.SpaceID)
}
if len(merged) == 0 {
return nil
}
return merged
}
func setMissing(dst map[string]string, key, value string) {
if value == "" {
return
}
if _, ok := dst[key]; !ok {
dst[key] = value
}
}
func metadataValue(metadata map[string]string, key string) string {
if metadata == nil {
return ""
}
return strings.TrimSpace(metadata[key])
}
func cloneStringMap(src map[string]string) map[string]string { func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 { if len(src) == 0 {
return nil return nil
@ -241,24 +64,11 @@ func cloneStringMap(src map[string]string) map[string]string {
return dst return dst
} }
func firstNonEmpty(values ...string) string { func normalizeKind(kind string) string {
for _, value := range values { switch strings.ToLower(strings.TrimSpace(kind)) {
if value != "" { case "direct", "group", "channel", "guild", "team", "workspace", "tenant", "topic":
return value return strings.ToLower(strings.TrimSpace(kind))
}
}
return ""
}
func normalizeKind(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func isTruthy(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "t", "true", "y", "yes", "on":
return true
default: default:
return false return strings.ToLower(strings.TrimSpace(kind))
} }
} }

View file

@ -2,62 +2,34 @@ package bus
import "strings" import "strings"
// ContextFromLegacyOutbound builds a minimal outbound context from the legacy // NewOutboundContext builds the minimal normalized addressing context required
// top-level outbound fields. This keeps older outbound publishers working // to deliver an outbound text message or reply.
// while new publishers gradually start carrying the original InboundContext. func NewOutboundContext(channel, chatID, replyToMessageID string) InboundContext {
func ContextFromLegacyOutbound(msg OutboundMessage) InboundContext {
return normalizeInboundContext(InboundContext{ return normalizeInboundContext(InboundContext{
Channel: strings.TrimSpace(msg.Channel), Channel: strings.TrimSpace(channel),
ChatID: strings.TrimSpace(msg.ChatID), ChatID: strings.TrimSpace(chatID),
ReplyToMessageID: strings.TrimSpace(msg.ReplyToMessageID), ReplyToMessageID: strings.TrimSpace(replyToMessageID),
}) })
} }
// ContextFromLegacyOutboundMedia builds a minimal outbound context for media. // NormalizeOutboundMessage ensures Context is normalized and keeps convenience
func ContextFromLegacyOutboundMedia(msg OutboundMediaMessage) InboundContext { // mirrors in sync for runtime consumers.
return normalizeInboundContext(InboundContext{
Channel: strings.TrimSpace(msg.Channel),
ChatID: strings.TrimSpace(msg.ChatID),
})
}
// NormalizeOutboundMessage ensures Context is present and mirrors legacy
// top-level addressing fields from it so older senders keep working.
func NormalizeOutboundMessage(msg OutboundMessage) OutboundMessage { func NormalizeOutboundMessage(msg OutboundMessage) OutboundMessage {
if msg.Context.isZero() { msg.Context = normalizeInboundContext(msg.Context)
msg.Context = ContextFromLegacyOutbound(msg) msg.Channel = msg.Context.Channel
} else { msg.ChatID = msg.Context.ChatID
msg.Context = normalizeInboundContext(msg.Context) if msg.Context.ReplyToMessageID == "" {
msg.Context.ReplyToMessageID = strings.TrimSpace(msg.ReplyToMessageID)
} }
msg.ReplyToMessageID = msg.Context.ReplyToMessageID
if msg.Channel == "" {
msg.Channel = msg.Context.Channel
}
if msg.ChatID == "" {
msg.ChatID = msg.Context.ChatID
}
if msg.ReplyToMessageID == "" {
msg.ReplyToMessageID = msg.Context.ReplyToMessageID
}
return msg return msg
} }
// NormalizeOutboundMediaMessage ensures media outbound messages also carry a // NormalizeOutboundMediaMessage ensures media outbound messages also carry a
// normalized context while preserving the legacy top-level routing fields. // normalized context while keeping convenience mirrors in sync.
func NormalizeOutboundMediaMessage(msg OutboundMediaMessage) OutboundMediaMessage { func NormalizeOutboundMediaMessage(msg OutboundMediaMessage) OutboundMediaMessage {
if msg.Context.isZero() { msg.Context = normalizeInboundContext(msg.Context)
msg.Context = ContextFromLegacyOutboundMedia(msg) msg.Channel = msg.Context.Channel
} else { msg.ChatID = msg.Context.ChatID
msg.Context = normalizeInboundContext(msg.Context)
}
if msg.Channel == "" {
msg.Channel = msg.Context.Channel
}
if msg.ChatID == "" {
msg.ChatID = msg.Context.ChatID
}
return msg return msg
} }

View file

@ -1,11 +1,5 @@
package bus package bus
// Peer identifies the routing peer for a message (direct, group, channel, etc.)
type Peer struct {
Kind string `json:"kind"` // "direct" | "group" | "channel" | ""
ID string `json:"id"`
}
// SenderInfo provides structured sender identity information. // SenderInfo provides structured sender identity information.
type SenderInfo struct { type SenderInfo struct {
Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ... Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ...
@ -16,9 +10,8 @@ type SenderInfo struct {
} }
// InboundContext captures the normalized, platform-agnostic facts about an // InboundContext captures the normalized, platform-agnostic facts about an
// inbound message. This is the long-term source of truth for routing and // inbound message. This is the source of truth for routing and session
// session allocation. Legacy top-level fields on InboundMessage remain during // allocation.
// the transition and are derived from this context when missing.
type InboundContext struct { type InboundContext struct {
Channel string `json:"channel"` Channel string `json:"channel"`
Account string `json:"account,omitempty"` Account string `json:"account,omitempty"`
@ -43,18 +36,18 @@ type InboundContext struct {
} }
type InboundMessage struct { type InboundMessage struct {
Channel string `json:"channel"` Context InboundContext `json:"context"`
SenderID string `json:"sender_id"` Sender SenderInfo `json:"sender"`
Sender SenderInfo `json:"sender"` Content string `json:"content"`
ChatID string `json:"chat_id"` Media []string `json:"media,omitempty"`
Context InboundContext `json:"context"` MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
Content string `json:"content"` SessionKey string `json:"session_key"`
Media []string `json:"media,omitempty"`
Peer Peer `json:"peer"` // routing peer // Convenience mirrors derived from Context for runtime consumers.
MessageID string `json:"message_id,omitempty"` // platform message ID Channel string `json:"channel"`
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope SenderID string `json:"sender_id"`
SessionKey string `json:"session_key"` ChatID string `json:"chat_id"`
Metadata map[string]string `json:"metadata,omitempty"` MessageID string `json:"message_id,omitempty"` // platform message ID
} }
type OutboundMessage struct { type OutboundMessage struct {

View file

@ -244,35 +244,8 @@ func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool {
return false return false
} }
func (c *BaseChannel) HandleMessage(
ctx context.Context,
peer bus.Peer,
messageID, senderID, chatID, content string,
media []string,
metadata map[string]string,
senderOpts ...bus.SenderInfo,
) {
var sender bus.SenderInfo
if len(senderOpts) > 0 {
sender = senderOpts[0]
}
inboundCtx := bus.ContextFromLegacyInbound(bus.InboundMessage{
Channel: c.name,
SenderID: senderID,
Sender: sender,
ChatID: chatID,
Peer: peer,
MessageID: messageID,
Metadata: metadata,
})
c.HandleMessageWithContext(ctx, peer, chatID, content, media, inboundCtx, senderOpts...)
}
func (c *BaseChannel) HandleMessageWithContext( func (c *BaseChannel) HandleMessageWithContext(
ctx context.Context, ctx context.Context,
peer bus.Peer,
deliveryChatID, content string, deliveryChatID, content string,
media []string, media []string,
inboundCtx bus.InboundContext, inboundCtx bus.InboundContext,
@ -315,15 +288,10 @@ func (c *BaseChannel) HandleMessageWithContext(
scope := BuildMediaScope(c.name, deliveryChatID, inboundCtx.MessageID) scope := BuildMediaScope(c.name, deliveryChatID, inboundCtx.MessageID)
msg := bus.InboundMessage{ msg := bus.InboundMessage{
Channel: c.name,
SenderID: resolvedSenderID,
Sender: sender,
ChatID: deliveryChatID,
Context: inboundCtx, Context: inboundCtx,
Sender: sender,
Content: content, Content: content,
Media: media, Media: media,
Peer: peer,
MessageID: inboundCtx.MessageID,
MediaScope: scope, MediaScope: scope,
} }
msg = bus.NormalizeInboundMessage(msg) msg = bus.NormalizeInboundMessage(msg)
@ -369,6 +337,18 @@ func (c *BaseChannel) HandleMessageWithContext(
} }
} }
// HandleInboundContext publishes a normalized inbound message using only the
// structured context.
func (c *BaseChannel) HandleInboundContext(
ctx context.Context,
deliveryChatID, content string,
media []string,
inboundCtx bus.InboundContext,
senderOpts ...bus.SenderInfo,
) {
c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
}
func (c *BaseChannel) SetRunning(running bool) { func (c *BaseChannel) SetRunning(running bool) {
c.running.Store(running) c.running.Store(running)
} }

View file

@ -1,6 +1,7 @@
package channels package channels
import ( import (
"context"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -263,3 +264,58 @@ func TestIsAllowedSender(t *testing.T) {
}) })
} }
} }
func TestHandleInboundContext_PublishesNormalizedContext(t *testing.T) {
tests := []struct {
name string
inbound bus.InboundContext
wantChat string
wantSender string
}{
{
name: "direct uses sender as peer",
inbound: bus.InboundContext{
Channel: "test",
ChatID: "chat-1",
ChatType: "direct",
SenderID: "user-1",
MessageID: "msg-1",
},
wantChat: "chat-1",
wantSender: "user-1",
},
{
name: "group uses chat as peer",
inbound: bus.InboundContext{
Channel: "test",
ChatID: "group-1",
ChatType: "group",
SenderID: "user-2",
MessageID: "msg-2",
},
wantChat: "group-1",
wantSender: "user-2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgBus := bus.NewMessageBus()
defer msgBus.Close()
ch := NewBaseChannel("test", nil, msgBus, nil)
ch.HandleInboundContext(context.Background(), tt.inbound.ChatID, "hello", nil, tt.inbound)
msg := <-msgBus.InboundChan()
if msg.ChatID != tt.wantChat {
t.Fatalf("ChatID = %q, want %q", msg.ChatID, tt.wantChat)
}
if msg.SenderID != tt.wantSender {
t.Fatalf("SenderID = %q, want %q", msg.SenderID, tt.wantSender)
}
if msg.Context.ChatType != tt.inbound.ChatType {
t.Fatalf("ChatType = %q, want %q", msg.Context.ChatType, tt.inbound.ChatType)
}
})
}
}

View file

@ -181,16 +181,15 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
"session_webhook": data.SessionWebhook, "session_webhook": data.SessionWebhook,
} }
var peer bus.Peer var (
chatType string
isMentioned bool
)
if data.ConversationType == "1" { if data.ConversationType == "1" {
peerID := senderID chatType = "direct"
if peerID == "" {
peerID = chatID
}
peer = bus.Peer{Kind: "direct", ID: peerID}
} else { } else {
peer = bus.Peer{Kind: "group", ID: data.ConversationId} chatType = "group"
isMentioned := data.IsInAtList isMentioned = data.IsInAtList
if isMentioned { if isMentioned {
content = stripLeadingAtMentions(content) content = stripLeadingAtMentions(content)
} }
@ -228,8 +227,21 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
return nil, nil return nil, nil
} }
// Handle the message through the base channel inboundCtx := bus.InboundContext{
c.HandleMessage(ctx, peer, "", resolvedSenderID, chatID, content, nil, metadata, sender) Channel: "dingtalk",
ChatID: chatID,
ChatType: chatType,
SenderID: resolvedSenderID,
Mentioned: isMentioned,
Raw: metadata,
}
if data.SessionWebhook != "" {
inboundCtx.ReplyHandles = map[string]string{
"session_webhook": data.SessionWebhook,
}
}
c.HandleInboundContext(ctx, chatID, content, nil, inboundCtx, sender)
// Return nil to indicate we've handled the message asynchronously // Return nil to indicate we've handled the message asynchronously
// The response will be sent through the message bus // The response will be sent through the message bus

View file

@ -461,14 +461,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
}) })
peerKind := "channel" peerKind := "channel"
peerID := m.ChannelID
if m.GuildID == "" { if m.GuildID == "" {
peerKind = "direct" peerKind = "direct"
peerID = senderID
} }
peer := bus.Peer{Kind: peerKind, ID: peerID}
metadata := map[string]string{ metadata := map[string]string{
"user_id": senderID, "user_id": senderID,
"username": m.Author.Username, "username": m.Author.Username,
@ -494,7 +490,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
inboundCtx.ReplyToMessageID = m.MessageReference.MessageID inboundCtx.ReplyToMessageID = m.MessageReference.MessageID
} }
c.HandleMessageWithContext(c.ctx, peer, m.ChannelID, content, mediaPaths, inboundCtx, sender) c.HandleInboundContext(c.ctx, m.ChannelID, content, mediaPaths, inboundCtx, sender)
} }
// startTyping starts a continuous typing indicator loop for the given chatID. // startTyping starts a continuous typing indicator loop for the given chatID.

View file

@ -447,22 +447,25 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
if messageType != "" { if messageType != "" {
metadata["message_type"] = messageType metadata["message_type"] = messageType
} }
chatType := stringValue(message.ChatType) rawChatType := stringValue(message.ChatType)
if chatType != "" { if rawChatType != "" {
metadata["chat_type"] = chatType metadata["chat_type"] = rawChatType
} }
if sender != nil && sender.TenantKey != nil { if sender != nil && sender.TenantKey != nil {
metadata["tenant_key"] = *sender.TenantKey metadata["tenant_key"] = *sender.TenantKey
} }
var peer bus.Peer var (
if chatType == "p2p" { inboundChatType string
peer = bus.Peer{Kind: "direct", ID: senderID} isMentioned bool
)
if rawChatType == "p2p" {
inboundChatType = "direct"
} else { } else {
peer = bus.Peer{Kind: "group", ID: chatID} inboundChatType = "group"
// Check if bot was mentioned // Check if bot was mentioned
isMentioned := c.isBotMentioned(message) isMentioned = c.isBotMentioned(message)
// Strip mention placeholders from content before group trigger check // Strip mention placeholders from content before group trigger check
if len(message.Mentions) > 0 { if len(message.Mentions) > 0 {
@ -484,7 +487,21 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
"preview": utils.Truncate(content, 80), "preview": utils.Truncate(content, 80),
}) })
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo) inboundCtx := bus.InboundContext{
Channel: "feishu",
ChatID: chatID,
ChatType: inboundChatType,
SenderID: senderID,
MessageID: messageID,
Mentioned: isMentioned,
Raw: metadata,
}
if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" {
inboundCtx.SpaceType = "tenant"
inboundCtx.SpaceID = *sender.TenantKey
}
c.HandleInboundContext(ctx, chatID, content, mediaRefs, inboundCtx, senderInfo)
return nil return nil
} }

View file

@ -51,14 +51,11 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) {
isDM := !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&") isDM := !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&")
var chatID string var chatID string
var peer bus.Peer
if isDM { if isDM {
chatID = nick chatID = nick
peer = bus.Peer{Kind: "direct", ID: nick}
} else { } else {
chatID = target chatID = target
peer = bus.Peer{Kind: "group", ID: target}
} }
sender := bus.SenderInfo{ sender := bus.SenderInfo{
@ -73,9 +70,11 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) {
return return
} }
isMentioned := false
// For channel messages, check group trigger (mention detection) // For channel messages, check group trigger (mention detection)
if !isDM { if !isDM {
isMentioned := isBotMentioned(content, currentNick) isMentioned = isBotMentioned(content, currentNick)
if isMentioned { if isMentioned {
content = stripBotMention(content, currentNick) content = stripBotMention(content, currentNick)
} }
@ -100,7 +99,21 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) {
metadata["channel"] = target metadata["channel"] = target
} }
c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender) inboundCtx := bus.InboundContext{
Channel: "irc",
ChatID: chatID,
SenderID: nick,
MessageID: messageID,
Mentioned: isMentioned,
Raw: metadata,
}
if isDM {
inboundCtx.ChatType = "direct"
} else {
inboundCtx.ChatType = "group"
}
c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender)
} }
// nickMentionedAt returns the byte index where botNick is mentioned in content // nickMentionedAt returns the byte index where botNick is mentioned in content

View file

@ -368,13 +368,6 @@ func (c *LINEChannel) processEvent(event lineEvent) {
"source_type": event.Source.Type, "source_type": event.Source.Type,
} }
var peer bus.Peer
if isGroup {
peer = bus.Peer{Kind: "group", ID: chatID}
} else {
peer = bus.Peer{Kind: "direct", ID: senderID}
}
logger.DebugCF("line", "Received message", map[string]any{ logger.DebugCF("line", "Received message", map[string]any{
"sender_id": senderID, "sender_id": senderID,
"chat_id": chatID, "chat_id": chatID,
@ -396,7 +389,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
inboundCtx := bus.InboundContext{ inboundCtx := bus.InboundContext{
Channel: c.Name(), Channel: c.Name(),
ChatID: chatID, ChatID: chatID,
ChatType: peer.Kind, ChatType: map[bool]string{true: "group", false: "direct"}[isGroup],
SenderID: senderID, SenderID: senderID,
MessageID: msg.ID, MessageID: msg.ID,
Mentioned: isMentioned, Mentioned: isMentioned,
@ -411,7 +404,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
} }
} }
c.HandleMessageWithContext(c.ctx, peer, chatID, content, mediaPaths, inboundCtx, sender) c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender)
} }
// isBotMentioned checks if the bot is mentioned in the message. // isBotMentioned checks if the bot is mentioned in the message.

View file

@ -196,17 +196,15 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
return return
} }
c.HandleMessage( inboundCtx := bus.InboundContext{
c.ctx, Channel: "maixcam",
bus.Peer{Kind: "channel", ID: "default"}, ChatID: chatID,
"", ChatType: "channel",
senderID, SenderID: senderID,
chatID, Raw: metadata,
content, }
[]string{},
metadata, c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender)
sender,
)
} }
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {

View file

@ -97,6 +97,22 @@ type asyncTask struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
func outboundMessageChannel(msg bus.OutboundMessage) string {
return msg.Context.Channel
}
func outboundMessageChatID(msg bus.OutboundMessage) string {
return msg.Context.ChatID
}
func outboundMediaChannel(msg bus.OutboundMediaMessage) string {
return msg.Context.Channel
}
func outboundMediaChatID(msg bus.OutboundMediaMessage) string {
return msg.Context.ChatID
}
// RecordPlaceholder registers a placeholder message for later editing. // RecordPlaceholder registers a placeholder message for later editing.
// Implements PlaceholderRecorder. // Implements PlaceholderRecorder.
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
@ -160,7 +176,8 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
// preSend handles typing stop, reaction undo, and placeholder editing before sending a message. // preSend handles typing stop, reaction undo, and placeholder editing before sending a message.
// Returns the delivered message IDs and true when delivery completed before a normal Send. // Returns the delivered message IDs and true when delivery completed before a normal Send.
func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) ([]string, bool) { func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) ([]string, bool) {
key := name + ":" + msg.ChatID chatID := outboundMessageChatID(msg)
key := name + ":" + chatID
// 1. Stop typing // 1. Stop typing
if v, loaded := m.typingStops.LoadAndDelete(key); loaded { if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
@ -182,9 +199,9 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
// Prefer deleting the placeholder (cleaner UX than editing to same content) // Prefer deleting the placeholder (cleaner UX than editing to same content)
if deleter, ok := ch.(MessageDeleter); ok { if deleter, ok := ch.(MessageDeleter); ok {
deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // best effort deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
} else if editor, ok := ch.(MessageEditor); ok { } else if editor, ok := ch.(MessageEditor); ok {
editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content) // fallback editor.EditMessage(ctx, chatID, entry.id, msg.Content) // fallback
} }
} }
} }
@ -195,7 +212,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if editor, ok := ch.(MessageEditor); ok { if editor, ok := ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil { if err := editor.EditMessage(ctx, chatID, entry.id, msg.Content); err == nil {
return []string{entry.id}, true return []string{entry.id}, true
} }
// edit failed → fall through to normal Send // edit failed → fall through to normal Send
@ -211,7 +228,8 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
// delivery never edits the placeholder because there is no text payload to // delivery never edits the placeholder because there is no text payload to
// replace it with; it only attempts to delete the placeholder when possible. // replace it with; it only attempts to delete the placeholder when possible.
func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.OutboundMediaMessage, ch Channel) { func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.OutboundMediaMessage, ch Channel) {
key := name + ":" + msg.ChatID chatID := outboundMediaChatID(msg)
key := name + ":" + chatID
// 1. Stop typing // 1. Stop typing
if v, loaded := m.typingStops.LoadAndDelete(key); loaded { if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
@ -234,7 +252,7 @@ func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.Outboun
if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if deleter, ok := ch.(MessageDeleter); ok { if deleter, ok := ch.(MessageDeleter); ok {
deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // best effort deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
} }
} }
} }
@ -756,7 +774,7 @@ func (m *Manager) sendWithRetry(
// All retries exhausted or permanent failure // All retries exhausted or permanent failure
logger.ErrorCF("channels", "Send failed", map[string]any{ logger.ErrorCF("channels", "Send failed", map[string]any{
"channel": name, "channel": name,
"chat_id": msg.ChatID, "chat_id": outboundMessageChatID(msg),
"error": lastErr.Error(), "error": lastErr.Error(),
"retries": maxRetries, "retries": maxRetries,
}) })
@ -818,7 +836,7 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
dispatchLoop( dispatchLoop(
ctx, m, ctx, m,
m.bus.OutboundChan(), m.bus.OutboundChan(),
func(msg bus.OutboundMessage) string { return msg.Channel }, func(msg bus.OutboundMessage) string { return outboundMessageChannel(msg) },
func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool { func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
select { select {
case w.queue <- msg: case w.queue <- msg:
@ -838,7 +856,7 @@ func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
dispatchLoop( dispatchLoop(
ctx, m, ctx, m,
m.bus.OutboundMediaChan(), m.bus.OutboundMediaChan(),
func(msg bus.OutboundMediaMessage) string { return msg.Channel }, func(msg bus.OutboundMediaMessage) string { return outboundMediaChannel(msg) },
func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool { func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
select { select {
case w.mediaQueue <- msg: case w.mediaQueue <- msg:
@ -937,7 +955,7 @@ func (m *Manager) sendMediaWithRetry(
// All retries exhausted or permanent failure // All retries exhausted or permanent failure
logger.ErrorCF("channels", "SendMedia failed", map[string]any{ logger.ErrorCF("channels", "SendMedia failed", map[string]any{
"channel": name, "channel": name,
"chat_id": msg.ChatID, "chat_id": outboundMediaChatID(msg),
"error": lastErr.Error(), "error": lastErr.Error(),
"retries": maxRetries, "retries": maxRetries,
}) })
@ -1131,17 +1149,18 @@ func (m *Manager) UnregisterChannel(name string) {
// a subsequent operation depends on the message having been sent. // a subsequent operation depends on the message having been sent.
func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
msg = bus.NormalizeOutboundMessage(msg) msg = bus.NormalizeOutboundMessage(msg)
channelName := outboundMessageChannel(msg)
m.mu.RLock() m.mu.RLock()
_, exists := m.channels[msg.Channel] _, exists := m.channels[channelName]
w, wExists := m.workers[msg.Channel] w, wExists := m.workers[channelName]
m.mu.RUnlock() m.mu.RUnlock()
if !exists { if !exists {
return fmt.Errorf("channel %s not found", msg.Channel) return fmt.Errorf("channel %s not found", channelName)
} }
if !wExists || w == nil { if !wExists || w == nil {
return fmt.Errorf("channel %s has no active worker", msg.Channel) return fmt.Errorf("channel %s has no active worker", channelName)
} }
maxLen := 0 maxLen := 0
@ -1152,10 +1171,10 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro
for _, chunk := range SplitMessage(msg.Content, maxLen) { for _, chunk := range SplitMessage(msg.Content, maxLen) {
chunkMsg := msg chunkMsg := msg
chunkMsg.Content = chunk chunkMsg.Content = chunk
m.sendWithRetry(ctx, msg.Channel, w, chunkMsg) m.sendWithRetry(ctx, channelName, w, chunkMsg)
} }
} else { } else {
m.sendWithRetry(ctx, msg.Channel, w, msg) m.sendWithRetry(ctx, channelName, w, msg)
} }
return nil return nil
} }
@ -1166,20 +1185,21 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro
// depends on actual media delivery. // depends on actual media delivery.
func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
msg = bus.NormalizeOutboundMediaMessage(msg) msg = bus.NormalizeOutboundMediaMessage(msg)
channelName := outboundMediaChannel(msg)
m.mu.RLock() m.mu.RLock()
_, exists := m.channels[msg.Channel] _, exists := m.channels[channelName]
w, wExists := m.workers[msg.Channel] w, wExists := m.workers[channelName]
m.mu.RUnlock() m.mu.RUnlock()
if !exists { if !exists {
return fmt.Errorf("channel %s not found", msg.Channel) return fmt.Errorf("channel %s not found", channelName)
} }
if !wExists || w == nil { if !wExists || w == nil {
return fmt.Errorf("channel %s has no active worker", msg.Channel) return fmt.Errorf("channel %s has no active worker", channelName)
} }
_, err := m.sendMediaWithRetry(ctx, msg.Channel, w, msg) _, err := m.sendMediaWithRetry(ctx, channelName, w, msg)
return err return err
} }
@ -1194,10 +1214,10 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten
} }
msg := bus.OutboundMessage{ msg := bus.OutboundMessage{
Channel: channelName, Context: bus.NewOutboundContext(channelName, chatID, ""),
ChatID: chatID,
Content: content, Content: content,
} }
msg = bus.NormalizeOutboundMessage(msg)
if wExists && w != nil { if wExists && w != nil {
select { select {

View file

@ -89,6 +89,20 @@ func newTestManager() *Manager {
} }
} }
func testOutboundMessage(msg bus.OutboundMessage) bus.OutboundMessage {
if msg.Context.Channel == "" && msg.Context.ChatID == "" {
msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, msg.ReplyToMessageID)
}
return bus.NormalizeOutboundMessage(msg)
}
func testOutboundMediaMessage(msg bus.OutboundMediaMessage) bus.OutboundMediaMessage {
if msg.Context.Channel == "" && msg.Context.ChatID == "" {
msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, "")
}
return bus.NormalizeOutboundMediaMessage(msg)
}
func TestSendWithRetry_Success(t *testing.T) { func TestSendWithRetry_Success(t *testing.T) {
m := newTestManager() m := newTestManager()
var callCount int var callCount int
@ -104,7 +118,7 @@ func TestSendWithRetry_Success(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
m.sendWithRetry(ctx, "test", w, msg) m.sendWithRetry(ctx, "test", w, msg)
@ -131,7 +145,7 @@ func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
m.sendWithRetry(ctx, "test", w, msg) m.sendWithRetry(ctx, "test", w, msg)
@ -155,7 +169,7 @@ func TestSendWithRetry_PermanentFailure(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
m.sendWithRetry(ctx, "test", w, msg) m.sendWithRetry(ctx, "test", w, msg)
@ -179,7 +193,7 @@ func TestSendWithRetry_NotRunning(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
m.sendWithRetry(ctx, "test", w, msg) m.sendWithRetry(ctx, "test", w, msg)
@ -206,7 +220,7 @@ func TestSendWithRetry_RateLimitRetry(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
start := time.Now() start := time.Now()
m.sendWithRetry(ctx, "test", w, msg) m.sendWithRetry(ctx, "test", w, msg)
@ -236,7 +250,7 @@ func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
m.sendWithRetry(ctx, "test", w, msg) m.sendWithRetry(ctx, "test", w, msg)
@ -262,11 +276,11 @@ func TestSendMedia_Success(t *testing.T) {
m.channels["test"] = ch m.channels["test"] = ch
m.workers["test"] = w m.workers["test"] = w
err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{
Channel: "test", Channel: "test",
ChatID: "chat1", ChatID: "chat1",
Parts: []bus.MediaPart{{Ref: "media://abc"}}, Parts: []bus.MediaPart{{Ref: "media://abc"}},
}) }))
if err != nil { if err != nil {
t.Fatalf("SendMedia() error = %v", err) t.Fatalf("SendMedia() error = %v", err)
} }
@ -289,11 +303,11 @@ func TestSendMedia_PropagatesFailure(t *testing.T) {
m.channels["test"] = ch m.channels["test"] = ch
m.workers["test"] = w m.workers["test"] = w
err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{
Channel: "test", Channel: "test",
ChatID: "chat1", ChatID: "chat1",
Parts: []bus.MediaPart{{Ref: "media://abc"}}, Parts: []bus.MediaPart{{Ref: "media://abc"}},
}) }))
if err == nil { if err == nil {
t.Fatal("expected SendMedia to return error") t.Fatal("expected SendMedia to return error")
} }
@ -316,11 +330,11 @@ func TestSendMedia_UnsupportedChannelReturnsError(t *testing.T) {
m.channels["test"] = ch m.channels["test"] = ch
m.workers["test"] = w m.workers["test"] = w
err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{
Channel: "test", Channel: "test",
ChatID: "chat1", ChatID: "chat1",
Parts: []bus.MediaPart{{Ref: "media://abc"}}, Parts: []bus.MediaPart{{Ref: "media://abc"}},
}) }))
if err == nil { if err == nil {
t.Fatal("expected SendMedia to return error for unsupported channel") t.Fatal("expected SendMedia to return error for unsupported channel")
} }
@ -346,11 +360,11 @@ func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) {
m.workers["test"] = w m.workers["test"] = w
m.RecordPlaceholder("test", "chat1", "placeholder-1") m.RecordPlaceholder("test", "chat1", "placeholder-1")
err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{
Channel: "test", Channel: "test",
ChatID: "chat1", ChatID: "chat1",
Parts: []bus.MediaPart{{Ref: "media://abc"}}, Parts: []bus.MediaPart{{Ref: "media://abc"}},
}) }))
if err != nil { if err != nil {
t.Fatalf("SendMedia() error = %v", err) t.Fatalf("SendMedia() error = %v", err)
} }
@ -383,7 +397,7 @@ func TestSendWithRetry_UnknownError(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
m.sendWithRetry(ctx, "test", w, msg) m.sendWithRetry(ctx, "test", w, msg)
@ -407,7 +421,7 @@ func TestSendWithRetry_ContextCancelled(t *testing.T) {
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
// Cancel context after first Send attempt returns // Cancel context after first Send attempt returns
ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error { ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error {
@ -453,7 +467,7 @@ func TestWorkerRateLimiter(t *testing.T) {
// Enqueue 4 messages // Enqueue 4 messages
for i := range 4 { for i := range 4 {
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)} w.queue <- testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)})
} }
// Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin) // Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin)
@ -529,7 +543,7 @@ func TestRunWorker_MessageSplitting(t *testing.T) {
go m.runWorker(ctx, "test", w) go m.runWorker(ctx, "test", w)
// Send a message that should be split // Send a message that should be split
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"} w.queue <- testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"})
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
@ -570,7 +584,7 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"})
start := time.Now() start := time.Now()
m.sendWithRetry(ctx, "test", w, msg) m.sendWithRetry(ctx, "test", w, msg)
@ -630,7 +644,7 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
// Register placeholder // Register placeholder
m.RecordPlaceholder("test", "123", "456") m.RecordPlaceholder("test", "123", "456")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
_, edited := m.preSend(context.Background(), "test", msg, ch) _, edited := m.preSend(context.Background(), "test", msg, ch)
if !edited { if !edited {
@ -660,7 +674,7 @@ func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
m.RecordPlaceholder("test", "123", "456") m.RecordPlaceholder("test", "123", "456")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
_, edited := m.preSend(context.Background(), "test", msg, ch) _, edited := m.preSend(context.Background(), "test", msg, ch)
if edited { if edited {
@ -719,7 +733,7 @@ func TestPreSend_TypingStopCalled(t *testing.T) {
stopCalled = true stopCalled = true
}) })
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
m.preSend(context.Background(), "test", msg, ch) m.preSend(context.Background(), "test", msg, ch)
if !stopCalled { if !stopCalled {
@ -736,7 +750,7 @@ func TestPreSend_NoRegisteredState(t *testing.T) {
}, },
} }
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
_, edited := m.preSend(context.Background(), "test", msg, ch) _, edited := m.preSend(context.Background(), "test", msg, ch)
if edited { if edited {
@ -766,7 +780,7 @@ func TestPreSend_TypingAndPlaceholder(t *testing.T) {
}) })
m.RecordPlaceholder("test", "123", "456") m.RecordPlaceholder("test", "123", "456")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
_, edited := m.preSend(context.Background(), "test", msg, ch) _, edited := m.preSend(context.Background(), "test", msg, ch)
if !stopCalled { if !stopCalled {
@ -830,7 +844,7 @@ func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) {
t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls) t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls)
} }
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
m.preSend(context.Background(), "test", msg, &mockChannel{}) m.preSend(context.Background(), "test", msg, &mockChannel{})
if newStopCalls != 1 { if newStopCalls != 1 {
@ -864,7 +878,7 @@ func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
limiter: rate.NewLimiter(rate.Inf, 1), limiter: rate.NewLimiter(rate.Inf, 1),
} }
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"})
m.sendWithRetry(context.Background(), "test", w, msg) m.sendWithRetry(context.Background(), "test", w, msg)
if sendCalled { if sendCalled {
@ -1027,7 +1041,7 @@ func TestPreSendStillWorksWithWrappedTypes(t *testing.T) {
}) })
m.RecordPlaceholder("test", "chat1", "ph_id") m.RecordPlaceholder("test", "chat1", "ph_id")
msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"} msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"})
_, edited := m.preSend(context.Background(), "test", msg, ch) _, edited := m.preSend(context.Background(), "test", msg, ch)
if !stopCalled { if !stopCalled {
@ -1130,11 +1144,11 @@ func TestManager_PlaceholderConsumedByResponse(t *testing.T) {
// Transcription feedback arrives first — it should consume the placeholder // Transcription feedback arrives first — it should consume the placeholder
// and be delivered via EditMessage, not Send. // and be delivered via EditMessage, not Send.
msgTranscript := bus.OutboundMessage{ msgTranscript := testOutboundMessage(bus.OutboundMessage{
Channel: "mock", Channel: "mock",
ChatID: "chat-1", ChatID: "chat-1",
Content: "Transcript: hello", Content: "Transcript: hello",
} })
mgr.sendWithRetry(ctx, "mock", worker, msgTranscript) mgr.sendWithRetry(ctx, "mock", worker, msgTranscript)
if mockCh.editedMessages != 1 { if mockCh.editedMessages != 1 {
@ -1150,11 +1164,11 @@ func TestManager_PlaceholderConsumedByResponse(t *testing.T) {
} }
// Final LLM response arrives — no placeholder left, so it goes through Send // Final LLM response arrives — no placeholder left, so it goes through Send
msgFinal := bus.OutboundMessage{ msgFinal := testOutboundMessage(bus.OutboundMessage{
Channel: "mock", Channel: "mock",
ChatID: "chat-1", ChatID: "chat-1",
Content: "Final Answer", Content: "Final Answer",
} })
mgr.sendWithRetry(ctx, "mock", worker, msgFinal) mgr.sendWithRetry(ctx, "mock", worker, msgFinal)
if len(mockCh.sentMessages) != 1 { if len(mockCh.sentMessages) != 1 {
@ -1180,12 +1194,12 @@ func TestSendMessage_Synchronous(t *testing.T) {
m.channels["test"] = ch m.channels["test"] = ch
m.workers["test"] = w m.workers["test"] = w
msg := bus.OutboundMessage{ msg := testOutboundMessage(bus.OutboundMessage{
Channel: "test", Channel: "test",
ChatID: "123", ChatID: "123",
Content: "hello world", Content: "hello world",
ReplyToMessageID: "msg-456", ReplyToMessageID: "msg-456",
} })
err := m.SendMessage(context.Background(), msg) err := m.SendMessage(context.Background(), msg)
if err != nil { if err != nil {
@ -1207,11 +1221,11 @@ func TestSendMessage_Synchronous(t *testing.T) {
func TestSendMessage_UnknownChannel(t *testing.T) { func TestSendMessage_UnknownChannel(t *testing.T) {
m := newTestManager() m := newTestManager()
msg := bus.OutboundMessage{ msg := testOutboundMessage(bus.OutboundMessage{
Channel: "nonexistent", Channel: "nonexistent",
ChatID: "123", ChatID: "123",
Content: "hello", Content: "hello",
} })
err := m.SendMessage(context.Background(), msg) err := m.SendMessage(context.Background(), msg)
if err == nil { if err == nil {
@ -1228,11 +1242,11 @@ func TestSendMessage_NoWorker(t *testing.T) {
m.channels["test"] = ch m.channels["test"] = ch
// No worker registered // No worker registered
msg := bus.OutboundMessage{ msg := testOutboundMessage(bus.OutboundMessage{
Channel: "test", Channel: "test",
ChatID: "123", ChatID: "123",
Content: "hello", Content: "hello",
} })
err := m.SendMessage(context.Background(), msg) err := m.SendMessage(context.Background(), msg)
if err == nil { if err == nil {
@ -1261,11 +1275,11 @@ func TestSendMessage_WithRetry(t *testing.T) {
m.channels["test"] = ch m.channels["test"] = ch
m.workers["test"] = w m.workers["test"] = w
msg := bus.OutboundMessage{ msg := testOutboundMessage(bus.OutboundMessage{
Channel: "test", Channel: "test",
ChatID: "123", ChatID: "123",
Content: "retry me", Content: "retry me",
} })
err := m.SendMessage(context.Background(), msg) err := m.SendMessage(context.Background(), msg)
if err != nil { if err != nil {
@ -1277,6 +1291,46 @@ func TestSendMessage_WithRetry(t *testing.T) {
} }
} }
func TestSendMessage_ContextOnlyUsesContextAddressing(t *testing.T) {
m := newTestManager()
var received []bus.OutboundMessage
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
received = append(received, msg)
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := testOutboundMessage(bus.OutboundMessage{
Context: bus.NewOutboundContext("test", "123", "msg-9"),
Content: "hello",
})
if err := m.SendMessage(context.Background(), msg); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(received) != 1 {
t.Fatalf("expected 1 message sent, got %d", len(received))
}
if received[0].Channel != "test" || received[0].ChatID != "123" {
t.Fatalf("expected mirrored legacy address, got %+v", received[0])
}
if received[0].Context.Channel != "test" || received[0].Context.ChatID != "123" {
t.Fatalf("expected context address to be preserved, got %+v", received[0].Context)
}
if received[0].ReplyToMessageID != "msg-9" {
t.Fatalf("expected reply_to_message_id msg-9, got %q", received[0].ReplyToMessageID)
}
}
func TestSendMessage_WithSplitting(t *testing.T) { func TestSendMessage_WithSplitting(t *testing.T) {
m := newTestManager() m := newTestManager()
@ -1298,11 +1352,11 @@ func TestSendMessage_WithSplitting(t *testing.T) {
m.channels["test"] = ch m.channels["test"] = ch
m.workers["test"] = w m.workers["test"] = w
msg := bus.OutboundMessage{ msg := testOutboundMessage(bus.OutboundMessage{
Channel: "test", Channel: "test",
ChatID: "123", ChatID: "123",
Content: "hello world", Content: "hello world",
} })
err := m.SendMessage(context.Background(), msg) err := m.SendMessage(context.Background(), msg)
if err != nil { if err != nil {
@ -1314,6 +1368,43 @@ func TestSendMessage_WithSplitting(t *testing.T) {
} }
} }
func TestSendMedia_ContextOnlyUsesContextAddressing(t *testing.T) {
m := newTestManager()
var received []bus.OutboundMediaMessage
ch := &mockMediaChannel{
sendMediaFn: func(_ context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
received = append(received, msg)
return nil, nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := testOutboundMediaMessage(bus.OutboundMediaMessage{
Context: bus.NewOutboundContext("test", "media-chat", ""),
Parts: []bus.MediaPart{{Type: "image", Ref: "media://1"}},
})
if err := m.SendMedia(context.Background(), msg); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(received) != 1 {
t.Fatalf("expected 1 media message sent, got %d", len(received))
}
if received[0].Channel != "test" || received[0].ChatID != "media-chat" {
t.Fatalf("expected mirrored legacy media address, got %+v", received[0])
}
if received[0].Context.Channel != "test" || received[0].Context.ChatID != "media-chat" {
t.Fatalf("expected media context address to be preserved, got %+v", received[0].Context)
}
}
func TestSendMessage_PreservesOrdering(t *testing.T) { func TestSendMessage_PreservesOrdering(t *testing.T) {
m := newTestManager() m := newTestManager()
@ -1333,12 +1424,12 @@ func TestSendMessage_PreservesOrdering(t *testing.T) {
m.workers["test"] = w m.workers["test"] = w
// Send two messages sequentially — they must arrive in order // Send two messages sequentially — they must arrive in order
_ = m.SendMessage(context.Background(), bus.OutboundMessage{ _ = m.SendMessage(context.Background(), testOutboundMessage(bus.OutboundMessage{
Channel: "test", ChatID: "1", Content: "first", Channel: "test", ChatID: "1", Content: "first",
}) }))
_ = m.SendMessage(context.Background(), bus.OutboundMessage{ _ = m.SendMessage(context.Background(), testOutboundMessage(bus.OutboundMessage{
Channel: "test", ChatID: "1", Content: "second", Channel: "test", ChatID: "1", Content: "second",
}) }))
if len(order) != 2 { if len(order) != 2 {
t.Fatalf("expected 2 messages, got %d", len(order)) t.Fatalf("expected 2 messages, got %d", len(order))

View file

@ -736,10 +736,8 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event
} }
peerKind := "direct" peerKind := "direct"
peerID := senderID
if isGroup { if isGroup {
peerKind = "group" peerKind = "group"
peerID = roomID
} }
metadata := map[string]string{ metadata := map[string]string{
@ -752,17 +750,19 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event
metadata["reply_to_msg_id"] = replyTo.String() metadata["reply_to_msg_id"] = replyTo.String()
} }
c.HandleMessage( inboundCtx := bus.InboundContext{
c.baseContext(), Channel: "matrix",
bus.Peer{Kind: peerKind, ID: peerID}, ChatID: roomID,
evt.ID.String(), ChatType: peerKind,
senderID, SenderID: senderID,
roomID, MessageID: evt.ID.String(),
content, Raw: metadata,
mediaPaths, }
metadata, if replyTo := msgEvt.GetRelatesTo().GetReplyTo(); replyTo != "" {
sender, inboundCtx.ReplyToMessageID = replyTo.String()
) }
c.HandleInboundContext(c.baseContext(), roomID, content, mediaPaths, inboundCtx, sender)
} }
// decryptEvent decrypts an encrypted event and returns the decrypted message event content. // decryptEvent decrypts an encrypted event and returns the decrypted message event content.

View file

@ -994,8 +994,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
var contextChatID string var contextChatID string
var contextChatType string var contextChatType string
var peer bus.Peer
metadata := map[string]string{} metadata := map[string]string{}
if parsed.ReplyTo != "" { if parsed.ReplyTo != "" {
@ -1007,14 +1005,12 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
chatID = "private:" + senderID chatID = "private:" + senderID
contextChatID = senderID contextChatID = senderID
contextChatType = "direct" contextChatType = "direct"
peer = bus.Peer{Kind: "direct", ID: senderID}
case "group": case "group":
groupIDStr := strconv.FormatInt(groupID, 10) groupIDStr := strconv.FormatInt(groupID, 10)
chatID = "group:" + groupIDStr chatID = "group:" + groupIDStr
contextChatID = groupIDStr contextChatID = groupIDStr
contextChatType = "group" contextChatType = "group"
peer = bus.Peer{Kind: "group", ID: groupIDStr}
metadata["group_id"] = groupIDStr metadata["group_id"] = groupIDStr
senderUserID, _ := parseJSONInt64(sender.UserID) senderUserID, _ := parseJSONInt64(sender.UserID)
@ -1089,7 +1085,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
Raw: metadata, Raw: metadata,
} }
c.HandleMessageWithContext(c.ctx, peer, chatID, content, parsed.Media, inboundCtx, senderInfo) c.HandleInboundContext(c.ctx, chatID, content, parsed.Media, inboundCtx, senderInfo)
} }
func (c *OneBotChannel) isDuplicate(messageID string) bool { func (c *OneBotChannel) isDuplicate(messageID string) bool {

View file

@ -254,8 +254,6 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
chatID := "pico_client:" + sessionID chatID := "pico_client:" + sessionID
senderID := "pico-remote" senderID := "pico-remote"
peer := bus.Peer{Kind: "direct", ID: chatID}
sender := bus.SenderInfo{ sender := bus.SenderInfo{
Platform: "pico_client", Platform: "pico_client",
PlatformID: senderID, PlatformID: senderID,
@ -266,10 +264,19 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
return return
} }
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, map[string]string{ inboundCtx := bus.InboundContext{
"platform": "pico_client", Channel: "pico_client",
"session_id": sessionID, ChatID: chatID,
}, sender) ChatType: "direct",
SenderID: senderID,
MessageID: msg.ID,
Raw: map[string]string{
"platform": "pico_client",
"session_id": sessionID,
},
}
c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender)
} }
// Send sends a message to the remote server. // Send sends a message to the remote server.

View file

@ -539,8 +539,6 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
chatID := "pico:" + sessionID chatID := "pico:" + sessionID
senderID := "pico-user" senderID := "pico-user"
peer := bus.Peer{Kind: "direct", ID: "pico:" + sessionID}
metadata := map[string]string{ metadata := map[string]string{
"platform": "pico", "platform": "pico",
"session_id": sessionID, "session_id": sessionID,
@ -562,7 +560,16 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
return return
} }
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender) inboundCtx := bus.InboundContext{
Channel: "pico",
ChatID: chatID,
ChatType: "direct",
SenderID: senderID,
MessageID: msg.ID,
Raw: metadata,
}
c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender)
} }
// truncate truncates a string to maxLen runes. // truncate truncates a string to maxLen runes.

View file

@ -657,15 +657,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
Raw: metadata, Raw: metadata,
} }
c.HandleMessageWithContext( c.HandleInboundContext(c.ctx, senderID, content, mediaPaths, inboundCtx, sender)
c.ctx,
bus.Peer{Kind: "direct", ID: senderID},
senderID,
content,
mediaPaths,
inboundCtx,
sender,
)
return nil return nil
} }
@ -744,15 +736,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
Raw: metadata, Raw: metadata,
} }
c.HandleMessageWithContext( c.HandleInboundContext(c.ctx, data.GroupID, content, mediaPaths, inboundCtx, sender)
c.ctx,
bus.Peer{Kind: "group", ID: data.GroupID},
data.GroupID,
content,
mediaPaths,
inboundCtx,
sender,
)
return nil return nil
} }

View file

@ -356,14 +356,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
} }
peerKind := "channel" peerKind := "channel"
peerID := channelID
if strings.HasPrefix(channelID, "D") { if strings.HasPrefix(channelID, "D") {
peerKind = "direct" peerKind = "direct"
peerID = senderID
} }
peer := bus.Peer{Kind: peerKind, ID: peerID}
metadata := map[string]string{ metadata := map[string]string{
"message_ts": messageTS, "message_ts": messageTS,
"channel_id": channelID, "channel_id": channelID,
@ -394,7 +390,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
inboundCtx.TopicID = threadTS inboundCtx.TopicID = threadTS
} }
c.HandleMessageWithContext(c.ctx, peer, chatID, content, mediaPaths, inboundCtx, sender) c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender)
} }
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
@ -442,14 +438,10 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
} }
mentionPeerKind := "channel" mentionPeerKind := "channel"
mentionPeerID := channelID
if strings.HasPrefix(channelID, "D") { if strings.HasPrefix(channelID, "D") {
mentionPeerKind = "direct" mentionPeerKind = "direct"
mentionPeerID = senderID
} }
mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID}
metadata := map[string]string{ metadata := map[string]string{
"message_ts": messageTS, "message_ts": messageTS,
"channel_id": channelID, "channel_id": channelID,
@ -472,7 +464,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
Raw: metadata, Raw: metadata,
} }
c.HandleMessageWithContext(c.ctx, mentionPeer, chatID, content, nil, inboundCtx, mentionSender) c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, mentionSender)
} }
func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
@ -520,10 +512,8 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"text": utils.Truncate(content, 50), "text": utils.Truncate(content, 50),
}) })
peerKind := "channel" peerKind := "channel"
peerID := channelID
if strings.HasPrefix(channelID, "D") { if strings.HasPrefix(channelID, "D") {
peerKind = "direct" peerKind = "direct"
peerID = senderID
} }
inboundCtx := bus.InboundContext{ inboundCtx := bus.InboundContext{
Channel: c.Name(), Channel: c.Name(),
@ -536,15 +526,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
Raw: metadata, Raw: metadata,
} }
c.HandleMessageWithContext( c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, cmdSender)
c.ctx,
bus.Peer{Kind: peerKind, ID: peerID},
chatID,
content,
nil,
inboundCtx,
cmdSender,
)
} }
func (c *SlackChannel) downloadSlackFile(file slack.File) string { func (c *SlackChannel) downloadSlackFile(file slack.File) string {

View file

@ -708,13 +708,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
}) })
peerKind := "direct" peerKind := "direct"
peerID := fmt.Sprintf("%d", user.ID)
if message.Chat.Type != "private" { if message.Chat.Type != "private" {
peerKind = "group" peerKind = "group"
peerID = compositeChatID
} }
peer := bus.Peer{Kind: peerKind, ID: peerID}
messageID := fmt.Sprintf("%d", message.MessageID) messageID := fmt.Sprintf("%d", message.MessageID)
metadata := map[string]string{ metadata := map[string]string{
@ -742,7 +738,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
c.HandleMessageWithContext( c.HandleMessageWithContext(
c.ctx, c.ctx,
peer,
compositeChatID, compositeChatID,
content, content,
mediaPaths, mediaPaths,

View file

@ -570,7 +570,6 @@ func (c *WeComChannel) dispatchIncoming(reqID string, msg wecomIncomingMessage)
return err return err
} }
peer := bus.Peer{Kind: peerKind, ID: actualChatID}
metadata := map[string]string{ metadata := map[string]string{
"channel": "wecom", "channel": "wecom",
"req_id": reqID, "req_id": reqID,
@ -596,7 +595,7 @@ func (c *WeComChannel) dispatchIncoming(reqID string, msg wecomIncomingMessage)
Raw: metadata, Raw: metadata,
} }
c.HandleMessageWithContext(c.ctx, peer, actualChatID, content, mediaRefs, inboundCtx, sender) c.HandleInboundContext(c.ctx, actualChatID, content, mediaRefs, inboundCtx, sender)
return nil return nil
} }

View file

@ -334,8 +334,6 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
return return
} }
peer := bus.Peer{Kind: "direct", ID: fromUserID}
metadata := map[string]string{ metadata := map[string]string{
"from_user_id": fromUserID, "from_user_id": fromUserID,
"context_token": msg.ContextToken, "context_token": msg.ContextToken,
@ -354,7 +352,21 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
c.persistContextTokens() c.persistContextTokens()
} }
c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, mediaRefs, metadata, sender) inboundCtx := bus.InboundContext{
Channel: "weixin",
ChatID: fromUserID,
ChatType: "direct",
SenderID: fromUserID,
MessageID: messageID,
Raw: metadata,
}
if msg.ContextToken != "" {
inboundCtx.ReplyHandles = map[string]string{
"context_token": msg.ContextToken,
}
}
c.HandleInboundContext(ctx, fromUserID, content, mediaRefs, inboundCtx, sender)
} }
// Send implements channels.Channel by sending a text message to the WeChat user. // Send implements channels.Channel by sending a text message to the WeChat user.

View file

@ -223,13 +223,6 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
metadata["user_name"] = userName metadata["user_name"] = userName
} }
var peer bus.Peer
if chatID == senderID {
peer = bus.Peer{Kind: "direct", ID: senderID}
} else {
peer = bus.Peer{Kind: "group", ID: chatID}
}
logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{ logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{
"sender": senderID, "sender": senderID,
"preview": utils.Truncate(content, 50), "preview": utils.Truncate(content, 50),
@ -248,5 +241,18 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
return return
} }
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) inboundCtx := bus.InboundContext{
Channel: "whatsapp",
ChatID: chatID,
SenderID: senderID,
MessageID: messageID,
Raw: metadata,
}
if chatID == senderID {
inboundCtx.ChatType = "direct"
} else {
inboundCtx.ChatType = "group"
}
c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender)
} }

View file

@ -375,7 +375,6 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
if evt.Info.Chat.Server == types.GroupServer { if evt.Info.Chat.Server == types.GroupServer {
peerKind = "group" peerKind = "group"
} }
peer := bus.Peer{Kind: peerKind, ID: chatID}
messageID := evt.Info.ID messageID := evt.Info.ID
sender := bus.SenderInfo{ sender := bus.SenderInfo{
Platform: "whatsapp", Platform: "whatsapp",
@ -393,7 +392,17 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
"WhatsApp message received", "WhatsApp message received",
map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}, map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)},
) )
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
inboundCtx := bus.InboundContext{
Channel: "whatsapp",
ChatID: chatID,
SenderID: senderID,
MessageID: messageID,
ChatType: peerKind,
Raw: metadata,
}
c.HandleInboundContext(c.runCtx, chatID, content, mediaPaths, inboundCtx, sender)
} }
func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {

View file

@ -99,7 +99,7 @@ type BuildInfo struct {
} }
// MarshalJSON implements custom JSON marshaling for Config // MarshalJSON implements custom JSON marshaling for Config
// to omit providers section when empty and session when empty // to omit providers section when empty and session when empty.
func (c *Config) MarshalJSON() ([]byte, error) { func (c *Config) MarshalJSON() ([]byte, error) {
type Alias Config type Alias Config
aux := &struct { aux := &struct {
@ -109,11 +109,8 @@ func (c *Config) MarshalJSON() ([]byte, error) {
Alias: (*Alias)(c), Alias: (*Alias)(c),
} }
// Only include session if not empty. Deprecated dm_scope is intentionally
// omitted so persisted configs converge on dimensions-based session policy.
if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 { if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 {
sessionCfg := c.Session sessionCfg := c.Session
sessionCfg.DMScope = ""
aux.Session = &sessionCfg aux.Session = &sessionCfg
} }
@ -199,7 +196,6 @@ type AgentBinding struct {
type SessionConfig struct { type SessionConfig struct {
Dimensions []string `json:"dimensions,omitempty"` Dimensions []string `json:"dimensions,omitempty"`
DMScope string `json:"dm_scope,omitempty"` // Deprecated: ignored by the new session policy path.
IdentityLinks map[string][]string `json:"identity_links,omitempty"` IdentityLinks map[string][]string `json:"identity_links,omitempty"`
} }

View file

@ -131,8 +131,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: platform, Context: bus.NewOutboundContext(platform, userID, ""),
ChatID: userID,
Content: msg, Content: msg,
}) })

View file

@ -339,8 +339,7 @@ func (hs *HeartbeatService) sendResponse(response string) {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: platform, Context: bus.NewOutboundContext(platform, userID, ""),
ChatID: userID,
Content: response, Content: response,
}) })

View file

@ -3,25 +3,21 @@ package routing
import ( import (
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
// RouteInput contains the routing context from an inbound message.
type RouteInput struct {
Channel string
AccountID string
Peer *RoutePeer
ParentPeer *RoutePeer
GuildID string
TeamID string
}
// SessionPolicy describes how a routed message should be mapped to a session. // SessionPolicy describes how a routed message should be mapped to a session.
type SessionPolicy struct { type SessionPolicy struct {
Dimensions []string Dimensions []string
IdentityLinks map[string][]string IdentityLinks map[string][]string
} }
type RoutePeer struct {
Kind string
ID string
}
// ResolvedRoute is the result of agent routing. // ResolvedRoute is the result of agent routing.
type ResolvedRoute struct { type ResolvedRoute struct {
AgentID string AgentID string
@ -41,14 +37,15 @@ func NewRouteResolver(cfg *config.Config) *RouteResolver {
return &RouteResolver{cfg: cfg} return &RouteResolver{cfg: cfg}
} }
// ResolveRoute determines which agent handles the message and returns the // ResolveRoute determines which agent handles the message from a normalized
// session policy that should be used to allocate session state. // inbound context and returns the session policy that should be used to
// allocate session state.
// Implements the 7-level priority cascade: // Implements the 7-level priority cascade:
// peer > parent_peer > guild > team > account > channel_wildcard > default // peer > parent_peer > guild > team > account > channel_wildcard > default
func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute { func (r *RouteResolver) ResolveRoute(inbound bus.InboundContext) ResolvedRoute {
channel := strings.ToLower(strings.TrimSpace(input.Channel)) channel := strings.ToLower(strings.TrimSpace(inbound.Channel))
accountID := NormalizeAccountID(input.AccountID) accountID := NormalizeAccountID(inbound.Account)
peer := input.Peer peer := routePeerFromContext(inbound)
sessionPolicy := r.sessionPolicy() sessionPolicy := r.sessionPolicy()
@ -73,7 +70,7 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
} }
// Priority 2: Parent peer binding // Priority 2: Parent peer binding
parentPeer := input.ParentPeer parentPeer := parentPeerFromContext(inbound)
if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" { if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" {
if match := r.findPeerMatch(bindings, parentPeer); match != nil { if match := r.findPeerMatch(bindings, parentPeer); match != nil {
return choose(match.AgentID, "binding.peer.parent") return choose(match.AgentID, "binding.peer.parent")
@ -81,7 +78,7 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
} }
// Priority 3: Guild binding // Priority 3: Guild binding
guildID := strings.TrimSpace(input.GuildID) guildID := routeGuildIDFromContext(inbound)
if guildID != "" { if guildID != "" {
if match := r.findGuildMatch(bindings, guildID); match != nil { if match := r.findGuildMatch(bindings, guildID); match != nil {
return choose(match.AgentID, "binding.guild") return choose(match.AgentID, "binding.guild")
@ -89,7 +86,7 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
} }
// Priority 4: Team binding // Priority 4: Team binding
teamID := strings.TrimSpace(input.TeamID) teamID := routeTeamIDFromContext(inbound)
if teamID != "" { if teamID != "" {
if match := r.findTeamMatch(bindings, teamID); match != nil { if match := r.findTeamMatch(bindings, teamID); match != nil {
return choose(match.AgentID, "binding.team") return choose(match.AgentID, "binding.team")
@ -276,6 +273,46 @@ func normalizeSessionDimensions(dimensions []string) []string {
return normalized return normalized
} }
func routePeerFromContext(ctx bus.InboundContext) *RoutePeer {
peerKind := normalizeChannel(strings.TrimSpace(ctx.ChatType))
if peerKind == "" || peerKind == "unknown" {
return nil
}
peerID := strings.TrimSpace(ctx.ChatID)
if peerKind == "direct" && peerID == "" {
peerID = strings.TrimSpace(ctx.SenderID)
}
if peerID == "" {
return nil
}
return &RoutePeer{Kind: peerKind, ID: peerID}
}
func parentPeerFromContext(ctx bus.InboundContext) *RoutePeer {
if topicID := strings.TrimSpace(ctx.TopicID); topicID != "" {
return &RoutePeer{Kind: "topic", ID: topicID}
}
return nil
}
func routeGuildIDFromContext(ctx bus.InboundContext) string {
if strings.EqualFold(strings.TrimSpace(ctx.SpaceType), "guild") {
return strings.TrimSpace(ctx.SpaceID)
}
return ""
}
func routeTeamIDFromContext(ctx bus.InboundContext) string {
switch strings.ToLower(strings.TrimSpace(ctx.SpaceType)) {
case "team", "workspace":
return strings.TrimSpace(ctx.SpaceID)
default:
return ""
}
}
func cloneIdentityLinks(src map[string][]string) map[string][]string { func cloneIdentityLinks(src map[string][]string) map[string][]string {
if len(src) == 0 { if len(src) == 0 {
return nil return nil
@ -288,3 +325,7 @@ func cloneIdentityLinks(src map[string][]string) map[string][]string {
} }
return cloned return cloned
} }
func normalizeChannel(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

View file

@ -3,6 +3,7 @@ package routing
import ( import (
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
@ -26,9 +27,10 @@ func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) {
cfg := testConfig(nil, nil) cfg := testConfig(nil, nil)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram", Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user1"}, ChatType: "direct",
SenderID: "user1",
}) })
if route.AgentID != DefaultAgentID { if route.AgentID != DefaultAgentID {
@ -63,9 +65,10 @@ func TestResolveRoute_PeerBinding(t *testing.T) {
cfg := testConfig(agents, bindings) cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram", Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user123"}, ChatType: "direct",
SenderID: "user123",
}) })
if route.AgentID != "support" { if route.AgentID != "support" {
@ -94,10 +97,12 @@ func TestResolveRoute_GuildBinding(t *testing.T) {
cfg := testConfig(agents, bindings) cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{
Channel: "discord", Channel: "discord",
GuildID: "guild-abc", ChatID: "ch1",
Peer: &RoutePeer{Kind: "channel", ID: "ch1"}, ChatType: "channel",
SpaceID: "guild-abc",
SpaceType: "guild",
}) })
if route.AgentID != "gaming" { if route.AgentID != "gaming" {
@ -126,10 +131,12 @@ func TestResolveRoute_TeamBinding(t *testing.T) {
cfg := testConfig(agents, bindings) cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{
Channel: "slack", Channel: "slack",
TeamID: "T12345", ChatID: "C001",
Peer: &RoutePeer{Kind: "channel", ID: "C001"}, ChatType: "channel",
SpaceID: "T12345",
SpaceType: "team",
}) })
if route.AgentID != "work" { if route.AgentID != "work" {
@ -157,10 +164,11 @@ func TestResolveRoute_AccountBinding(t *testing.T) {
cfg := testConfig(agents, bindings) cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram", Channel: "telegram",
AccountID: "bot2", Account: "bot2",
Peer: &RoutePeer{Kind: "direct", ID: "user1"}, ChatType: "direct",
SenderID: "user1",
}) })
if route.AgentID != "premium" { if route.AgentID != "premium" {
@ -188,9 +196,10 @@ func TestResolveRoute_ChannelWildcard(t *testing.T) {
cfg := testConfig(agents, bindings) cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram", Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user1"}, ChatType: "direct",
SenderID: "user1",
}) })
if route.AgentID != "telegram-bot" { if route.AgentID != "telegram-bot" {
@ -228,10 +237,12 @@ func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) {
cfg := testConfig(agents, bindings) cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{
Channel: "discord", Channel: "discord",
GuildID: "guild-1", ChatType: "direct",
Peer: &RoutePeer{Kind: "direct", ID: "user-vip"}, SenderID: "user-vip",
SpaceID: "guild-1",
SpaceType: "guild",
}) })
if route.AgentID != "vip" { if route.AgentID != "vip" {
@ -258,9 +269,7 @@ func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) {
cfg := testConfig(agents, bindings) cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{Channel: "telegram"})
Channel: "telegram",
})
if route.AgentID != "main" { if route.AgentID != "main" {
t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID) t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID)
@ -276,9 +285,7 @@ func TestResolveRoute_DefaultAgentSelection(t *testing.T) {
cfg := testConfig(agents, nil) cfg := testConfig(agents, nil)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{Channel: "cli"})
Channel: "cli",
})
if route.AgentID != "beta" { if route.AgentID != "beta" {
t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID) t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID)
@ -293,9 +300,7 @@ func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) {
cfg := testConfig(agents, nil) cfg := testConfig(agents, nil)
r := NewRouteResolver(cfg) r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{ route := r.ResolveRoute(bus.InboundContext{Channel: "cli"})
Channel: "cli",
})
if route.AgentID != "alpha" { if route.AgentID != "alpha" {
t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID) t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID)

View file

@ -1,218 +0,0 @@
package routing
import (
"fmt"
"strings"
)
// DMScope controls DM session isolation granularity.
type DMScope string
const (
DMScopeMain DMScope = "main"
DMScopePerPeer DMScope = "per-peer"
DMScopePerChannelPeer DMScope = "per-channel-peer"
DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer"
)
// RoutePeer represents a chat peer with kind and ID.
type RoutePeer struct {
Kind string // "direct", "group", "channel"
ID string
}
// SessionKeyParams holds all inputs for session key construction.
type SessionKeyParams struct {
AgentID string
Channel string
AccountID string
Peer *RoutePeer
DMScope DMScope
IdentityLinks map[string][]string
}
// ParsedSessionKey is the result of parsing an agent-scoped session key.
type ParsedSessionKey struct {
AgentID string
Rest string
}
// BuildAgentMainSessionKey returns "agent:<agentId>:main".
func BuildAgentMainSessionKey(agentID string) string {
return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey)
}
// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope.
func BuildAgentPeerSessionKey(params SessionKeyParams) string {
agentID := NormalizeAgentID(params.AgentID)
peer := params.Peer
if peer == nil {
peer = &RoutePeer{Kind: "direct"}
}
peerKind := strings.TrimSpace(peer.Kind)
if peerKind == "" {
peerKind = "direct"
}
if peerKind == "direct" {
dmScope := params.DMScope
if dmScope == "" {
dmScope = DMScopeMain
}
peerID := CanonicalSessionPeerID(params.Channel, peer.ID, dmScope, params.IdentityLinks)
switch dmScope {
case DMScopePerAccountChannelPeer:
if peerID != "" {
channel := normalizeChannel(params.Channel)
accountID := NormalizeAccountID(params.AccountID)
return fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, accountID, peerID)
}
case DMScopePerChannelPeer:
if peerID != "" {
channel := normalizeChannel(params.Channel)
return fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID)
}
case DMScopePerPeer:
if peerID != "" {
return fmt.Sprintf("agent:%s:direct:%s", agentID, peerID)
}
}
return BuildAgentMainSessionKey(agentID)
}
// Group/channel peers always get per-peer sessions
channel := normalizeChannel(params.Channel)
peerID := strings.ToLower(strings.TrimSpace(peer.ID))
if peerID == "" {
peerID = "unknown"
}
return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID)
}
// CanonicalSessionPeerID applies the current DM session canonicalization rules,
// including identity-link collapse when enabled.
func CanonicalSessionPeerID(
channel, peerID string,
dmScope DMScope,
identityLinks map[string][]string,
) string {
normalizedPeerID := strings.TrimSpace(peerID)
if normalizedPeerID == "" {
return ""
}
if dmScope != DMScopeMain {
if linked := resolveLinkedPeerID(identityLinks, channel, normalizedPeerID); linked != "" {
normalizedPeerID = linked
}
}
return strings.ToLower(normalizedPeerID)
}
// CanonicalSessionIdentityID collapses an identity using identity_links when
// possible, then returns a normalized lowercase identifier.
func CanonicalSessionIdentityID(channel, rawID string, identityLinks map[string][]string) string {
normalizedID := strings.TrimSpace(rawID)
if normalizedID == "" {
return ""
}
if linked := resolveLinkedPeerID(identityLinks, channel, normalizedID); linked != "" {
normalizedID = linked
}
return strings.ToLower(normalizedID)
}
// ParseAgentSessionKey extracts agentId and rest from "agent:<agentId>:<rest>".
func ParseAgentSessionKey(sessionKey string) *ParsedSessionKey {
raw := strings.TrimSpace(sessionKey)
if raw == "" {
return nil
}
parts := strings.SplitN(raw, ":", 3)
if len(parts) < 3 {
return nil
}
if parts[0] != "agent" {
return nil
}
agentID := strings.TrimSpace(parts[1])
rest := parts[2]
if agentID == "" || rest == "" {
return nil
}
return &ParsedSessionKey{AgentID: agentID, Rest: rest}
}
// IsSubagentSessionKey returns true if the session key represents a subagent.
func IsSubagentSessionKey(sessionKey string) bool {
raw := strings.TrimSpace(sessionKey)
if raw == "" {
return false
}
if strings.HasPrefix(strings.ToLower(raw), "subagent:") {
return true
}
parsed := ParseAgentSessionKey(raw)
if parsed == nil {
return false
}
return strings.HasPrefix(strings.ToLower(parsed.Rest), "subagent:")
}
func normalizeChannel(channel string) string {
c := strings.TrimSpace(strings.ToLower(channel))
if c == "" {
return "unknown"
}
return c
}
func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {
if len(identityLinks) == 0 {
return ""
}
peerID = strings.TrimSpace(peerID)
if peerID == "" {
return ""
}
candidates := make(map[string]bool)
rawCandidate := strings.ToLower(peerID)
if rawCandidate != "" {
candidates[rawCandidate] = true
}
channel = strings.ToLower(strings.TrimSpace(channel))
if channel != "" {
scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
candidates[scopedCandidate] = true
}
// If peerID is already in canonical "platform:id" format, also add the
// bare ID part as a candidate for backward compatibility with identity_links
// that use raw IDs (e.g. "123" instead of "telegram:123").
if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
bareID := rawCandidate[idx+1:]
candidates[bareID] = true
}
if len(candidates) == 0 {
return ""
}
for canonical, ids := range identityLinks {
canonicalName := strings.TrimSpace(canonical)
if canonicalName == "" {
continue
}
for _, id := range ids {
normalized := strings.ToLower(strings.TrimSpace(id))
if normalized != "" && candidates[normalized] {
return canonicalName
}
}
}
return ""
}

View file

@ -1,207 +0,0 @@
package routing
import "testing"
func TestBuildAgentMainSessionKey(t *testing.T) {
got := BuildAgentMainSessionKey("sales")
want := "agent:sales:main"
if got != want {
t.Errorf("BuildAgentMainSessionKey('sales') = %q, want %q", got, want)
}
}
func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) {
got := BuildAgentMainSessionKey("Sales Bot")
want := "agent:sales-bot:main"
if got != want {
t.Errorf("BuildAgentMainSessionKey('Sales Bot') = %q, want %q", got, want)
}
}
func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main",
Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
DMScope: DMScopeMain,
})
want := "agent:main:main"
if got != want {
t.Errorf("DMScopeMain = %q, want %q", got, want)
}
}
func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main",
Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
DMScope: DMScopePerPeer,
})
want := "agent:main:direct:user123"
if got != want {
t.Errorf("DMScopePerPeer = %q, want %q", got, want)
}
}
func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main",
Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
DMScope: DMScopePerChannelPeer,
})
want := "agent:main:telegram:direct:user123"
if got != want {
t.Errorf("DMScopePerChannelPeer = %q, want %q", got, want)
}
}
func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main",
Channel: "telegram",
AccountID: "bot1",
Peer: &RoutePeer{Kind: "direct", ID: "User123"},
DMScope: DMScopePerAccountChannelPeer,
})
want := "agent:main:telegram:bot1:direct:user123"
if got != want {
t.Errorf("DMScopePerAccountChannelPeer = %q, want %q", got, want)
}
}
func TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main",
Channel: "telegram",
Peer: &RoutePeer{Kind: "group", ID: "chat456"},
DMScope: DMScopePerPeer,
})
want := "agent:main:telegram:group:chat456"
if got != want {
t.Errorf("GroupPeer = %q, want %q", got, want)
}
}
func TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) {
got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main",
Channel: "telegram",
Peer: nil,
DMScope: DMScopePerPeer,
})
// nil peer defaults to direct with empty ID, falls to main
want := "agent:main:main"
if got != want {
t.Errorf("NilPeer = %q, want %q", got, want)
}
}
func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
links := map[string][]string{
"john": {"telegram:user123", "discord:john#1234"},
}
got := BuildAgentPeerSessionKey(SessionKeyParams{
AgentID: "main",
Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
DMScope: DMScopePerPeer,
IdentityLinks: links,
})
want := "agent:main:direct:john"
if got != want {
t.Errorf("IdentityLink = %q, want %q", got, want)
}
}
func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) {
// When peerID is already in canonical "platform:id" format,
// it should match identity_links that use the bare ID.
links := map[string][]string{
"john": {"123"},
}
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
if got != "john" {
t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john")
}
}
func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) {
// When identity_links contain canonical IDs and peerID is canonical too
links := map[string][]string{
"john": {"telegram:123", "discord:456"},
}
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
if got != "john" {
t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john")
}
}
func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) {
// When peerID is bare "123" and links have "telegram:123",
// the scoped candidate "telegram:123" should match.
links := map[string][]string{
"john": {"telegram:123"},
}
got := resolveLinkedPeerID(links, "telegram", "123")
if got != "john" {
t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john")
}
}
func TestResolveLinkedPeerID_NoMatch(t *testing.T) {
links := map[string][]string{
"john": {"telegram:123"},
}
got := resolveLinkedPeerID(links, "discord", "999")
if got != "" {
t.Errorf("resolveLinkedPeerID no match = %q, want empty", got)
}
}
func TestParseAgentSessionKey_Valid(t *testing.T) {
parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
if parsed == nil {
t.Fatal("expected non-nil result")
}
if parsed.AgentID != "sales" {
t.Errorf("AgentID = %q, want 'sales'", parsed.AgentID)
}
if parsed.Rest != "telegram:direct:user123" {
t.Errorf("Rest = %q, want 'telegram:direct:user123'", parsed.Rest)
}
}
func TestParseAgentSessionKey_Invalid(t *testing.T) {
tests := []string{
"",
"foo:bar",
"notprefix:sales:main",
"agent::main",
"agent:sales:",
}
for _, input := range tests {
if got := ParseAgentSessionKey(input); got != nil {
t.Errorf("ParseAgentSessionKey(%q) = %+v, want nil", input, got)
}
}
}
func TestIsSubagentSessionKey(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"subagent:task-1", true},
{"agent:main:subagent:task-1", true},
{"agent:main:main", false},
{"agent:main:telegram:direct:user123", false},
{"", false},
}
for _, tt := range tests {
if got := IsSubagentSessionKey(tt.input); got != tt.want {
t.Errorf("IsSubagentSessionKey(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}

View file

@ -32,7 +32,7 @@ type AllocationInput struct {
func AllocateRouteSession(input AllocationInput) Allocation { func AllocateRouteSession(input AllocationInput) Allocation {
scope := buildSessionScope(input) scope := buildSessionScope(input)
legacySessionAliases := buildLegacySessionAliases(input) legacySessionAliases := buildLegacySessionAliases(input)
legacyMainSessionKey := strings.ToLower(routing.BuildAgentMainSessionKey(input.AgentID)) legacyMainSessionKey := strings.ToLower(BuildLegacyMainAlias(input.AgentID))
return Allocation{ return Allocation{
Scope: scope, Scope: scope,
SessionKey: BuildSessionKey(scope), SessionKey: BuildSessionKey(scope),
@ -85,7 +85,7 @@ func buildSessionScope(input AllocationInput) SessionScope {
values["topic"] = "topic:" + strings.ToLower(topicID) values["topic"] = "topic:" + strings.ToLower(topicID)
} }
case "sender": case "sender":
senderID := routing.CanonicalSessionIdentityID( senderID := CanonicalSessionIdentityID(
inbound.Channel, inbound.Channel,
inbound.SenderID, inbound.SenderID,
input.SessionPolicy.IdentityLinks, input.SessionPolicy.IdentityLinks,
@ -107,11 +107,11 @@ func buildSessionScope(input AllocationInput) SessionScope {
} }
func buildLegacySessionAliases(input AllocationInput) []string { func buildLegacySessionAliases(input AllocationInput) []string {
aliases := []string{strings.ToLower(routing.BuildAgentMainSessionKey(input.AgentID))} aliases := []string{strings.ToLower(BuildLegacyMainAlias(input.AgentID))}
inbound := input.Context inbound := input.Context
if strings.EqualFold(strings.TrimSpace(inbound.ChatType), "direct") { if strings.EqualFold(strings.TrimSpace(inbound.ChatType), "direct") {
senderID := routing.CanonicalSessionIdentityID( senderID := CanonicalSessionIdentityID(
inbound.Channel, inbound.Channel,
inbound.SenderID, inbound.SenderID,
input.SessionPolicy.IdentityLinks, input.SessionPolicy.IdentityLinks,
@ -119,20 +119,10 @@ func buildLegacySessionAliases(input AllocationInput) []string {
if senderID == "" { if senderID == "" {
return uniqueAliases(aliases) return uniqueAliases(aliases)
} }
for _, dmScope := range []routing.DMScope{ aliases = append(
routing.DMScopePerPeer, aliases,
routing.DMScopePerChannelPeer, BuildLegacyDirectAliases(input.AgentID, inbound.Channel, inbound.Account, senderID)...,
routing.DMScopePerAccountChannelPeer, )
} {
aliases = append(aliases, strings.ToLower(routing.BuildAgentPeerSessionKey(routing.SessionKeyParams{
AgentID: input.AgentID,
Channel: inbound.Channel,
AccountID: inbound.Account,
Peer: &routing.RoutePeer{Kind: "direct", ID: senderID},
DMScope: dmScope,
IdentityLinks: input.SessionPolicy.IdentityLinks,
})))
}
return uniqueAliases(aliases) return uniqueAliases(aliases)
} }
@ -143,15 +133,12 @@ func buildLegacySessionAliases(input AllocationInput) []string {
if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" { if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" {
peerID = peerID + "/" + topicID peerID = peerID + "/" + topicID
} }
aliases = append(aliases, strings.ToLower(routing.BuildAgentPeerSessionKey(routing.SessionKeyParams{ aliases = append(aliases, BuildLegacyPeerAlias(
AgentID: input.AgentID, input.AgentID,
Channel: inbound.Channel, inbound.Channel,
AccountID: inbound.Account, strings.ToLower(strings.TrimSpace(inbound.ChatType)),
Peer: &routing.RoutePeer{ peerID,
Kind: strings.ToLower(strings.TrimSpace(inbound.ChatType)), ))
ID: peerID,
},
})))
return uniqueAliases(aliases) return uniqueAliases(aliases)
} }

View file

@ -5,9 +5,19 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/routing"
) )
const sessionKeyV1Prefix = "sk_v1_" const (
sessionKeyV1Prefix = "sk_v1_"
legacyAgentSessionKeyPrefix = "agent:"
)
type ParsedLegacySessionKey struct {
AgentID string
Rest string
}
// BuildOpaqueSessionKey returns a stable opaque session key derived from a // BuildOpaqueSessionKey returns a stable opaque session key derived from a
// canonical alias string. The alias remains available through metadata for // canonical alias string. The alias remains available through metadata for
@ -27,6 +37,129 @@ func IsOpaqueSessionKey(key string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), sessionKeyV1Prefix) return strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), sessionKeyV1Prefix)
} }
func IsLegacyAgentSessionKey(key string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), legacyAgentSessionKeyPrefix)
}
func IsExplicitSessionKey(key string) bool {
return IsOpaqueSessionKey(key) || IsLegacyAgentSessionKey(key)
}
func ParseLegacyAgentSessionKey(sessionKey string) *ParsedLegacySessionKey {
raw := strings.TrimSpace(sessionKey)
if raw == "" {
return nil
}
parts := strings.SplitN(raw, ":", 3)
if len(parts) < 3 || parts[0] != "agent" {
return nil
}
agentID := strings.TrimSpace(parts[1])
rest := parts[2]
if agentID == "" || rest == "" {
return nil
}
return &ParsedLegacySessionKey{AgentID: agentID, Rest: rest}
}
func BuildLegacyMainAlias(agentID string) string {
return fmt.Sprintf("agent:%s:main", routing.NormalizeAgentID(agentID))
}
// BuildMainSessionKey returns the canonical opaque main-session key for an
// agent. The corresponding legacy alias remains available via
// BuildLegacyMainAlias for compatibility and migration logic.
func BuildMainSessionKey(agentID string) string {
return BuildOpaqueSessionKey(BuildLegacyMainAlias(agentID))
}
func BuildLegacyDirectAliases(agentID, channel, account, peerID string) []string {
agentID = routing.NormalizeAgentID(agentID)
channel = normalizeLegacyChannel(channel)
account = routing.NormalizeAccountID(account)
peerID = strings.ToLower(strings.TrimSpace(peerID))
if peerID == "" {
return nil
}
return []string{
fmt.Sprintf("agent:%s:direct:%s", agentID, peerID),
fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID),
fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, account, peerID),
}
}
func BuildLegacyPeerAlias(agentID, channel, peerKind, peerID string) string {
agentID = routing.NormalizeAgentID(agentID)
channel = normalizeLegacyChannel(channel)
peerKind = strings.ToLower(strings.TrimSpace(peerKind))
if peerKind == "" {
peerKind = "unknown"
}
peerID = strings.ToLower(strings.TrimSpace(peerID))
if peerID == "" {
peerID = "unknown"
}
return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID)
}
// CanonicalSessionIdentityID collapses an identity using identity_links when
// possible, then returns a normalized lowercase identifier.
func CanonicalSessionIdentityID(channel, rawID string, identityLinks map[string][]string) string {
normalizedID := strings.TrimSpace(rawID)
if normalizedID == "" {
return ""
}
if linked := resolveLinkedPeerID(identityLinks, channel, normalizedID); linked != "" {
normalizedID = linked
}
return strings.ToLower(normalizedID)
}
func normalizeLegacyChannel(channel string) string {
channel = strings.ToLower(strings.TrimSpace(channel))
if channel == "" {
return "unknown"
}
return channel
}
func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {
if len(identityLinks) == 0 {
return ""
}
peerID = strings.TrimSpace(peerID)
if peerID == "" {
return ""
}
candidates := make(map[string]bool)
rawCandidate := strings.ToLower(peerID)
if rawCandidate != "" {
candidates[rawCandidate] = true
}
channel = strings.ToLower(strings.TrimSpace(channel))
if channel != "" {
candidates[fmt.Sprintf("%s:%s", channel, rawCandidate)] = true
}
if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
candidates[rawCandidate[idx+1:]] = true
}
for canonical, ids := range identityLinks {
canonicalName := strings.TrimSpace(canonical)
if canonicalName == "" {
continue
}
for _, id := range ids {
normalized := strings.ToLower(strings.TrimSpace(id))
if normalized != "" && candidates[normalized] {
return canonicalName
}
}
}
return ""
}
// CanonicalScopeSignature returns a stable serialized representation of scope. // CanonicalScopeSignature returns a stable serialized representation of scope.
func CanonicalScopeSignature(scope SessionScope) string { func CanonicalScopeSignature(scope SessionScope) string {
parts := []string{ parts := []string{

72
pkg/session/key_test.go Normal file
View file

@ -0,0 +1,72 @@
package session
import "testing"
func TestIsExplicitSessionKey(t *testing.T) {
tests := []struct {
key string
want bool
}{
{"sk_v1_abc", true},
{"agent:main:direct:user123", true},
{"custom-key", false},
{"", false},
}
for _, tt := range tests {
if got := IsExplicitSessionKey(tt.key); got != tt.want {
t.Fatalf("IsExplicitSessionKey(%q) = %v, want %v", tt.key, got, tt.want)
}
}
}
func TestParseLegacyAgentSessionKey(t *testing.T) {
parsed := ParseLegacyAgentSessionKey("agent:sales:telegram:direct:user123")
if parsed == nil {
t.Fatal("expected parsed legacy key, got nil")
}
if parsed.AgentID != "sales" {
t.Fatalf("AgentID = %q, want sales", parsed.AgentID)
}
if parsed.Rest != "telegram:direct:user123" {
t.Fatalf("Rest = %q, want telegram:direct:user123", parsed.Rest)
}
if got := ParseLegacyAgentSessionKey("sk_v1_abc"); got != nil {
t.Fatalf("expected nil for opaque key, got %+v", got)
}
}
func TestBuildLegacyDirectAliases(t *testing.T) {
aliases := BuildLegacyDirectAliases("Main", "Telegram", "BotA", "User123")
want := []string{
"agent:main:direct:user123",
"agent:main:telegram:direct:user123",
"agent:main:telegram:bota:direct:user123",
}
if len(aliases) != len(want) {
t.Fatalf("len(aliases) = %d, want %d", len(aliases), len(want))
}
for i := range want {
if aliases[i] != want[i] {
t.Fatalf("aliases[%d] = %q, want %q", i, aliases[i], want[i])
}
}
}
func TestBuildLegacyPeerAlias(t *testing.T) {
got := BuildLegacyPeerAlias("Main", "Slack", "channel", "C001")
if got != "agent:main:slack:channel:c001" {
t.Fatalf("BuildLegacyPeerAlias() = %q", got)
}
}
func TestBuildMainSessionKey(t *testing.T) {
got := BuildMainSessionKey("Main")
if !IsOpaqueSessionKey(got) {
t.Fatalf("BuildMainSessionKey() = %q, want opaque key", got)
}
if got != BuildOpaqueSessionKey("agent:main:main") {
t.Fatalf("BuildMainSessionKey() = %q, want stable main-key hash", got)
}
}

View file

@ -311,8 +311,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel, Context: bus.NewOutboundContext(channel, chatID, ""),
ChatID: chatID,
Content: output, Content: output,
}) })
return "ok" return "ok"
@ -335,8 +334,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel, Context: bus.NewOutboundContext(channel, chatID, ""),
ChatID: chatID,
Content: output, Content: output,
}) })
return "ok" return "ok"