mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-11 20:10:59 +00:00
b231878a85
- Add limit parameter to ListTasks interface (dashboard=200, agent=30) - Add teams.tasks.get-light WS method returning task only (no comments/events) - Truncate dashboard response to exact limit (fix off-by-one from limit+1) - Update all 7 ListTasks callers with explicit limit values
835 lines
27 KiB
Go
835 lines
27 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// maxCommentLength caps comment/reason content to prevent DB bloat.
|
|
const maxCommentLength = 10000
|
|
|
|
func taskBusEvent(name string, payload any) bus.Event {
|
|
return bus.Event{Name: name, Payload: payload}
|
|
}
|
|
|
|
func taskNowUTC() string {
|
|
return time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
}
|
|
|
|
// parseTaskParams unmarshals params and checks teamStore availability.
|
|
// Returns locale and false if an error response was already sent.
|
|
func (m *TeamsMethods) parseTaskParams(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame, dst any) (string, bool) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
if m.teamStore == nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
|
|
return locale, false
|
|
}
|
|
if err := json.Unmarshal(req.Params, dst); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return locale, false
|
|
}
|
|
return locale, true
|
|
}
|
|
|
|
// RegisterTasks registers teams.tasks.* RPC handlers.
|
|
func (m *TeamsMethods) RegisterTasks(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodTeamsTaskGet, m.handleTaskGet)
|
|
router.Register(protocol.MethodTeamsTaskGetLight, m.handleTaskGetLight)
|
|
router.Register(protocol.MethodTeamsTaskApprove, m.handleTaskApprove)
|
|
router.Register(protocol.MethodTeamsTaskReject, m.handleTaskReject)
|
|
router.Register(protocol.MethodTeamsTaskComment, m.handleTaskComment)
|
|
router.Register(protocol.MethodTeamsTaskComments, m.handleTaskComments)
|
|
router.Register(protocol.MethodTeamsTaskEvents, m.handleTaskEvents)
|
|
router.Register(protocol.MethodTeamsTaskCreate, m.handleTaskCreate)
|
|
router.Register(protocol.MethodTeamsTaskDelete, m.handleTaskDelete)
|
|
router.Register(protocol.MethodTeamsTaskDeleteBulk, m.handleTaskDeleteBulk)
|
|
router.Register(protocol.MethodTeamsTaskAssign, m.handleTaskAssign)
|
|
}
|
|
|
|
// --- Task Get (with comments + events + attachments) ---
|
|
|
|
type taskGetParams struct {
|
|
TeamID string `json:"teamId"`
|
|
TaskID string `json:"taskId"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskGetParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
taskID, err := uuid.Parse(params.TaskID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
|
return
|
|
}
|
|
|
|
task, err := m.teamStore.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrTaskNotFound) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
} else {
|
|
slog.Warn("teams.tasks.get failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
}
|
|
return
|
|
}
|
|
|
|
// Validate task belongs to the requested team (prevent IDOR).
|
|
if task.TeamID != teamID {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
return
|
|
}
|
|
|
|
comments, _ := m.teamStore.ListTaskComments(ctx, taskID)
|
|
events, _ := m.teamStore.ListTaskEvents(ctx, taskID)
|
|
attachments, _ := m.teamStore.ListTaskAttachments(ctx, taskID)
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"task": task,
|
|
"comments": comments,
|
|
"events": events,
|
|
"attachments": attachments,
|
|
}))
|
|
}
|
|
|
|
// --- Task Get Light (task only, no comments/events/attachments) ---
|
|
|
|
func (m *TeamsMethods) handleTaskGetLight(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskGetParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
taskID, err := uuid.Parse(params.TaskID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
|
return
|
|
}
|
|
|
|
task, err := m.teamStore.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrTaskNotFound) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
} else {
|
|
slog.Warn("teams.tasks.get-light failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
}
|
|
return
|
|
}
|
|
|
|
if task.TeamID != teamID {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"task": task,
|
|
}))
|
|
}
|
|
|
|
// --- Task Approve ---
|
|
|
|
type taskApproveParams struct {
|
|
TeamID string `json:"teamId"`
|
|
TaskID string `json:"taskId"`
|
|
Comment string `json:"comment"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskApprove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskApproveParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
taskID, err := uuid.Parse(params.TaskID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
|
return
|
|
}
|
|
|
|
if len(params.Comment) > maxCommentLength {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "comment too long"))
|
|
return
|
|
}
|
|
|
|
if err := m.teamStore.ApproveTask(ctx, taskID, teamID, params.Comment); err != nil {
|
|
slog.Warn("teams.tasks.approve failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
return
|
|
}
|
|
|
|
// Add optional comment.
|
|
if params.Comment != "" {
|
|
if err := m.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
|
|
TaskID: taskID,
|
|
UserID: client.UserID(),
|
|
Content: params.Comment,
|
|
}); err != nil {
|
|
slog.Warn("audit.comment_failed", "task_id", taskID, "error", err)
|
|
}
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
|
|
|
if m.msgBus != nil {
|
|
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{
|
|
TeamID: teamID.String(),
|
|
TaskID: taskID.String(),
|
|
Status: store.TeamTaskStatusCompleted,
|
|
UserID: client.UserID(),
|
|
Channel: "dashboard",
|
|
Timestamp: taskNowUTC(),
|
|
ActorType: "human",
|
|
ActorID: client.UserID(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
// --- Task Reject ---
|
|
|
|
type taskRejectParams struct {
|
|
TeamID string `json:"teamId"`
|
|
TaskID string `json:"taskId"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskReject(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskRejectParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
taskID, err := uuid.Parse(params.TaskID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
|
return
|
|
}
|
|
|
|
reason := params.Reason
|
|
if reason == "" {
|
|
reason = "Rejected by human"
|
|
}
|
|
if len(reason) > maxCommentLength {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "reason too long"))
|
|
return
|
|
}
|
|
|
|
if err := m.teamStore.RejectTask(ctx, taskID, teamID, reason); err != nil {
|
|
slog.Warn("teams.tasks.reject failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
|
|
|
if m.msgBus != nil {
|
|
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
|
|
TeamID: teamID.String(),
|
|
TaskID: taskID.String(),
|
|
Status: store.TeamTaskStatusCancelled,
|
|
Reason: reason,
|
|
UserID: client.UserID(),
|
|
Channel: "dashboard",
|
|
Timestamp: taskNowUTC(),
|
|
ActorType: "human",
|
|
ActorID: client.UserID(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
// --- Task Comment (human adds comment) ---
|
|
|
|
type taskCommentParams struct {
|
|
TeamID string `json:"teamId"`
|
|
TaskID string `json:"taskId"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskComment(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskCommentParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
taskID, err := uuid.Parse(params.TaskID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
|
return
|
|
}
|
|
|
|
if params.Content == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "content")))
|
|
return
|
|
}
|
|
if len(params.Content) > maxCommentLength {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "comment too long"))
|
|
return
|
|
}
|
|
|
|
// Validate task belongs to team (prevent IDOR).
|
|
task, err := m.teamStore.GetTask(ctx, taskID)
|
|
if err != nil || task.TeamID != teamID {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
return
|
|
}
|
|
|
|
if err := m.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
|
|
TaskID: taskID,
|
|
UserID: client.UserID(),
|
|
Content: params.Content,
|
|
}); err != nil {
|
|
slog.Warn("teams.tasks.comment failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
|
|
|
if m.msgBus != nil {
|
|
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
|
|
TeamID: teamID.String(),
|
|
TaskID: taskID.String(),
|
|
UserID: client.UserID(),
|
|
Channel: "dashboard",
|
|
Timestamp: taskNowUTC(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
// --- Task Comments list ---
|
|
|
|
type taskCommentsParams struct {
|
|
TeamID string `json:"teamId"`
|
|
TaskID string `json:"taskId"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskComments(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskCommentsParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
taskID, err := uuid.Parse(params.TaskID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
|
return
|
|
}
|
|
|
|
// Validate task belongs to team (prevent IDOR).
|
|
task, err := m.teamStore.GetTask(ctx, taskID)
|
|
if err != nil || task.TeamID != teamID {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
return
|
|
}
|
|
|
|
comments, err := m.teamStore.ListTaskComments(ctx, taskID)
|
|
if err != nil {
|
|
slog.Warn("teams.tasks.comments failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"comments": comments,
|
|
}))
|
|
}
|
|
|
|
// --- Task Events list ---
|
|
|
|
type taskEventsParams struct {
|
|
TeamID string `json:"teamId"`
|
|
TaskID string `json:"taskId"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskEvents(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskEventsParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
taskID, err := uuid.Parse(params.TaskID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
|
return
|
|
}
|
|
|
|
// Validate task belongs to team (prevent IDOR).
|
|
task, err := m.teamStore.GetTask(ctx, taskID)
|
|
if err != nil || task.TeamID != teamID {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
return
|
|
}
|
|
|
|
events, err := m.teamStore.ListTaskEvents(ctx, taskID)
|
|
if err != nil {
|
|
slog.Warn("teams.tasks.events failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"events": events,
|
|
}))
|
|
}
|
|
|
|
// --- Task Create ---
|
|
|
|
type taskCreateParams struct {
|
|
TeamID string `json:"teamId"`
|
|
Subject string `json:"subject"`
|
|
Description string `json:"description"`
|
|
Priority int `json:"priority"`
|
|
TaskType string `json:"taskType"`
|
|
AssignTo string `json:"assignTo"` // optional agent UUID — assign immediately after creation
|
|
Channel string `json:"channel"` // optional scope — defaults to "dashboard"
|
|
ChatID string `json:"chatId"` // optional scope — defaults to teamID
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskCreateParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
|
|
if params.Subject == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "subject")))
|
|
return
|
|
}
|
|
if len(params.Subject) > 500 {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "subject too long"))
|
|
return
|
|
}
|
|
if len(params.Description) > maxCommentLength {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "description too long"))
|
|
return
|
|
}
|
|
|
|
taskType := params.TaskType
|
|
if taskType == "" {
|
|
taskType = "general"
|
|
}
|
|
|
|
ch := params.Channel
|
|
if ch == "" {
|
|
ch = "dashboard"
|
|
}
|
|
cid := params.ChatID
|
|
if cid == "" {
|
|
cid = teamID.String()
|
|
}
|
|
|
|
task := &store.TeamTaskData{
|
|
TeamID: teamID,
|
|
Subject: params.Subject,
|
|
Description: params.Description,
|
|
Status: store.TeamTaskStatusPending,
|
|
Priority: params.Priority,
|
|
TaskType: taskType,
|
|
UserID: client.UserID(),
|
|
Channel: ch,
|
|
ChatID: cid,
|
|
}
|
|
|
|
if err := m.teamStore.CreateTask(ctx, task); err != nil {
|
|
slog.Warn("teams.tasks.create failed", "team_id", teamID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
return
|
|
}
|
|
|
|
// Auto-assign: use explicit assignTo, otherwise fall back to team lead.
|
|
assignTo := params.AssignTo
|
|
if assignTo == "" {
|
|
team, err := m.teamStore.GetTeam(ctx, teamID)
|
|
if err == nil && team != nil && team.LeadAgentID != uuid.Nil {
|
|
assignTo = team.LeadAgentID.String()
|
|
}
|
|
}
|
|
var autoAssignedAgentID uuid.UUID
|
|
if assignTo != "" {
|
|
agentID, err := uuid.Parse(assignTo)
|
|
if err == nil {
|
|
if err := m.teamStore.AssignTask(ctx, task.ID, agentID, teamID); err != nil {
|
|
slog.Warn("teams.tasks.create auto-assign failed", "task_id", task.ID, "agent_id", agentID, "error", err)
|
|
} else {
|
|
task.Status = store.TeamTaskStatusInProgress
|
|
task.OwnerAgentID = &agentID
|
|
autoAssignedAgentID = agentID
|
|
}
|
|
}
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"task": task}))
|
|
|
|
if m.msgBus != nil {
|
|
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
|
|
TeamID: teamID.String(),
|
|
TaskID: task.ID.String(),
|
|
Status: store.TeamTaskStatusPending,
|
|
UserID: client.UserID(),
|
|
Channel: ch,
|
|
ChatID: cid,
|
|
Timestamp: taskNowUTC(),
|
|
ActorType: "human",
|
|
ActorID: client.UserID(),
|
|
}))
|
|
|
|
if autoAssignedAgentID != uuid.Nil {
|
|
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
|
|
TeamID: teamID.String(),
|
|
TaskID: task.ID.String(),
|
|
Status: store.TeamTaskStatusInProgress,
|
|
OwnerAgentKey: autoAssignedAgentID.String(),
|
|
UserID: client.UserID(),
|
|
Channel: ch,
|
|
ChatID: cid,
|
|
Timestamp: taskNowUTC(),
|
|
ActorType: "human",
|
|
ActorID: client.UserID(),
|
|
}))
|
|
|
|
// Dispatch to assigned agent.
|
|
m.dispatchTaskToAgent(ctx, task, task.ID, teamID, autoAssignedAgentID, client.UserID())
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Task Assign ---
|
|
|
|
type taskAssignParams struct {
|
|
TeamID string `json:"teamId"`
|
|
TaskID string `json:"taskId"`
|
|
AgentID string `json:"agentId"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskAssign(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskAssignParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
taskID, err := uuid.Parse(params.TaskID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
|
return
|
|
}
|
|
agentID, err := uuid.Parse(params.AgentID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agentId")))
|
|
return
|
|
}
|
|
|
|
// Validate task belongs to team (prevent IDOR).
|
|
task, err := m.teamStore.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrTaskNotFound) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
} else {
|
|
slog.Warn("teams.tasks.assign get failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
}
|
|
return
|
|
}
|
|
if task.TeamID != teamID {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
return
|
|
}
|
|
|
|
if err := m.teamStore.AssignTask(ctx, taskID, agentID, teamID); err != nil {
|
|
slog.Warn("teams.tasks.assign failed", "task_id", taskID, "agent_id", agentID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
|
|
|
if m.msgBus != nil {
|
|
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
|
|
TeamID: teamID.String(),
|
|
TaskID: taskID.String(),
|
|
Status: store.TeamTaskStatusInProgress,
|
|
UserID: client.UserID(),
|
|
Channel: task.Channel,
|
|
ChatID: task.ChatID,
|
|
Timestamp: taskNowUTC(),
|
|
ActorType: "human",
|
|
ActorID: client.UserID(),
|
|
}))
|
|
|
|
// Dispatch task to the assigned agent via message bus so the consumer
|
|
// routes it through the agent loop (same pattern as team_message).
|
|
m.dispatchTaskToAgent(ctx, task, taskID, teamID, agentID, client.UserID())
|
|
}
|
|
}
|
|
|
|
// --- Task Delete (hard-delete terminal-status tasks) ---
|
|
|
|
type taskDeleteParams struct {
|
|
TeamID string `json:"teamId"`
|
|
TaskID string `json:"taskId"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskDeleteParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
taskID, err := uuid.Parse(params.TaskID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
|
return
|
|
}
|
|
|
|
// Validate task belongs to team (prevent IDOR).
|
|
task, err := m.teamStore.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrTaskNotFound) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
} else {
|
|
slog.Warn("teams.tasks.delete get failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
}
|
|
return
|
|
}
|
|
if task.TeamID != teamID {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
|
return
|
|
}
|
|
|
|
if err := m.teamStore.DeleteTask(ctx, taskID, teamID); err != nil {
|
|
if errors.Is(err, store.ErrTaskNotFound) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "task is not in a deletable state"))
|
|
} else {
|
|
slog.Warn("teams.tasks.delete failed", "task_id", taskID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
}
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
|
|
|
if m.msgBus != nil {
|
|
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskDeleted, protocol.TeamTaskEventPayload{
|
|
TeamID: teamID.String(),
|
|
TaskID: taskID.String(),
|
|
Status: task.Status,
|
|
UserID: client.UserID(),
|
|
Channel: "dashboard",
|
|
Timestamp: taskNowUTC(),
|
|
ActorType: "human",
|
|
ActorID: client.UserID(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
// --- Task Delete Bulk (hard-delete multiple terminal-status tasks) ---
|
|
|
|
type taskDeleteBulkParams struct {
|
|
TeamID string `json:"teamId"`
|
|
TaskIDs []string `json:"taskIds"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleTaskDeleteBulk(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
var params taskDeleteBulkParams
|
|
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
|
return
|
|
}
|
|
if len(params.TaskIDs) == 0 {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "taskIds is required"))
|
|
return
|
|
}
|
|
|
|
taskUUIDs := make([]uuid.UUID, 0, len(params.TaskIDs))
|
|
for _, raw := range params.TaskIDs {
|
|
id, err := uuid.Parse(raw)
|
|
if err != nil {
|
|
continue // skip invalid IDs
|
|
}
|
|
taskUUIDs = append(taskUUIDs, id)
|
|
}
|
|
if len(taskUUIDs) == 0 {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "no valid taskIds"))
|
|
return
|
|
}
|
|
|
|
deleted, err := m.teamStore.DeleteTasks(ctx, taskUUIDs, teamID)
|
|
if err != nil {
|
|
slog.Warn("teams.tasks.delete-bulk failed", "team_id", teamID, "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"deleted": len(deleted),
|
|
}))
|
|
|
|
// Broadcast delete event per task for real-time UI sync.
|
|
if m.msgBus != nil {
|
|
for _, id := range deleted {
|
|
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskDeleted, protocol.TeamTaskEventPayload{
|
|
TeamID: teamID.String(),
|
|
TaskID: id.String(),
|
|
UserID: client.UserID(),
|
|
Channel: "dashboard",
|
|
Timestamp: taskNowUTC(),
|
|
ActorType: "human",
|
|
ActorID: client.UserID(),
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
// dispatchTaskToAgent publishes a teammate-style inbound message so the
|
|
// gateway consumer picks it up and runs the assigned agent, then auto-completes
|
|
// the task on success or auto-fails on error.
|
|
func (m *TeamsMethods) dispatchTaskToAgent(ctx context.Context, task *store.TeamTaskData, taskID, teamID, agentID uuid.UUID, userID string) {
|
|
ag, err := m.agentStore.GetByID(ctx, agentID)
|
|
if err != nil {
|
|
slog.Warn("teams.tasks.dispatch: cannot resolve agent", "agent_id", agentID, "error", err)
|
|
return
|
|
}
|
|
|
|
// Build task prompt for the agent.
|
|
content := fmt.Sprintf("[Assigned task #%d (id: %s)]: %s", task.TaskNumber, task.ID, task.Subject)
|
|
if task.Description != "" {
|
|
content += "\n\n" + task.Description
|
|
}
|
|
|
|
// Use the task's original channel/chat so completion announcements route
|
|
// back to the user's real channel (e.g. Telegram) instead of void "dashboard".
|
|
originChannel := task.Channel
|
|
if originChannel == "" {
|
|
originChannel = "dashboard"
|
|
}
|
|
fromAgent := "dashboard"
|
|
if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil && team != nil {
|
|
if leadAg, err := m.agentStore.GetByID(ctx, team.LeadAgentID); err == nil {
|
|
fromAgent = leadAg.AgentKey
|
|
}
|
|
}
|
|
|
|
// Resolve peer kind from task metadata; fallback to "direct" for old tasks.
|
|
originPeerKind := "direct"
|
|
if task.Metadata != nil {
|
|
if pk, ok := task.Metadata["peer_kind"].(string); ok && pk != "" {
|
|
originPeerKind = pk
|
|
}
|
|
}
|
|
|
|
meta := map[string]string{
|
|
"origin_channel": originChannel,
|
|
"origin_peer_kind": originPeerKind,
|
|
"origin_chat_id": task.ChatID,
|
|
"from_agent": fromAgent,
|
|
"to_agent": ag.AgentKey,
|
|
"team_task_id": taskID.String(),
|
|
"team_id": teamID.String(),
|
|
}
|
|
// Pass team workspace and local key from task metadata.
|
|
if task.Metadata != nil {
|
|
if ws, _ := task.Metadata["team_workspace"].(string); ws != "" {
|
|
meta["team_workspace"] = ws
|
|
}
|
|
if lk, _ := task.Metadata["local_key"].(string); lk != "" {
|
|
meta["origin_local_key"] = lk
|
|
}
|
|
}
|
|
|
|
m.msgBus.PublishInbound(bus.InboundMessage{
|
|
Channel: "system",
|
|
SenderID: "teammate:dashboard",
|
|
ChatID: teamID.String(),
|
|
Content: content,
|
|
UserID: userID,
|
|
AgentID: ag.AgentKey,
|
|
Metadata: meta,
|
|
})
|
|
slog.Info("teams.tasks.dispatch: sent task to agent",
|
|
"task_id", taskID,
|
|
"agent_key", ag.AgentKey,
|
|
"team_id", teamID,
|
|
)
|
|
}
|