Files
goclaw/internal/tools/team_message_tool.go
T
viettranx bdb60de7ae chore: upgrade Go 1.25 → 1.26 and apply go fix modernizations
- Update go.mod and Dockerfile to Go 1.26
- Apply `go fix ./...` stdlib modernizations across 170+ files
- Add `go fix` to post-implementation checklist in CLAUDE.md
- Fix go fix misapplied rewrite in loop_history.go
2026-03-10 00:09:15 +07:00

248 lines
7.1 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// TeamMessageTool exposes the team mailbox to agents.
// Actions: send, broadcast, read.
type TeamMessageTool struct {
manager *TeamToolManager
}
func NewTeamMessageTool(manager *TeamToolManager) *TeamMessageTool {
return &TeamMessageTool{manager: manager}
}
func (t *TeamMessageTool) Name() string { return "team_message" }
func (t *TeamMessageTool) Description() string {
return "Send and receive messages within your team. Actions: send (direct message to a teammate), broadcast (message all teammates), read (check unread messages). See TEAM.md for your teammates."
}
func (t *TeamMessageTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"description": "'send', 'broadcast', or 'read'",
},
"to": map[string]any{
"type": "string",
"description": "Target agent key (required for action=send)",
},
"text": map[string]any{
"type": "string",
"description": "Message content (required for action=send and action=broadcast)",
},
},
"required": []string{"action"},
}
}
func (t *TeamMessageTool) Execute(ctx context.Context, args map[string]any) *Result {
action, _ := args["action"].(string)
switch action {
case "send":
return t.executeSend(ctx, args)
case "broadcast":
return t.executeBroadcast(ctx, args)
case "read":
return t.executeRead(ctx)
default:
return ErrorResult(fmt.Sprintf("unknown action: %s (use send, broadcast, or read)", action))
}
}
func (t *TeamMessageTool) executeSend(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
toKey, _ := args["to"].(string)
if toKey == "" {
return ErrorResult("to parameter is required for send action")
}
text, _ := args["text"].(string)
if text == "" {
return ErrorResult("text parameter is required for send action")
}
toAgentID, err := t.manager.resolveAgentByKey(toKey)
if err != nil {
return ErrorResult(err.Error())
}
// Validate recipient is in the same team (prevent cross-team messaging).
members, err := t.manager.teamStore.ListMembers(ctx, team.ID)
if err != nil {
return ErrorResult("failed to verify team membership: " + err.Error())
}
isMember := false
for _, m := range members {
if m.AgentID == toAgentID {
isMember = true
break
}
}
if !isMember {
return ErrorResult(fmt.Sprintf("agent %q is not a member of your team", toKey))
}
// Persist to DB
msg := &store.TeamMessageData{
TeamID: team.ID,
FromAgentID: agentID,
ToAgentID: &toAgentID,
Content: text,
MessageType: store.TeamMessageTypeChat,
}
if err := t.manager.teamStore.SendMessage(ctx, msg); err != nil {
return ErrorResult("failed to send message: " + err.Error())
}
// Real-time delivery via message bus
fromKey := t.manager.agentKeyFromID(ctx, agentID)
t.publishTeammateMessage(fromKey, toKey, text, ctx)
preview := text
if len(preview) > 100 {
preview = preview[:100] + "..."
}
t.manager.broadcastTeamEvent(protocol.EventTeamMessageSent, protocol.TeamMessageEventPayload{
TeamID: team.ID.String(),
FromAgentKey: fromKey,
FromDisplayName: t.manager.agentDisplayName(ctx, fromKey),
ToAgentKey: toKey,
ToDisplayName: t.manager.agentDisplayName(ctx, toKey),
MessageType: string(store.TeamMessageTypeChat),
Preview: preview,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
})
return NewResult(fmt.Sprintf("Message sent to %s.", toKey))
}
func (t *TeamMessageTool) executeBroadcast(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
text, _ := args["text"].(string)
if text == "" {
return ErrorResult("text parameter is required for broadcast action")
}
// Persist to DB (to_agent_id = NULL means broadcast)
msg := &store.TeamMessageData{
TeamID: team.ID,
FromAgentID: agentID,
ToAgentID: nil,
Content: text,
MessageType: store.TeamMessageTypeBroadcast,
}
if err := t.manager.teamStore.SendMessage(ctx, msg); err != nil {
return ErrorResult("failed to broadcast message: " + err.Error())
}
// Real-time delivery to all teammates via message bus
fromKey := t.manager.agentKeyFromID(ctx, agentID)
members, err := t.manager.teamStore.ListMembers(ctx, team.ID)
if err == nil {
for _, m := range members {
if m.AgentID == agentID {
continue // don't send to self
}
t.publishTeammateMessage(fromKey, m.AgentKey, text, ctx)
}
}
preview := text
if len(preview) > 100 {
preview = preview[:100] + "..."
}
t.manager.broadcastTeamEvent(protocol.EventTeamMessageSent, protocol.TeamMessageEventPayload{
TeamID: team.ID.String(),
FromAgentKey: fromKey,
FromDisplayName: t.manager.agentDisplayName(ctx, fromKey),
ToAgentKey: "broadcast",
MessageType: string(store.TeamMessageTypeBroadcast),
Preview: preview,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
})
return NewResult(fmt.Sprintf("Broadcast sent to all teammates."))
}
func (t *TeamMessageTool) executeRead(ctx context.Context) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
messages, err := t.manager.teamStore.GetUnread(ctx, team.ID, agentID)
if err != nil {
return ErrorResult("failed to get unread messages: " + err.Error())
}
// Mark all as read
for _, msg := range messages {
_ = t.manager.teamStore.MarkRead(ctx, msg.ID)
}
out, _ := json.Marshal(map[string]any{
"messages": messages,
"count": len(messages),
})
return SilentResult(string(out))
}
// publishTeammateMessage sends a real-time notification via the message bus.
// Uses "teammate:{fromKey}" sender prefix so the consumer can route it.
func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, ctx context.Context) {
if t.manager.msgBus == nil {
return
}
userID := store.UserIDFromContext(ctx)
chatID := ToolChatIDFromCtx(ctx)
originChannel := ToolChannelFromCtx(ctx)
originPeerKind := ToolPeerKindFromCtx(ctx)
teamMeta := map[string]string{
"origin_channel": originChannel,
"origin_peer_kind": originPeerKind,
"from_agent": fromKey,
"to_agent": toKey,
}
if localKey := ToolLocalKeyFromCtx(ctx); localKey != "" {
teamMeta["origin_local_key"] = localKey
}
t.manager.msgBus.PublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("teammate:%s", fromKey),
ChatID: chatID,
Content: fmt.Sprintf("[Team message from %s]: %s", fromKey, text),
UserID: userID,
AgentID: toKey,
Metadata: teamMeta,
})
}