Files
goclaw/internal/tools/team_message_tool.go
T
viettranx 2c5ae04c01 feat(teams): implement workspace scope setting (shared vs isolated)
- IsSharedWorkspace() reads team.settings.workspace_scope
- Shared: workspace at teams/{teamID}/ (all chats share)
- Isolated (default): workspace at teams/{teamID}/{chatID}/
- Remove _default fallback; isolated mode requires chat_id
- Update loop.go, task creation, task listing, message dispatch,
  workspace API (list/read/delete), task board snapshot
- Update UI descriptions to reflect per-conversation scoping
2026-03-16 22:46:35 +07:00

365 lines
11 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"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, broadcast, read. 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)",
},
"media": map[string]any{
"type": "array",
"description": "Optional file paths to attach as media (for action=send)",
"items": map[string]any{
"type": "string",
},
},
},
"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.cachedListMembers(ctx, team.ID, agentID)
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))
}
// Parse optional media paths
var mediaFiles []bus.MediaFile
if rawMedia, ok := args["media"]; ok {
if mediaArr, ok := rawMedia.([]any); ok {
for _, item := range mediaArr {
if path, ok := item.(string); ok && path != "" {
mediaFiles = append(mediaFiles, bus.MediaFile{Path: path})
}
}
}
}
// 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())
}
// Auto-create a "message" task so team messages appear in the Tasks tab.
subject := text
if len(subject) > 100 {
subject = subject[:100] + "..."
}
now := time.Now()
lockExpires := now.Add(30 * time.Minute)
taskData := &store.TeamTaskData{
TeamID: team.ID,
Subject: subject,
Description: text,
Status: store.TeamTaskStatusInProgress,
TaskType: "message",
CreatedByAgentID: &agentID,
OwnerAgentID: &toAgentID,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
LockedAt: &now,
LockExpiresAt: &lockExpires,
}
var teamTaskID uuid.UUID
slog.Info("team_message: creating auto-task",
"team_id", team.ID,
"from_agent", agentID,
"to_agent", toAgentID,
"status", taskData.Status,
"task_type", taskData.TaskType,
"user_id", taskData.UserID,
"channel", taskData.Channel,
"chat_id", taskData.ChatID,
)
if err := t.manager.teamStore.CreateTask(ctx, taskData); err != nil {
slog.Warn("team_message: failed to auto-create task",
"error", err,
"team_id", team.ID,
"from_agent", agentID,
"to_agent", toAgentID,
)
} else {
teamTaskID = taskData.ID
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: teamTaskID.String(),
Subject: subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: toKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, toKey),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
}
// Real-time delivery via message bus
fromKey := t.manager.agentKeyFromID(ctx, agentID)
t.publishTeammateMessage(fromKey, toKey, text, mediaFiles, teamTaskID, team.ID, team.Settings, 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.cachedListMembers(ctx, team.ID, agentID)
if err == nil {
for _, m := range members {
if m.AgentID == agentID {
continue // don't send to self
}
t.publishTeammateMessage(fromKey, m.AgentKey, text, nil, uuid.Nil, team.ID, team.Settings, 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)
}
resp := map[string]any{
"messages": messages,
"count": len(messages),
}
if len(messages) >= 50 {
resp["note"] = "Showing latest 50 unread messages. Read again after processing these to get more."
resp["has_more"] = true
}
out, _ := json.Marshal(resp)
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, media []bus.MediaFile, teamTaskID uuid.UUID, teamID uuid.UUID, teamSettings json.RawMessage, ctx context.Context) {
if t.manager.msgBus == nil {
return
}
userID := store.UserIDFromContext(ctx)
chatID := ToolChatIDFromCtx(ctx)
originChannel := ToolChannelFromCtx(ctx)
originPeerKind := ToolPeerKindFromCtx(ctx)
slog.Info("team_message: publishTeammateMessage",
"from", fromKey, "to", toKey,
"origin_channel", originChannel,
"chat_id", chatID,
"origin_peer_kind", originPeerKind,
"user_id", userID,
"team_task_id", teamTaskID,
)
teamMeta := map[string]string{
"origin_channel": originChannel,
"origin_peer_kind": originPeerKind,
"origin_chat_id": chatID,
"origin_user_id": userID,
"from_agent": fromKey,
"to_agent": toKey,
"team_id": teamID.String(),
}
if localKey := ToolLocalKeyFromCtx(ctx); localKey != "" {
teamMeta["origin_local_key"] = localKey
}
if teamTaskID != uuid.Nil {
teamMeta["team_task_id"] = teamTaskID.String()
}
// Pass team workspace so the receiving agent can access shared files.
wsChat := chatID
if IsSharedWorkspace(teamSettings) {
wsChat = ""
}
if ws, err := WorkspaceDir(t.manager.dataDir, teamID, wsChat); err == nil {
teamMeta["team_workspace"] = ws
}
// Propagate trace context so the receiving agent's trace links back.
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
teamMeta["origin_trace_id"] = traceID.String()
}
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
teamMeta["origin_root_span_id"] = rootSpanID.String()
}
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),
Media: media,
UserID: userID,
AgentID: toKey,
Metadata: teamMeta,
})
}