diff --git a/Makefile b/Makefile index 1c62ebbb..a3303191 100644 --- a/Makefile +++ b/Makefile @@ -176,15 +176,19 @@ build-docker-embed-runtime-embed: build-docker-embed-images patch-docker-embed-i build-picoclaw-runtime-embed: build-docker-embed-runtime-embed build-picoclaw-manager-image: stage-docker-embed-cli - chmod +x scripts/prepare-docker-embed-dist.sh scripts/build-docker-embed-images.sh - scripts/prepare-docker-embed-dist.sh picoclaw-manager + chmod +x scripts/prepare-docker-embed-dist.sh scripts/patch-docker-embed-image-refs.sh scripts/build-docker-embed-images.sh + scripts/prepare-docker-embed-dist.sh + ACR_REGISTRY="$(ACR_REGISTRY)" VERSION="$(DOCKER_EMBED_IMAGE_TAG)" \ + scripts/patch-docker-embed-image-refs.sh ACR_REGISTRY="$(ACR_REGISTRY)" PICOCLAW_BASE_IMAGE="$(PICOCLAW_BASE_IMAGE)" \ DOCKER_EMBED_IMAGE_TAG="$(DOCKER_EMBED_IMAGE_TAG)" \ scripts/build-docker-embed-images.sh picoclaw-manager build-picoclaw-worker-image: stage-docker-embed-cli - chmod +x scripts/prepare-docker-embed-dist.sh scripts/build-docker-embed-images.sh - scripts/prepare-docker-embed-dist.sh picoclaw-worker + chmod +x scripts/prepare-docker-embed-dist.sh scripts/patch-docker-embed-image-refs.sh scripts/build-docker-embed-images.sh + scripts/prepare-docker-embed-dist.sh + ACR_REGISTRY="$(ACR_REGISTRY)" VERSION="$(DOCKER_EMBED_IMAGE_TAG)" \ + scripts/patch-docker-embed-image-refs.sh ACR_REGISTRY="$(ACR_REGISTRY)" PICOCLAW_BASE_IMAGE="$(PICOCLAW_BASE_IMAGE)" \ DOCKER_EMBED_IMAGE_TAG="$(DOCKER_EMBED_IMAGE_TAG)" \ scripts/build-docker-embed-images.sh picoclaw-worker diff --git a/cli/app.go b/cli/app.go index bfe57b1d..27499dba 100644 --- a/cli/app.go +++ b/cli/app.go @@ -10,13 +10,13 @@ import ( "strings" agentcmd "csgclaw/cli/agent" - "csgclaw/cli/bot" "csgclaw/cli/command" completioncmd "csgclaw/cli/completion" hubcmd "csgclaw/cli/hub" "csgclaw/cli/member" "csgclaw/cli/message" modelcmd "csgclaw/cli/model" + participantcmd "csgclaw/cli/participant" "csgclaw/cli/room" servecmd "csgclaw/cli/serve" skillcmd "csgclaw/cli/skill" @@ -79,8 +79,9 @@ func (a *App) registerDefaultCommands() { hubcmd.NewCmd(), skillcmd.NewCmd(), modelcmd.NewCmd(), + participantcmd.NewCmd(), + participantcmd.NewAliasCmd("pt"), usercmd.NewCmd(), - bot.NewCmd(), room.NewCmd(), member.NewCmd(), message.NewCmd(), @@ -205,11 +206,10 @@ func (a *App) usage() { fmt.Fprintln(a.stderr, " csgclaw [global-flags] [args]") fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Available Commands:") - for _, cmd := range a.order { - if hidden, ok := cmd.(interface{ Hidden() bool }); ok && hidden.Hidden() { - continue - } - fmt.Fprintf(a.stderr, " %-8s %s\n", cmd.Name(), cmd.Summary()) + commands := a.visibleCommands() + width := commandNameWidth(commands) + for _, cmd := range commands { + fmt.Fprintf(a.stderr, " %-*s %s\n", width, cmd.Name(), cmd.Summary()) } fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Examples:") @@ -230,6 +230,27 @@ func (a *App) usage() { fmt.Fprintln(a.stderr, " --version, -V Print version and exit") } +func (a *App) visibleCommands() []command.Command { + commands := make([]command.Command, 0, len(a.order)) + for _, cmd := range a.order { + if hidden, ok := cmd.(interface{ Hidden() bool }); ok && hidden.Hidden() { + continue + } + commands = append(commands, cmd) + } + return commands +} + +func commandNameWidth(commands []command.Command) int { + width := 8 + for _, cmd := range commands { + if n := len(cmd.Name()); n > width { + width = n + } + } + return width + 1 +} + func (a *App) printVersion(output string) error { version := appversion.Current() if output == "json" { diff --git a/cli/app_test.go b/cli/app_test.go index 5b6ca685..060cc723 100644 --- a/cli/app_test.go +++ b/cli/app_test.go @@ -17,8 +17,8 @@ import ( "testing" "csgclaw/internal/apitypes" - "csgclaw/internal/bot" "csgclaw/internal/channel/feishu" + "csgclaw/internal/participant" appversion "csgclaw/internal/version" ) @@ -44,12 +44,27 @@ func TestExecuteAgentListUsesHTTPClientJSON(t *testing.T) { } } -func TestExecuteChannelCommandIsRemoved(t *testing.T) { - var stderr bytes.Buffer - app := &App{stdout: io.Discard, stderr: &stderr} - err := app.Execute(context.Background(), []string{"channel", "reload"}) - if err == nil || !strings.Contains(err.Error(), `unknown command "channel"`) { - t.Fatalf("Execute() error = %v, want unknown channel command", err) +func TestExecuteParticipantConfigGetUsesHTTPClient(t *testing.T) { + var stdout bytes.Buffer + app := &App{ + stdout: &stdout, + stderr: &bytes.Buffer{}, + httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodGet { + t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) + } + if req.URL.String() != "http://example.test/api/v1/channels/feishu/config?bot_id=u-manager" { + t.Fatalf("url = %q, want Feishu config route", req.URL.String()) + } + return jsonResponse(http.StatusOK, `{"bot_id":"u-manager","configured":true,"app_id":"cli_xxx","app_secret":"present","admin_open_id":"ou_xxx"}`), nil + }), + } + + if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "participant", "config", "--channel", "feishu", "--get", "--bot-id", "u-manager"}); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(stdout.String(), `"bot_id": "u-manager"`) || !strings.Contains(stdout.String(), `"app_secret": "present"`) { + t.Fatalf("stdout = %q, want masked Feishu config JSON", stdout.String()) } } @@ -199,118 +214,7 @@ func TestExecuteAgentStopUsesHTTPClient(t *testing.T) { assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "stopped", "codex", "codex-main", "ghcr.io/opencsg/csgclaw-agent:2026.4.28") } -func TestExecuteBotListUsesDefaultChannel(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots" { - t.Fatalf("url = %q, want csgclaw bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[{"id":"bot-alice","name":"alice","role":"worker","channel":"csgclaw","runtime_kind":"codex","agent_id":"u-alice","user_id":"u-alice","available":true,"created_at":"2026-04-12T09:00:00Z"}]`), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - assertTableHasRow(t, stdout.String(), "bot-alice", "alice", "-", "worker", "csgclaw", "u-alice", "u-alice", "true", "codex", "2026-04-12T09:00:00Z") -} - -func TestExecuteBotListFeishuUsesChannelQuery(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want feishu bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","role":"manager","channel":"feishu","agent_id":"u-manager","user_id":"fsu-manager","created_at":"2026-04-12T09:00:00Z"}]`), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "list", "--channel", "feishu"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), `"id": "bot-feishu"`) || !strings.Contains(stdout.String(), `"channel": "feishu"`) { - t.Fatalf("stdout = %q, want JSON bot payload", stdout.String()) - } - for _, want := range []string{`"agent_id": "u-manager"`, `"user_id": "fsu-manager"`, `"created_at": "2026-04-12T09:00:00Z"`} { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("stdout = %q, want full csgclaw bot list field %s", stdout.String(), want) - } - } -} - -func TestExecuteBotListUsesChannelBotsRoute(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want GET", req.Method) - } - if strings.Contains(req.URL.String(), "type=notification") { - t.Fatalf("url = %q, bot list must not use type=notification query", req.URL.String()) - } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots" { - t.Fatalf("url = %q, want channel bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[]`), nil - }), - } - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } -} - -func TestExecuteBotListUsesTypeQuery(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots?type=notification" { - t.Fatalf("url = %q, want type=notification on bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[]`), nil - }), - } - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list", "--type", "notification"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } -} - -func TestExecuteBotListUsesRoleQuery(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots?role=worker" { - t.Fatalf("url = %q, want role-filtered bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[{"id":"bot-alice","name":"alice","description":"abcdefghijklmnopqrstuvwxyz1234567890ABCDE","role":"worker","channel":"csgclaw","runtime_kind":"codex","agent_id":"u-alice","user_id":"u-alice","available":true,"created_at":"2026-04-12T09:00:00Z"}]`), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list", "--role", "worker"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - assertTableHasRow(t, stdout.String(), "bot-alice", "alice", "abcdefghijklmnopqrstuvwxyz1234567890ABCD...", "worker", "csgclaw", "u-alice", "u-alice", "true", "codex", "2026-04-12T09:00:00Z") -} - -func TestExecuteBotCreateUsesDefaultChannel(t *testing.T) { +func TestExecuteParticipantCreateSendsTemplateDescriptionAndEnv(t *testing.T) { var stdout bytes.Buffer app := &App{ stdout: &stdout, @@ -319,205 +223,54 @@ func TestExecuteBotCreateUsesDefaultChannel(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("method = %q, want %q", req.Method, http.MethodPost) } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots" { - t.Fatalf("url = %q, want %q", req.URL.String(), "http://example.test/api/v1/channels/csgclaw/bots") + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want feishu participant route", req.URL.String()) } - var payload bot.CreateRequest + var payload participant.CreateRequest if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { t.Fatalf("decode request: %v", err) } - if payload.Name != "alice" || payload.Role != "worker" || payload.Channel != "csgclaw" { - t.Fatalf("payload = %+v, want alice worker csgclaw", payload) + if payload.ID != "u-gitlab" || payload.Name != "gitlab" || payload.Type != "agent" || payload.Channel != "feishu" { + t.Fatalf("payload = %+v, want u-gitlab gitlab agent feishu", payload) } - if payload.Description != "test lead" { - t.Fatalf("payload = %+v, want description", payload) + if payload.Metadata["description"] != "GitLab worker" { + t.Fatalf("payload.Metadata = %#v, want description", payload.Metadata) } - if payload.AgentProfile == nil || payload.AgentProfile.ModelID != "gpt-test" { - t.Fatalf("payload.AgentProfile = %+v, want model_id", payload.AgentProfile) + if payload.AgentBinding.Mode != "create" || payload.AgentBinding.AgentID != "u-gitlab" || payload.AgentBinding.Agent == nil { + t.Fatalf("payload.AgentBinding = %+v, want create u-gitlab", payload.AgentBinding) } - if payload.RuntimeKind != "codex" { - t.Fatalf("payload.RuntimeKind = %q, want codex", payload.RuntimeKind) + spec := payload.AgentBinding.Agent + if spec.Description != "GitLab worker" || spec.FromTemplate != "builtin.gitlab-worker" || spec.Role != "worker" { + t.Fatalf("payload.AgentBinding.Agent = %+v, want description/template/role", spec) } - return jsonResponse(http.StatusCreated, `{"id":"u-alice","name":"alice","description":"test-lead","role":"worker","channel":"csgclaw","runtime_kind":"codex","agent_id":"u-alice","user_id":"u-alice","available":true,"created_at":"2026-04-12T09:00:00Z"}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "create", "--name", "alice", "--description", "test lead", "--role", "worker", "--model-id", "gpt-test", "--runtime", "codex"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - assertTableHasRow(t, stdout.String(), "u-alice", "alice", "test-lead", "worker", "csgclaw") -} - -func TestExecuteBotCreateSendsFromTemplateAndEnv(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPost { - t.Fatalf("method = %q, want %q", req.Method, http.MethodPost) + if spec.AgentProfile.ModelID != "gpt-test" || spec.AgentProfile.Env["GITLAB_TOKEN"] != "secret" { + t.Fatalf("payload.AgentBinding.Agent.AgentProfile = %+v, want model and env", spec.AgentProfile) } - var payload bot.CreateRequest - if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { - t.Fatalf("decode request: %v", err) - } - if payload.FromTemplate != "builtin.gitlab-worker" { - t.Fatalf("payload.FromTemplate = %q, want builtin.gitlab-worker", payload.FromTemplate) - } - if payload.AgentProfile == nil || payload.AgentProfile.Env["GITLAB_TOKEN"] != "secret" { - t.Fatalf("payload.AgentProfile.Env = %#v, want GITLAB_TOKEN", payload.AgentProfile) - } - return jsonResponse(http.StatusCreated, `{"id":"u-gitlab","name":"gitlab","description":"gitlab-worker","role":"worker","channel":"csgclaw","runtime_kind":"picoclaw_sandbox","agent_id":"u-gitlab","user_id":"u-gitlab","available":true,"created_at":"2026-04-12T09:00:00Z"}`), nil + return jsonResponse(http.StatusCreated, `{"id":"u-gitlab","name":"gitlab","type":"agent","channel":"feishu","agent_id":"u-gitlab","channel_user_ref":"u-gitlab","lifecycle_status":"active","metadata":{"description":"GitLab worker"},"created_at":"2026-04-12T09:00:00Z"}`), nil }), } err := app.Execute(context.Background(), []string{ "--endpoint", "http://example.test", - "bot", "create", + "--output", "json", + "participant", "create", + "--id", "u-gitlab", "--name", "gitlab", - "--description", "gitlab worker", + "--description", "GitLab worker", + "--type", "agent", + "--channel", "feishu", + "--bind", "create", + "--agent-id", "u-gitlab", "--role", "worker", "--from-template", "builtin.gitlab-worker", + "--model-id", "gpt-test", "--env", "GITLAB_TOKEN=secret", }) if err != nil { t.Fatalf("Execute() error = %v", err) } - assertTableHasRow(t, stdout.String(), "u-gitlab", "gitlab", "gitlab-worker", "worker", "csgclaw") -} - -func TestExecuteBotCreateFeishuSendsChannelPayload(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPost { - t.Fatalf("method = %q, want %q", req.Method, http.MethodPost) - } - var payload bot.CreateRequest - if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { - t.Fatalf("decode request: %v", err) - } - if payload.ID != "u-alice" || payload.Name != "alice" || payload.Role != "worker" || payload.Channel != "feishu" { - t.Fatalf("payload = %+v, want u-alice alice worker feishu", payload) - } - return jsonResponse(http.StatusCreated, `{"id":"u-alice","name":"alice","role":"worker","channel":"feishu","agent_id":"u-alice","user_id":"u-alice","created_at":"2026-04-12T09:00:00Z"}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "create", "--id", "u-alice", "--name", "alice", "--role", "worker", "--channel", "feishu"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), `"id": "u-alice"`) || !strings.Contains(stdout.String(), `"channel": "feishu"`) { - t.Fatalf("stdout = %q, want JSON feishu bot payload", stdout.String()) - } -} - -func TestExecuteBotDeleteUsesDefaultChannel(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodDelete { - t.Fatalf("method = %q, want %q", req.Method, http.MethodDelete) - } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots/u-alice" { - t.Fatalf("url = %q, want csgclaw bot delete route", req.URL.String()) - } - return jsonResponse(http.StatusNoContent, ``), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "delete", "u-alice"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } -} - -func TestExecuteBotDeleteFeishuUsesChannelQuery(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodDelete { - t.Fatalf("method = %q, want %q", req.Method, http.MethodDelete) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots/u-alice" { - t.Fatalf("url = %q, want feishu bot delete route", req.URL.String()) - } - return jsonResponse(http.StatusNoContent, ``), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "delete", "--channel", "feishu", "u-alice"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } -} - -func TestExecuteBotDeleteSupportsJSONOutput(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodDelete { - t.Fatalf("method = %q, want %q", req.Method, http.MethodDelete) - } - return jsonResponse(http.StatusNoContent, ``), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "delete", "--channel", "feishu", "u-alice"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - for _, want := range []string{`"command": "bot"`, `"action": "delete"`, `"status": "deleted"`, `"id": "u-alice"`, `"channel": "feishu"`} { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("stdout = %q, want %s", stdout.String(), want) - } - } -} - -func TestExecuteBotConfigGetUsesFeishuConfigRoute(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/config?bot_id=u-dev" { - t.Fatalf("url = %q, want feishu config get route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `{"bot_id":"u-dev","configured":true,"app_id":"cli_dev","app_secret":"present"}`), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "config", "--channel", "feishu", "--get", "--bot-id", "u-dev"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), "u-dev") || !strings.Contains(stdout.String(), "present") { - t.Fatalf("stdout = %s, want bot and masked secret", stdout.String()) - } -} - -func TestExecuteBotCreateRequiresNameAndRole(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, nil }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "create", "--role", "worker"}) - if err == nil || !strings.Contains(err.Error(), "requires --name") { - t.Fatalf("Execute(missing name) error = %v, want --name error", err) - } - - err = app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "create", "--name", "alice"}) - if err == nil || !strings.Contains(err.Error(), "requires --role") { - t.Fatalf("Execute(missing role) error = %v, want --role error", err) + if !strings.Contains(stdout.String(), `"id": "u-gitlab"`) || !strings.Contains(stdout.String(), `"channel": "feishu"`) { + t.Fatalf("stdout = %q, want created participant JSON", stdout.String()) } } @@ -1969,19 +1722,23 @@ func TestUsageIncludesTopLevelCommandIndex(t *testing.T) { got := stderr.String() for _, want := range []string{ "Available Commands:", - "agent Manage agents", - "model Manage model providers.", - "bot Manage bots", - "room Manage IM rooms", - "member Manage IM room members", - "team Manage agent teams.", - "user Manage IM users", - "completion Generate shell completion scripts.", + "agent Manage agents", + "model Manage model providers.", + "participant Manage channel participants.", + "pt Manage channel participants.", + "room Manage IM rooms", + "member Manage IM room members", + "team Manage agent teams.", + "user Manage IM users", + "completion Generate shell completion scripts.", } { if !strings.Contains(got, want) { t.Fatalf("usage = %q, want substring %q", got, want) } } + if strings.Contains(got, "Manage bots") || strings.Contains(got, "\n bot ") { + t.Fatalf("usage = %q, should not include bot command", got) + } } func TestRootHelpIncludesAvailableCommands(t *testing.T) { @@ -2000,19 +1757,23 @@ func TestRootHelpIncludesAvailableCommands(t *testing.T) { got := stderr.String() for _, want := range []string{ "Available Commands:", - "agent Manage agents", - "model Manage model providers.", - "bot Manage bots", - "room Manage IM rooms", - "member Manage IM room members", - "team Manage agent teams.", - "user Manage IM users", - "completion Generate shell completion scripts.", + "agent Manage agents", + "model Manage model providers.", + "participant Manage channel participants.", + "pt Manage channel participants.", + "room Manage IM rooms", + "member Manage IM room members", + "team Manage agent teams.", + "user Manage IM users", + "completion Generate shell completion scripts.", } { if !strings.Contains(got, want) { t.Fatalf("help = %q, want substring %q", got, want) } } + if strings.Contains(got, "Manage bots") || strings.Contains(got, "\n bot ") { + t.Fatalf("help = %q, should not include bot command", got) + } } func TestExecuteDoesNotRegisterTopLevelAuthAliases(t *testing.T) { @@ -2266,33 +2027,6 @@ func TestAgentHelpIncludesSubcommands(t *testing.T) { } } -func TestBotHelpIncludesSubcommands(t *testing.T) { - var stderr bytes.Buffer - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &stderr, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, nil }), - } - - err := app.Execute(context.Background(), []string{"bot", "-h"}) - if err != flag.ErrHelp { - t.Fatalf("Execute() error = %v, want %v", err, flag.ErrHelp) - } - - got := stderr.String() - for _, want := range []string{ - "Manage bots.", - "csgclaw bot [flags]", - "list List bots", - "create Create a bot", - "delete Delete a bot", - } { - if !strings.Contains(got, want) { - t.Fatalf("help = %q, want substring %q", got, want) - } - } -} - func TestAgentSubcommandHelpIncludesUsageAndFlags(t *testing.T) { var stderr bytes.Buffer app := &App{ @@ -2395,7 +2129,7 @@ func TestExecuteStartIsRejected(t *testing.T) { if !strings.Contains(err.Error(), `unknown command "start"`) { t.Fatalf("Execute() error = %v, want unknown command start", err) } - if !strings.Contains(stderr.String(), " serve Start the local HTTP server") { + if !strings.Contains(stderr.String(), " serve Start the local HTTP server") { t.Fatalf("stderr = %q, want serve command in usage", stderr.String()) } } diff --git a/cli/bot/bot.go b/cli/bot/bot.go deleted file mode 100644 index 78021378..00000000 --- a/cli/bot/bot.go +++ /dev/null @@ -1,208 +0,0 @@ -package bot - -import ( - "context" - "flag" - "fmt" - "strings" - - "csgclaw/cli/command" - "csgclaw/internal/apitypes" - botdomain "csgclaw/internal/bot" -) - -type cmd struct{} - -func NewCmd() command.Command { - return cmd{} -} - -func (cmd) Name() string { - return "bot" -} - -func (cmd) Summary() string { - return "Manage bots." -} - -func (c cmd) Run(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { - if len(args) == 0 { - c.usage(run) - return flag.ErrHelp - } - if command.IsHelpArg(args[0]) { - c.usage(run) - return flag.ErrHelp - } - - switch args[0] { - case "list": - return c.runList(ctx, run, args[1:], globals) - case "create": - return c.runCreate(ctx, run, args[1:], globals) - case "delete": - return c.runDelete(ctx, run, args[1:], globals) - case "config": - return c.runConfig(ctx, run, args[1:], globals) - default: - c.usage(run) - return fmt.Errorf("unknown bot subcommand %q", args[0]) - } -} - -func (c cmd) usage(run *command.Context) { - subcommands := []string{ - "list List bots (--type normal|notification optional; csgclaw default includes notification)", - "create Create a bot", - "delete Delete a bot", - "config Manage bot channel config", - } - run.UsageCommandGroup(c, run.Program+" bot [flags]", subcommands) -} - -func (c cmd) runList(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { - fs := run.NewFlagSet("bot list", run.Program+" bot list [flags]", "List bots (csgclaw includes notification bots; feishu lists normal bots only).") - channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") - role := fs.String("role", "", "bot role: manager or worker") - botType := fs.String("type", "", "bot type filter: normal or notification (default: all types allowed for channel)") - if err := fs.Parse(args); err != nil { - return err - } - if len(fs.Args()) != 0 { - return fmt.Errorf("bot list does not accept positional arguments") - } - - client := run.APIClient(globals) - typeFilter := strings.TrimSpace(*botType) - if typeFilter != "" { - typeFilter = botdomain.NormalizeBotType(typeFilter) - } - bots, err := client.ListBots(ctx, *channelName, *role, typeFilter) - if err != nil { - return err - } - return renderBotList(run, globals, bots) -} - -func renderBotList(run *command.Context, globals command.GlobalOptions, bots []apitypes.Bot) error { - if strings.TrimSpace(run.Program) == "csgclaw-cli" { - return command.RenderCompactBotList(globals.Output, run.Stdout, bots) - } - return command.RenderFullBotList(globals.Output, run.Stdout, bots) -} - -func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { - fs := run.NewFlagSet("bot create", run.Program+" bot create [flags]", "Create a bot.") - id := fs.String("id", "", "bot id") - name := fs.String("name", "", "bot name") - description := fs.String("description", "", "bot description") - role := fs.String("role", "", "bot role: manager or worker") - channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") - modelID := fs.String("model-id", "", "agent model identifier") - runtimeKind := fs.String("runtime", "", "agent runtime kind for worker bots (for example: picoclaw_sandbox, openclaw_sandbox, codex)") - fromTemplate := fs.String("from-template", "", "hub template to use as creation defaults and workspace overlay") - var envValues envFlag - fs.Var(&envValues, "env", "agent image environment variable as KEY=VALUE (repeatable)") - botType := fs.String("type", botdomain.BotTypeNormal, "bot type: normal or notification") - if err := fs.Parse(args); err != nil { - return err - } - if len(fs.Args()) != 0 { - return fmt.Errorf("bot create does not accept positional arguments") - } - if *name == "" { - return fmt.Errorf("bot create requires --name") - } - if *role == "" { - return fmt.Errorf("bot create requires --role") - } - - envMap, err := parseEnvAssignments(envValues) - if err != nil { - return err - } - - req := apitypes.CreateBotRequest{ - ID: *id, - Name: *name, - Description: *description, - Type: botdomain.NormalizeBotType(*botType), - Role: *role, - Channel: *channelName, - RuntimeKind: *runtimeKind, - FromTemplate: *fromTemplate, - } - if strings.TrimSpace(*modelID) != "" || len(envMap) > 0 { - req.AgentProfile = &apitypes.CreateAgentProfile{ModelID: *modelID, Env: envMap} - } - client := run.APIClient(globals) - var created apitypes.Bot - if req.Type == botdomain.BotTypeNotification { - created, err = client.CreateNotificationBot(ctx, req) - } else { - created, err = client.CreateBot(ctx, req) - } - if err != nil { - return err - } - return command.RenderBots(globals.Output, run.Stdout, []apitypes.Bot{created}) -} - -func (c cmd) runDelete(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { - fs := run.NewFlagSet("bot delete", run.Program+" bot delete [flags]", "Delete a bot.") - channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") - if err := fs.Parse(args); err != nil { - return err - } - - rest := fs.Args() - if len(rest) != 1 { - return fmt.Errorf("bot delete requires exactly one id") - } - - if err := run.APIClient(globals).DeleteBot(ctx, *channelName, rest[0]); err != nil { - return err - } - return command.RenderAction(globals.Output, run.Stdout, command.ActionResult{ - Command: "bot", - Action: "delete", - Status: "deleted", - ID: rest[0], - Channel: *channelName, - Message: fmt.Sprintf("deleted %s bot %s", *channelName, rest[0]), - }) -} - -type envFlag []string - -func (e *envFlag) String() string { - return strings.Join(*e, ",") -} - -func (e *envFlag) Set(value string) error { - *e = append(*e, value) - return nil -} - -func parseEnvAssignments(values []string) (map[string]string, error) { - if len(values) == 0 { - return nil, nil - } - out := make(map[string]string, len(values)) - for _, raw := range values { - raw = strings.TrimSpace(raw) - if raw == "" { - continue - } - key, value, ok := strings.Cut(raw, "=") - key = strings.TrimSpace(key) - if !ok || key == "" { - return nil, fmt.Errorf("invalid --env %q: expected KEY=VALUE", raw) - } - if _, exists := out[key]; exists { - return nil, fmt.Errorf("duplicate --env key %q", key) - } - out[key] = value - } - return out, nil -} diff --git a/cli/bot/bot_test.go b/cli/bot/bot_test.go deleted file mode 100644 index 6039116d..00000000 --- a/cli/bot/bot_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package bot - -import "testing" - -func TestParseEnvAssignments(t *testing.T) { - t.Parallel() - - got, err := parseEnvAssignments([]string{"GITLAB_TOKEN=secret", "GITLAB_URL=https://gitlab.example.com"}) - if err != nil { - t.Fatalf("parseEnvAssignments() error = %v", err) - } - if got["GITLAB_TOKEN"] != "secret" || got["GITLAB_URL"] != "https://gitlab.example.com" { - t.Fatalf("parseEnvAssignments() = %#v", got) - } -} - -func TestParseEnvAssignmentsRejectsInvalid(t *testing.T) { - t.Parallel() - - if _, err := parseEnvAssignments([]string{"NOT_A_PAIR"}); err == nil { - t.Fatal("parseEnvAssignments() error = nil, want invalid env") - } -} - -func TestParseEnvAssignmentsRejectsDuplicateKey(t *testing.T) { - t.Parallel() - - if _, err := parseEnvAssignments([]string{"A=1", "A=2"}); err == nil { - t.Fatal("parseEnvAssignments() error = nil, want duplicate key") - } -} diff --git a/cli/command/command.go b/cli/command/command.go index 09f8033c..b827123e 100644 --- a/cli/command/command.go +++ b/cli/command/command.go @@ -8,7 +8,6 @@ import ( "io" "strings" "text/tabwriter" - "time" "csgclaw/internal/apiclient" "csgclaw/internal/apitypes" @@ -151,40 +150,6 @@ func RenderAction(output string, w io.Writer, result ActionResult) error { return tw.Flush() } -func RenderBots(output string, w io.Writer, bots []apitypes.Bot) error { - switch output { - case "", "table": - return RenderBotsTable(w, bots) - case "json": - return WriteJSON(w, bots) - default: - return fmt.Errorf("unsupported output format %q", output) - } -} - -func RenderCompactBotList(output string, w io.Writer, bots []apitypes.Bot) error { - compact := compactBotList(bots) - switch output { - case "", "table": - return RenderCompactBotsTable(w, compact) - case "json": - return WriteJSON(w, compact) - default: - return fmt.Errorf("unsupported output format %q", output) - } -} - -func RenderFullBotList(output string, w io.Writer, bots []apitypes.Bot) error { - switch output { - case "", "table": - return RenderFullBotsTable(w, bots) - case "json": - return WriteJSON(w, bots) - default: - return fmt.Errorf("unsupported output format %q", output) - } -} - func RenderAgents(output string, w io.Writer, agents []apitypes.Agent) error { switch output { case "", "table": @@ -218,6 +183,17 @@ func RenderUsers(output string, w io.Writer, users []apitypes.User) error { } } +func RenderParticipants(output string, w io.Writer, participants []apitypes.Participant) error { + switch output { + case "", "table": + return RenderParticipantsTable(w, participants) + case "json": + return WriteJSON(w, participants) + default: + return fmt.Errorf("unsupported output format %q", output) + } +} + func RenderMessages(output string, w io.Writer, messages []apitypes.Message) error { switch output { case "", "table": @@ -283,76 +259,24 @@ func displayAgentProfile(profile string) string { return displayAgentField(profile) } -func RenderBotsTable(w io.Writer, bots []apitypes.Bot) error { - return RenderCompactBotsTable(w, compactBotList(bots)) -} - -func RenderCompactBotsTable(w io.Writer, bots []compactBot) error { - tw := NewTableWriter(w) - fmt.Fprintln(tw, "ID\tNAME\tDESCRIPTION\tROLE\tCHANNEL") - for _, b := range bots { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", b.ID, b.Name, displayBotDescription(b.Description), b.Role, b.Channel) - } - return tw.Flush() -} - -func RenderFullBotsTable(w io.Writer, bots []apitypes.Bot) error { +func RenderParticipantsTable(w io.Writer, participants []apitypes.Participant) error { tw := NewTableWriter(w) - fmt.Fprintln(tw, "ID\tNAME\tDESCRIPTION\tROLE\tCHANNEL\tAGENT_ID\tUSER_ID\tAVAILABLE\tRUNTIME_KIND\tCREATED_AT") - for _, b := range bots { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%t\t%s\t%s\n", - b.ID, - b.Name, - displayBotDescription(b.Description), - b.Role, - b.Channel, - displayBotField(b.AgentID), - displayBotField(b.UserID), - b.Available, - displayBotField(b.RuntimeKind), - displayBotTime(b.CreatedAt), + fmt.Fprintln(tw, "ID\tNAME\tTYPE\tCHANNEL\tAGENT_ID\tCHANNEL_USER\tAPP_REF\tSTATUS") + for _, p := range participants { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + displayBotField(p.ID), + displayBotField(p.Name), + displayBotField(p.Type), + displayBotField(p.Channel), + displayBotField(p.AgentID), + displayBotField(p.ChannelUserRef), + displayBotField(p.ChannelAppRef), + displayBotField(p.LifecycleStatus), ) } return tw.Flush() } -type compactBot struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Role string `json:"role"` - Channel string `json:"channel"` -} - -func compactBotList(bots []apitypes.Bot) []compactBot { - out := make([]compactBot, 0, len(bots)) - for _, b := range bots { - out = append(out, compactBot{ - ID: b.ID, - Name: b.Name, - Description: b.Description, - Role: b.Role, - Channel: b.Channel, - }) - } - return out -} - -func displayBotDescription(value string) string { - const maxRunes = 40 - - value = strings.TrimSpace(value) - if value == "" { - return "-" - } - - runes := []rune(value) - if len(runes) <= maxRunes { - return value - } - return string(runes[:maxRunes]) + "..." -} - func displayBotField(value string) string { value = strings.TrimSpace(value) if value == "" { @@ -361,13 +285,6 @@ func displayBotField(value string) string { return value } -func displayBotTime(value time.Time) string { - if value.IsZero() { - return "-" - } - return value.UTC().Format(time.RFC3339) -} - func RenderRoomsTable(w io.Writer, rooms []apitypes.Room) error { tw := NewTableWriter(w) fmt.Fprintln(tw, "ID\tTITLE\tDIRECT\tMEMBERS\tMESSAGES") diff --git a/cli/command/command_test.go b/cli/command/command_test.go deleted file mode 100644 index 1f301f35..00000000 --- a/cli/command/command_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package command - -import ( - "bytes" - "strings" - "testing" - "time" - - "csgclaw/internal/apitypes" -) - -func TestRenderCompactBotListJSONOmitsOperationalFields(t *testing.T) { - bots := []apitypes.Bot{{ - ID: "bot-feishu", - Name: "feishu", - Description: "manager bot", - Role: "manager", - Channel: "feishu", - AgentID: "u-manager", - UserID: "ou_manager", - Available: true, - RuntimeKind: "codex", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }} - - var stdout bytes.Buffer - if err := RenderCompactBotList("json", &stdout, bots); err != nil { - t.Fatalf("RenderCompactBotList() error = %v", err) - } - - out := stdout.String() - for _, want := range []string{`"id": "bot-feishu"`, `"description": "manager bot"`, `"role": "manager"`, `"channel": "feishu"`} { - if !strings.Contains(out, want) { - t.Fatalf("compact JSON = %q, want %s", out, want) - } - } - for _, unexpected := range []string{`"agent_id"`, `"user_id"`, `"available"`, `"runtime_kind"`, `"created_at"`} { - if strings.Contains(out, unexpected) { - t.Fatalf("compact JSON = %q, should omit %s", out, unexpected) - } - } -} - -func TestRenderCompactBotListTableUsesCompactColumns(t *testing.T) { - bots := []apitypes.Bot{{ - ID: "bot-feishu", - Name: "feishu", - Role: "manager", - Channel: "feishu", - AgentID: "u-manager", - UserID: "ou_manager", - Available: true, - RuntimeKind: "codex", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }} - - var stdout bytes.Buffer - if err := RenderCompactBotList("table", &stdout, bots); err != nil { - t.Fatalf("RenderCompactBotList() error = %v", err) - } - - out := stdout.String() - if !strings.Contains(out, "ID") || !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "bot-feishu") { - t.Fatalf("compact table = %q, want compact bot columns", out) - } - for _, unexpected := range []string{"AGENT_ID", "USER_ID", "AVAILABLE", "RUNTIME_KIND", "CREATED_AT", "u-manager", "ou_manager", "codex"} { - if strings.Contains(out, unexpected) { - t.Fatalf("compact table = %q, should omit %s", out, unexpected) - } - } -} - -func TestRenderFullBotListTableIncludesRuntime(t *testing.T) { - bots := []apitypes.Bot{{ - ID: "bot-alice", - Name: "alice", - Role: "worker", - Channel: "csgclaw", - AgentID: "u-alice", - UserID: "u-alice", - Available: true, - RuntimeKind: "codex", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }} - - var stdout bytes.Buffer - if err := RenderFullBotList("table", &stdout, bots); err != nil { - t.Fatalf("RenderFullBotList() error = %v", err) - } - - out := stdout.String() - for _, want := range []string{"AGENT_ID", "USER_ID", "AVAILABLE", "RUNTIME_KIND", "CREATED_AT", "bot-alice", "u-alice", "true", "codex", "2026-04-12T09:00:00Z"} { - if !strings.Contains(out, want) { - t.Fatalf("full table = %q, want %s", out, want) - } - } - if strings.Contains(out, "%!s(MISSING)") { - t.Fatalf("full table = %q, contains missing fmt argument", out) - } -} diff --git a/cli/completion/completion.go b/cli/completion/completion.go index 672cab17..0dc2582d 100644 --- a/cli/completion/completion.go +++ b/cli/completion/completion.go @@ -85,8 +85,9 @@ func FullSpec() CommandSpec { hubSpec(), skillSpec(), modelSpec(), + participantSpec("participant"), + participantSpec("pt"), userSpec(), - botSpec(), roomSpec(), memberSpec(), messageSpec(), @@ -101,7 +102,8 @@ func LiteSpec() CommandSpec { Name: "csgclaw-cli", Flags: liteGlobalFlags(), Children: []CommandSpec{ - botSpec(), + participantSpec("participant"), + participantSpec("pt"), hubSpec(), roomSpec(), memberSpec(), @@ -353,31 +355,50 @@ func userSpec() CommandSpec { } } -func botSpec() CommandSpec { +func participantSpec(name string) CommandSpec { return CommandSpec{ - Name: "bot", - Summary: "Manage bots.", + Name: name, + Summary: "Manage channel participants.", Children: []CommandSpec{ { Name: "list", - Summary: "List bots", - Flags: append(channelFlags(), FlagSpec{Name: "role", TakesValue: true, Values: roleValues()}), + Summary: "List participants", + Flags: append(channelFlags(), + FlagSpec{Name: "type", TakesValue: true, Values: []string{"human", "agent", "notification"}}, + FlagSpec{Name: "agent-id", TakesValue: true}, + ), }, { Name: "create", - Summary: "Create a bot", + Summary: "Create a participant", Flags: append(channelFlags(), FlagSpec{Name: "id", TakesValue: true}, FlagSpec{Name: "name", TakesValue: true}, FlagSpec{Name: "description", TakesValue: true}, + FlagSpec{Name: "type", TakesValue: true, Values: []string{"human", "agent", "notification"}}, + FlagSpec{Name: "channel-user-ref", TakesValue: true}, + FlagSpec{Name: "channel-user-kind", TakesValue: true, Values: []string{"local_user_id", "open_id"}}, + FlagSpec{Name: "channel-app-ref", TakesValue: true}, + FlagSpec{Name: "bind", TakesValue: true, Values: []string{"create", "reuse", "none"}}, + FlagSpec{Name: "agent-id", TakesValue: true}, FlagSpec{Name: "role", TakesValue: true, Values: roleValues()}, + FlagSpec{Name: "runtime", TakesValue: true}, + FlagSpec{Name: "image", TakesValue: true}, + FlagSpec{Name: "from-template", TakesValue: true}, FlagSpec{Name: "model-id", TakesValue: true}, + FlagSpec{Name: "env", TakesValue: true}, + ), + }, + { + Name: "delete", + Summary: "Delete a participant", + Flags: append(channelFlags(), + FlagSpec{Name: "delete-agent", TakesValue: true, Values: []string{"if_unreferenced"}}, ), }, - {Name: "delete", Summary: "Delete a bot", Flags: channelFlags()}, { Name: "config", - Summary: "Manage bot channel config", + Summary: "Manage participant channel config", Flags: append(feishuChannelFlags(), FlagSpec{Name: "get"}, FlagSpec{Name: "set"}, diff --git a/cli/completion/completion_test.go b/cli/completion/completion_test.go index 468ff2d6..cd603d19 100644 --- a/cli/completion/completion_test.go +++ b/cli/completion/completion_test.go @@ -9,15 +9,15 @@ import ( func TestCompleteFullTopLevel(t *testing.T) { got := Complete(FullSpec(), "csgclaw", []string{"csgclaw", ""}) - assertContainsAll(t, got, "serve", "upgrade", "agent", "hub", "skill", "model", "bot", "completion", "--endpoint", "--config", "-V") - assertContainsNone(t, got, "_serve", "__complete") + assertContainsAll(t, got, "serve", "upgrade", "agent", "hub", "skill", "model", "participant", "pt", "completion", "--endpoint", "--config", "-V") + assertContainsNone(t, got, "bot", "channel", "_serve", "__complete") } func TestCompleteLiteTopLevel(t *testing.T) { got := Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", ""}) - assertContainsAll(t, got, "bot", "room", "member", "message", "completion", "--endpoint", "-V") - assertContainsNone(t, got, "serve", "agent", "model", "user", "_serve", "__complete") + assertContainsAll(t, got, "participant", "pt", "room", "member", "message", "completion", "--endpoint", "-V") + assertContainsNone(t, got, "bot", "channel", "serve", "agent", "model", "user", "_serve", "__complete") } func TestCompleteSubcommandsAndFlags(t *testing.T) { @@ -39,24 +39,27 @@ func TestCompleteSubcommandsAndFlags(t *testing.T) { got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "team", "task", ""}) assertContainsAll(t, got, "list", "create-batch", "assign", "claim", "claim-next", "update", "--help") - got = Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", "bot", ""}) + got = Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", "participant", ""}) assertContainsAll(t, got, "list", "create", "delete", "config") - got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", ""}) + got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "pt", ""}) assertContainsAll(t, got, "list", "create", "delete", "config") - got = Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", "bot", "config", "--"}) - assertContainsAll(t, got, "--channel", "--get", "--set", "--reload", "--bot-id", "--app-secret-stdin") + got = Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", "participant", "create", "--"}) + assertContainsAll(t, got, "--channel", "--name", "--type", "--bind", "--agent-id") + + got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", ""}) + assertContainsNone(t, got, "list", "create", "delete", "config") - got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", "config", "--"}) - assertContainsAll(t, got, "--channel", "--get", "--set", "--reload", "--bot-id", "--app-secret-stdin") + got = Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", "pt", "config", "--"}) + assertContainsAll(t, got, "--channel", "--get", "--set", "--reload", "--bot-id", "--app-secret-env") } func TestCompleteFlagValues(t *testing.T) { - got := Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", "list", "--channel", ""}) + got := Complete(FullSpec(), "csgclaw", []string{"csgclaw", "participant", "list", "--channel", ""}) assertEqual(t, got, []string{"csgclaw", "feishu"}) - got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", "list", "--channel=f"}) + got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "participant", "list", "--channel=f"}) assertEqual(t, got, []string{"--channel=feishu"}) got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "model", "auth", "login", "c"}) diff --git a/cli/csgclawcli/app.go b/cli/csgclawcli/app.go index 31c7b80c..77405ad7 100644 --- a/cli/csgclawcli/app.go +++ b/cli/csgclawcli/app.go @@ -9,12 +9,12 @@ import ( "os" "strings" - "csgclaw/cli/bot" "csgclaw/cli/command" completioncmd "csgclaw/cli/completion" hubcmd "csgclaw/cli/hub" "csgclaw/cli/member" "csgclaw/cli/message" + participantcmd "csgclaw/cli/participant" "csgclaw/cli/room" skillcmd "csgclaw/cli/skill" teamcmd "csgclaw/cli/team" @@ -68,7 +68,8 @@ func (a *App) AddCommand(commands ...command.Command) { func (a *App) registerDefaultCommands() { a.AddCommand( - bot.NewCmd(), + participantcmd.NewCmd(), + participantcmd.NewAliasCmd("pt"), hubcmd.NewCmd(), room.NewCmd(), member.NewCmd(), @@ -172,21 +173,23 @@ func consumesValue(arg string) bool { func (a *App) usage() { a.ensureDefaultCommands() - fmt.Fprintln(a.stderr, "csgclaw-cli is a lite CSGClaw CLI for bots, rooms, messages, and teams.") + fmt.Fprintln(a.stderr, "csgclaw-cli is a lite CSGClaw CLI for participants, rooms, messages, and teams.") fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Usage:") fmt.Fprintln(a.stderr, " csgclaw-cli [global-flags] [args]") fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Available Commands:") - for _, cmd := range a.order { - fmt.Fprintf(a.stderr, " %-8s %s\n", cmd.Name(), cmd.Summary()) + commands := a.visibleCommands() + width := commandNameWidth(commands) + for _, cmd := range commands { + fmt.Fprintf(a.stderr, " %-*s %s\n", width, cmd.Name(), cmd.Summary()) } fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Examples:") fmt.Fprintln(a.stderr, " csgclaw-cli -h") fmt.Fprintln(a.stderr, " csgclaw-cli --version") - fmt.Fprintln(a.stderr, " csgclaw-cli bot list --channel feishu") - fmt.Fprintln(a.stderr, " csgclaw-cli bot config --channel feishu --get --bot-id u-dev") + fmt.Fprintln(a.stderr, " csgclaw-cli participant list --channel feishu") + fmt.Fprintln(a.stderr, " csgclaw-cli pt create --channel feishu --name dev --type agent --bind reuse --agent-id u-dev") fmt.Fprintln(a.stderr, " csgclaw-cli message create --channel feishu --room-id oc_x --sender-id u-manager --content hello") fmt.Fprintln(a.stderr, " csgclaw-cli team create --lead-bot-id bot-manager --title release") fmt.Fprintln(a.stderr) @@ -197,6 +200,27 @@ func (a *App) usage() { fmt.Fprintln(a.stderr, " --version, -V Print version and exit") } +func (a *App) visibleCommands() []command.Command { + commands := make([]command.Command, 0, len(a.order)) + for _, cmd := range a.order { + if hidden, ok := cmd.(interface{ Hidden() bool }); ok && hidden.Hidden() { + continue + } + commands = append(commands, cmd) + } + return commands +} + +func commandNameWidth(commands []command.Command) int { + width := 8 + for _, cmd := range commands { + if n := len(cmd.Name()); n > width { + width = n + } + } + return width + 1 +} + func (a *App) printVersion(output string) error { version := appversion.Current() if output == "json" { diff --git a/cli/csgclawcli/app_test.go b/cli/csgclawcli/app_test.go index ae4a1a49..f58c9deb 100644 --- a/cli/csgclawcli/app_test.go +++ b/cli/csgclawcli/app_test.go @@ -30,20 +30,21 @@ func TestExecuteExposesOnlyLiteCommands(t *testing.T) { got := stderr.String() for _, want := range []string{ "Available Commands:", - "bot Manage bots", - "hub Discover agent templates.", - "room Manage IM rooms", - "member Manage IM room members", - "message Manage IM messages.", - "team Manage agent teams.", - "skill Discover and install ClawHub skills.", - "completion Generate shell completion scripts.", + "participant Manage channel participants.", + "pt Manage channel participants.", + "hub Discover agent templates.", + "room Manage IM rooms", + "member Manage IM room members", + "message Manage IM messages.", + "team Manage agent teams.", + "skill Discover and install ClawHub skills.", + "completion Generate shell completion scripts.", } { if !strings.Contains(got, want) { t.Fatalf("usage = %q, want substring %q", got, want) } } - for _, notWant := range []string{" agent", " serve", " onboard", " user"} { + for _, notWant := range []string{" agent", " serve", " onboard", " user", "\n bot ", "\n channel "} { if strings.Contains(got, notWant) { t.Fatalf("usage = %q, should not include %q", got, notWant) } @@ -104,20 +105,70 @@ func TestExecuteHiddenCompleteUsesLiteCommandSet(t *testing.T) { t.Fatalf("Execute() error = %v", err) } got := stdout.String() - for _, want := range []string{"bot\n", "hub\n", "room\n", "member\n", "message\n", "team\n", "completion\n"} { + for _, want := range []string{"participant\n", "pt\n", "hub\n", "room\n", "member\n", "message\n", "team\n", "completion\n"} { if !strings.Contains(got, want) { t.Fatalf("stdout = %q, want substring %q", got, want) } } - for _, notWant := range []string{"agent\n", "serve\n", "onboard\n", "user\n", "__complete\n"} { + for _, notWant := range []string{"agent\n", "serve\n", "onboard\n", "user\n", "bot\n", "channel\n", "__complete\n"} { if strings.Contains(got, notWant) { t.Fatalf("stdout = %q, should not include %q", got, notWant) } } } -func TestExecuteRejectsFullCsgclawCommands(t *testing.T) { - for _, command := range []string{"agent", "serve", "onboard", "user"} { +func TestExecuteParticipantAliasConfigSetUsesHTTPClient(t *testing.T) { + t.Setenv("FEISHU_APP_SECRET", "secret-value") + var stdout bytes.Buffer + app := &App{ + stdout: &stdout, + stderr: &bytes.Buffer{}, + httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPut { + t.Fatalf("method = %q, want %q", req.Method, http.MethodPut) + } + if req.URL.String() != "http://example.test/api/v1/channels/feishu/config" { + t.Fatalf("url = %q, want Feishu config route", req.URL.String()) + } + var payload map[string]any + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if payload["bot_id"] != "u-manager" || payload["app_id"] != "cli_xxx" || payload["app_secret"] != "secret-value" || payload["admin_open_id"] != "ou_xxx" { + t.Fatalf("payload = %#v, want Feishu config fields", payload) + } + if payload["reload"] != false { + t.Fatalf("payload reload = %#v, want false", payload["reload"]) + } + return jsonResponse(http.StatusOK, `{"bot_id":"u-manager","configured":true,"app_id":"cli_xxx","app_secret":"present","admin_open_id":"ou_xxx","reloaded":false}`), nil + }), + } + + err := app.Execute(context.Background(), []string{ + "--endpoint", "http://example.test", + "--output", "json", + "pt", "config", + "--channel", "feishu", + "--set", + "--bot-id", "u-manager", + "--app-id", "cli_xxx", + "--admin-open-id", "ou_xxx", + "--app-secret-env", "FEISHU_APP_SECRET", + "--no-reload", + }) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if strings.Contains(stdout.String(), "secret-value") { + t.Fatalf("stdout leaked app secret: %q", stdout.String()) + } + if !strings.Contains(stdout.String(), `"app_secret": "present"`) { + t.Fatalf("stdout = %q, want masked secret status", stdout.String()) + } +} + +func TestExecuteRejectsUnavailableCommands(t *testing.T) { + for _, command := range []string{"agent", "serve", "onboard", "user", "bot", "channel"} { t.Run(command, func(t *testing.T) { app := &App{ stdout: &bytes.Buffer{}, @@ -136,7 +187,7 @@ func TestExecuteRejectsFullCsgclawCommands(t *testing.T) { } } -func TestExecuteBotIdentityHelpUsesBotIDSemantics(t *testing.T) { +func TestExecuteCollaborationIdentityHelpUsesParticipantSemantics(t *testing.T) { tests := []struct { name string args []string @@ -145,17 +196,17 @@ func TestExecuteBotIdentityHelpUsesBotIDSemantics(t *testing.T) { { name: "room create", args: []string{"room", "create", "--help"}, - want: []string{"creator bot id", "comma-separated member bot ids"}, + want: []string{"creator participant id", "comma-separated member participant ids"}, }, { name: "member create", args: []string{"member", "create", "--help"}, - want: []string{"bot id to add", "inviter bot id"}, + want: []string{"participant id to add", "inviter participant id"}, }, { name: "message create", args: []string{"message", "create", "--help"}, - want: []string{"sender bot id", "mentioned bot id"}, + want: []string{"sender participant id", "mentioned participant id"}, }, { name: "team create", @@ -237,7 +288,7 @@ func TestExecuteTeamTaskListUsesHTTPClient(t *testing.T) { } } -func TestExecuteBotIdentityRequiredErrorsUseBotIDSemantics(t *testing.T) { +func TestExecuteCollaborationIdentityRequiredErrorsUseParticipantSemantics(t *testing.T) { tests := []struct { name string args []string @@ -246,12 +297,12 @@ func TestExecuteBotIdentityRequiredErrorsUseBotIDSemantics(t *testing.T) { { name: "member create missing user id", args: []string{"member", "create", "--room-id", "room-1", "--inviter-id", "u-manager"}, - want: "--user-id bot id is required", + want: "--user-id participant id is required", }, { name: "message create missing sender id", args: []string{"message", "create", "--room-id", "room-1", "--content", "hello"}, - want: "--sender-id bot id is required", + want: "--sender-id participant id is required", }, } @@ -274,7 +325,7 @@ func TestExecuteBotIdentityRequiredErrorsUseBotIDSemantics(t *testing.T) { } } -func TestExecuteBotListUsesAPIClient(t *testing.T) { +func TestExecuteParticipantListUsesAPIClient(t *testing.T) { var stdout bytes.Buffer app := &App{ stdout: &stdout, @@ -283,24 +334,19 @@ func TestExecuteBotListUsesAPIClient(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want feishu bot list route", req.URL.String()) + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want feishu participant list route", req.URL.String()) } - return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","role":"manager","channel":"feishu","agent_id":"u-manager","user_id":"fsu-manager","created_at":"2026-04-12T09:00:00Z"}]`), nil + return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","type":"agent","channel":"feishu","agent_id":"u-manager","channel_user_ref":"fsu-manager","lifecycle_status":"active","created_at":"2026-04-12T09:00:00Z"}]`), nil }), } - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "list", "--channel", "feishu"}) + err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "participant", "list", "--channel", "feishu"}) if err != nil { t.Fatalf("Execute() error = %v", err) } if !strings.Contains(stdout.String(), `"id": "bot-feishu"`) || !strings.Contains(stdout.String(), `"channel": "feishu"`) { - t.Fatalf("stdout = %q, want JSON bot payload", stdout.String()) - } - for _, unexpected := range []string{`"agent_id"`, `"user_id"`, `"created_at"`} { - if strings.Contains(stdout.String(), unexpected) { - t.Fatalf("stdout = %q, want compact csgclaw-cli bot list without %s", stdout.String(), unexpected) - } + t.Fatalf("stdout = %q, want JSON participant payload", stdout.String()) } } @@ -318,14 +364,14 @@ func TestExecuteDefaultsToJSONOutputForNonTerminalStdout(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want feishu bot list route", req.URL.String()) + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want feishu participant list route", req.URL.String()) } - return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","role":"manager","channel":"feishu","agent_id":"u-manager","user_id":"fsu-manager","created_at":"2026-04-12T09:00:00Z"}]`), nil + return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","type":"agent","channel":"feishu","agent_id":"u-manager","channel_user_ref":"fsu-manager","lifecycle_status":"active","created_at":"2026-04-12T09:00:00Z"}]`), nil }), } - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list", "--channel", "feishu"}); err != nil { + if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "participant", "list", "--channel", "feishu"}); err != nil { t.Fatalf("Execute() error = %v", err) } got, err := os.ReadFile(stdout.Name()) @@ -333,16 +379,11 @@ func TestExecuteDefaultsToJSONOutputForNonTerminalStdout(t *testing.T) { t.Fatalf("ReadFile(stdout) error = %v", err) } if !strings.Contains(string(got), `"id": "bot-feishu"`) || !strings.Contains(string(got), `"channel": "feishu"`) { - t.Fatalf("stdout = %q, want JSON bot payload", string(got)) - } - for _, unexpected := range []string{`"agent_id"`, `"user_id"`, `"created_at"`} { - if strings.Contains(string(got), unexpected) { - t.Fatalf("stdout = %q, want compact csgclaw-cli bot list without %s", string(got), unexpected) - } + t.Fatalf("stdout = %q, want JSON participant payload", string(got)) } } -func TestExecuteBotListUsesRoleQuery(t *testing.T) { +func TestExecuteParticipantListUsesTypeQuery(t *testing.T) { var stdout bytes.Buffer app := &App{ stdout: &stdout, @@ -351,24 +392,19 @@ func TestExecuteBotListUsesRoleQuery(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots?role=manager" { - t.Fatalf("url = %q, want role-filtered bot list route", req.URL.String()) + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants?type=agent" { + t.Fatalf("url = %q, want type-filtered participant list route", req.URL.String()) } - return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","role":"manager","channel":"feishu","agent_id":"u-manager","user_id":"fsu-manager","created_at":"2026-04-12T09:00:00Z"}]`), nil + return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","type":"agent","channel":"feishu","agent_id":"u-manager","channel_user_ref":"fsu-manager","lifecycle_status":"active","created_at":"2026-04-12T09:00:00Z"}]`), nil }), } - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "list", "--channel", "feishu", "--role", "manager"}) + err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "participant", "list", "--channel", "feishu", "--type", "agent"}) if err != nil { t.Fatalf("Execute() error = %v", err) } - if !strings.Contains(stdout.String(), `"id": "bot-feishu"`) || !strings.Contains(stdout.String(), `"role": "manager"`) { - t.Fatalf("stdout = %q, want JSON bot payload", stdout.String()) - } - for _, unexpected := range []string{`"agent_id"`, `"user_id"`, `"created_at"`} { - if strings.Contains(stdout.String(), unexpected) { - t.Fatalf("stdout = %q, want compact csgclaw-cli bot list without %s", stdout.String(), unexpected) - } + if !strings.Contains(stdout.String(), `"id": "bot-feishu"`) || !strings.Contains(stdout.String(), `"type": "agent"`) { + t.Fatalf("stdout = %q, want JSON participant payload", stdout.String()) } } @@ -380,8 +416,8 @@ func TestExecuteUsesEnvironmentForEndpointAndToken(t *testing.T) { stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}, httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "http://env.example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want %q", req.URL.String(), "http://env.example.test/api/v1/channels/feishu/bots") + if req.URL.String() != "http://env.example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want %q", req.URL.String(), "http://env.example.test/api/v1/channels/feishu/participants") } if got := req.Header.Get("Authorization"); got != "Bearer env-secret-token" { t.Fatalf("Authorization = %q, want %q", got, "Bearer env-secret-token") @@ -390,7 +426,7 @@ func TestExecuteUsesEnvironmentForEndpointAndToken(t *testing.T) { }), } - if err := app.Execute(context.Background(), []string{"bot", "list", "--channel", "feishu"}); err != nil { + if err := app.Execute(context.Background(), []string{"participant", "list", "--channel", "feishu"}); err != nil { t.Fatalf("Execute() error = %v", err) } } @@ -403,8 +439,8 @@ func TestExecuteFlagsOverrideEnvironmentForEndpointAndToken(t *testing.T) { stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}, httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "http://flag.example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want %q", req.URL.String(), "http://flag.example.test/api/v1/channels/feishu/bots") + if req.URL.String() != "http://flag.example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want %q", req.URL.String(), "http://flag.example.test/api/v1/channels/feishu/participants") } if got := req.Header.Get("Authorization"); got != "Bearer flag-secret-token" { t.Fatalf("Authorization = %q, want %q", got, "Bearer flag-secret-token") @@ -416,13 +452,13 @@ func TestExecuteFlagsOverrideEnvironmentForEndpointAndToken(t *testing.T) { if err := app.Execute(context.Background(), []string{ "--endpoint", "http://flag.example.test", "--token", "flag-secret-token", - "bot", "list", "--channel", "feishu", + "participant", "list", "--channel", "feishu", }); err != nil { t.Fatalf("Execute() error = %v", err) } } -func TestExecuteBotDeleteUsesAPIClient(t *testing.T) { +func TestExecuteParticipantDeleteUsesAPIClient(t *testing.T) { app := &App{ stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}, @@ -430,20 +466,20 @@ func TestExecuteBotDeleteUsesAPIClient(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("method = %q, want %q", req.Method, http.MethodDelete) } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots/u-alice" { - t.Fatalf("url = %q, want feishu bot delete route", req.URL.String()) + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants/u-alice" { + t.Fatalf("url = %q, want feishu participant delete route", req.URL.String()) } return jsonResponse(http.StatusNoContent, ``), nil }), } - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "delete", "--channel", "feishu", "u-alice"}) + err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "participant", "delete", "--channel", "feishu", "u-alice"}) if err != nil { t.Fatalf("Execute() error = %v", err) } } -func TestExecuteBotDeleteSupportsJSONOutput(t *testing.T) { +func TestExecuteParticipantDeleteSupportsJSONOutput(t *testing.T) { var stdout bytes.Buffer app := &App{ stdout: &stdout, @@ -456,113 +492,17 @@ func TestExecuteBotDeleteSupportsJSONOutput(t *testing.T) { }), } - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "delete", "--channel", "feishu", "u-alice"}) + err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "participant", "delete", "--channel", "feishu", "u-alice"}) if err != nil { t.Fatalf("Execute() error = %v", err) } - for _, want := range []string{`"command": "bot"`, `"action": "delete"`, `"status": "deleted"`, `"id": "u-alice"`, `"channel": "feishu"`} { + for _, want := range []string{`"command": "participant"`, `"action": "delete"`, `"status": "deleted"`, `"id": "u-alice"`, `"channel": "feishu"`} { if !strings.Contains(stdout.String(), want) { t.Fatalf("stdout = %q, want %s", stdout.String(), want) } } } -func TestExecuteBotConfigSetUsesFeishuConfigRoute(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdin: strings.NewReader("stdin-secret\n"), - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPut { - t.Fatalf("method = %q, want %q", req.Method, http.MethodPut) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/config" { - t.Fatalf("url = %q, want feishu config route", req.URL.String()) - } - if got := req.Header.Get("Authorization"); got != "Bearer token" { - t.Fatalf("Authorization = %q, want bearer token", got) - } - var payload map[string]any - if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { - t.Fatalf("decode request: %v", err) - } - for key, want := range map[string]string{ - "bot_id": "u-dev", - "app_id": "cli_dev", - "app_secret": "stdin-secret", - "admin_open_id": "ou_admin", - } { - if got := payload[key]; got != want { - t.Fatalf("payload[%s] = %#v, want %q; payload=%#v", key, got, want, payload) - } - } - return jsonResponse(http.StatusOK, `{"bot_id":"u-dev","configured":true,"app_id":"cli_dev","app_secret":"present","admin_open_id":"ou_admin","reloaded":true}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--token", "token", "--output", "json", "bot", "config", "--channel", "feishu", "--set", "--bot-id", "u-dev", "--app-id", "cli_dev", "--admin-open-id", "ou_admin", "--app-secret-stdin"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if strings.Contains(stdout.String(), "stdin-secret") { - t.Fatalf("stdout leaked secret: %s", stdout.String()) - } - if !strings.Contains(stdout.String(), `"app_secret": "present"`) { - t.Fatalf("stdout = %q, want masked secret", stdout.String()) - } -} - -func TestExecuteBotConfigGetUsesFeishuConfigRoute(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/config?bot_id=u-dev" { - t.Fatalf("url = %q, want feishu config get route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `{"bot_id":"u-dev","configured":true,"app_id":"cli_dev","app_secret":"present"}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "config", "--channel", "feishu", "--get", "--bot-id", "u-dev"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), "u-dev") || !strings.Contains(stdout.String(), "present") { - t.Fatalf("stdout = %s, want bot and masked secret", stdout.String()) - } -} - -func TestExecuteBotConfigReloadUsesFeishuConfigRoute(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPost { - t.Fatalf("method = %q, want %q", req.Method, http.MethodPost) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/config" { - t.Fatalf("url = %q, want feishu config reload route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `{"status":"reloaded","feishu_bots":["u-dev"]}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "config", "--channel", "feishu", "--reload"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), "reloaded") || !strings.Contains(stdout.String(), "u-dev") { - t.Fatalf("stdout = %s, want reload result", stdout.String()) - } -} - func TestExecuteRoomCreateUsesChannelRoute(t *testing.T) { var stdout bytes.Buffer app := &App{ diff --git a/cli/http_client.go b/cli/http_client.go index 33ca8728..441c470d 100644 --- a/cli/http_client.go +++ b/cli/http_client.go @@ -115,10 +115,6 @@ func renderAgentsTable(w io.Writer, agents []apitypes.Agent) error { return tw.Flush() } -func renderBotsTable(w io.Writer, bots []apitypes.Bot) error { - return command.RenderBotsTable(w, bots) -} - func displayAgentField(value string) string { value = strings.TrimSpace(value) if value == "" { diff --git a/cli/member/member.go b/cli/member/member.go index d66f191b..5cb581f4 100644 --- a/cli/member/member.go +++ b/cli/member/member.go @@ -73,8 +73,8 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, fs := run.NewFlagSet("member create", run.Program+" member create [flags]", "Add a member to a room.") channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") roomID := fs.String("room-id", "", "target room id") - userID := fs.String("user-id", "", "bot id to add") - inviterID := fs.String("inviter-id", "", "inviter bot id") + userID := fs.String("user-id", "", "participant id to add") + inviterID := fs.String("inviter-id", "", "inviter participant id") locale := fs.String("locale", "", "room locale") if err := fs.Parse(args); err != nil { return err @@ -83,7 +83,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, return fmt.Errorf("member create does not accept positional arguments") } if *userID == "" { - return fmt.Errorf("--user-id bot id is required") + return fmt.Errorf("--user-id participant id is required") } room, err := run.APIClient(globals).AddRoomMemberByChannel(ctx, *channelName, apitypes.AddRoomMembersRequest{ diff --git a/cli/message/message.go b/cli/message/message.go index 981bd673..3767085b 100644 --- a/cli/message/message.go +++ b/cli/message/message.go @@ -76,9 +76,9 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, fs := run.NewFlagSet("message create", run.Program+" message create [flags]", "Create a message.") channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") roomID := fs.String("room-id", "", "target room id") - senderID := fs.String("sender-id", "", "sender bot id") + senderID := fs.String("sender-id", "", "sender participant id") content := fs.String("content", "", "message content") - mentionID := fs.String("mention-id", "", "mentioned bot id") + mentionID := fs.String("mention-id", "", "mentioned participant id") if err := fs.Parse(args); err != nil { return err } @@ -89,7 +89,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, return fmt.Errorf("room_id is required") } if *senderID == "" { - return fmt.Errorf("--sender-id bot id is required") + return fmt.Errorf("--sender-id participant id is required") } if *content == "" { return fmt.Errorf("content is required") diff --git a/cli/bot/config.go b/cli/participant/config.go similarity index 89% rename from cli/bot/config.go rename to cli/participant/config.go index 52c3e5e3..813cebe5 100644 --- a/cli/bot/config.go +++ b/cli/participant/config.go @@ -1,4 +1,4 @@ -package bot +package participant import ( "context" @@ -18,15 +18,15 @@ const feishuConfigAPIPath = "/api/v1/channels/feishu/config" func (c cmd) runConfig(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { fs := run.NewFlagSet( - "bot config", - run.Program+" bot config --channel feishu (--get|--set|--reload) [flags]", - "Manage bot channel configuration.", + c.Name()+" config", + run.Program+" "+c.Name()+" config --channel feishu (--get|--set|--reload) [flags]", + "Manage participant channel configuration.", ) - channelName := fs.String("channel", "feishu", "channel name; only feishu supports bot config") + channelName := fs.String("channel", "feishu", "channel name; only feishu supports participant config") get := fs.Bool("get", false, "get masked channel config") set := fs.Bool("set", false, "set channel config") reload := fs.Bool("reload", false, "reload channel config") - botID := fs.String("bot-id", "", "bot id") + botID := fs.String("bot-id", "", "Feishu config key") appID := fs.String("app-id", "", "Feishu app id") adminOpenID := fs.String("admin-open-id", "", "Feishu admin open_id") secretFile := fs.String("app-secret-file", "", "read Feishu app secret from file") @@ -37,10 +37,10 @@ func (c cmd) runConfig(ctx context.Context, run *command.Context, args []string, return err } if len(fs.Args()) != 0 { - return fmt.Errorf("bot config does not accept positional arguments") + return fmt.Errorf("%s config does not accept positional arguments", c.Name()) } if normalizeChannel(*channelName) != "feishu" { - return fmt.Errorf("bot config currently supports only --channel feishu") + return fmt.Errorf("%s config currently supports only --channel feishu", c.Name()) } actions := 0 @@ -66,7 +66,7 @@ func (c cmd) runConfig(ctx context.Context, run *command.Context, args []string, } func (c cmd) runConfigGet(ctx context.Context, run *command.Context, globals command.GlobalOptions, botID string) error { - id, err := requireBotID(botID) + id, err := requireConfigKey(botID) if err != nil { return err } @@ -79,12 +79,12 @@ func (c cmd) runConfigGet(ctx context.Context, run *command.Context, globals com } func (c cmd) runConfigSet(ctx context.Context, run *command.Context, globals command.GlobalOptions, botID, appID, adminOpenID, secretFile, secretEnv string, secretStdin bool, noReload bool) error { - id, err := requireBotID(botID) + id, err := requireConfigKey(botID) if err != nil { return err } if strings.TrimSpace(appID) == "" { - return fmt.Errorf("bot config --set requires --app-id") + return fmt.Errorf("%s config --set requires --app-id", c.Name()) } secret, err := readSecret(run.Stdin, secretFile, secretEnv, secretStdin) if err != nil { @@ -113,7 +113,7 @@ func (c cmd) runConfigReload(ctx context.Context, run *command.Context, globals return renderConfigReload(globals.Output, run.Stdout, resp) } -func requireBotID(botID string) (string, error) { +func requireConfigKey(botID string) (string, error) { botID = strings.TrimSpace(botID) if botID == "" { return "", fmt.Errorf("--bot-id is required") diff --git a/cli/participant/participant.go b/cli/participant/participant.go new file mode 100644 index 00000000..536fc107 --- /dev/null +++ b/cli/participant/participant.go @@ -0,0 +1,226 @@ +package participant + +import ( + "context" + "flag" + "fmt" + "strings" + + "csgclaw/cli/command" + "csgclaw/internal/agent" + participantpkg "csgclaw/internal/participant" +) + +type cmd struct { + name string +} + +func NewCmd() command.Command { + return cmd{name: "participant"} +} + +func NewAliasCmd(name string) command.Command { + name = strings.TrimSpace(name) + if name == "" { + name = "pt" + } + return cmd{name: name} +} + +func (c cmd) Name() string { + return c.name +} + +func (cmd) Summary() string { + return "Manage channel participants." +} + +func (c cmd) Run(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { + if len(args) == 0 { + c.usage(run) + return flag.ErrHelp + } + if command.IsHelpArg(args[0]) { + c.usage(run) + return flag.ErrHelp + } + + switch args[0] { + case "list": + return c.runList(ctx, run, args[1:], globals) + case "create": + return c.runCreate(ctx, run, args[1:], globals) + case "delete": + return c.runDelete(ctx, run, args[1:], globals) + case "config": + return c.runConfig(ctx, run, args[1:], globals) + default: + c.usage(run) + return fmt.Errorf("unknown %s subcommand %q", c.Name(), args[0]) + } +} + +func (c cmd) usage(run *command.Context) { + subcommands := []string{ + "list List participants", + "create Create a participant", + "delete Delete a participant", + "config Manage participant channel config", + } + run.UsageCommandGroup(c, run.Program+" "+c.Name()+" [flags]", subcommands) +} + +func (c cmd) runList(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { + fs := run.NewFlagSet(c.Name()+" list", run.Program+" "+c.Name()+" list [flags]", "List participants.") + channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") + participantType := fs.String("type", "", "participant type: human, agent, or notification") + agentID := fs.String("agent-id", "", "filter by bound agent id") + if err := fs.Parse(args); err != nil { + return err + } + if len(fs.Args()) != 0 { + return fmt.Errorf("%s list does not accept positional arguments", c.Name()) + } + + items, err := run.APIClient(globals).ListParticipants(ctx, *channelName, *participantType, *agentID) + if err != nil { + return err + } + return command.RenderParticipants(globals.Output, run.Stdout, items) +} + +func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { + fs := run.NewFlagSet(c.Name()+" create", run.Program+" "+c.Name()+" create [flags]", "Create a participant.") + channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") + id := fs.String("id", "", "participant id") + name := fs.String("name", "", "participant display name") + description := fs.String("description", "", "agent description for bind create and participant metadata") + participantType := fs.String("type", participantpkg.TypeAgent, "participant type: human, agent, or notification") + channelUserRef := fs.String("channel-user-ref", "", "channel user identity such as local user id or Feishu open_id") + channelUserKind := fs.String("channel-user-kind", "", "channel user identity kind such as local_user_id or open_id") + channelAppRef := fs.String("channel-app-ref", "", "channel app/config reference such as Feishu app_id") + bindMode := fs.String("bind", participantpkg.BindingModeNone, "agent binding mode: create, reuse, or none") + agentID := fs.String("agent-id", "", "agent id for bind reuse, or optional id for bind create") + role := fs.String("role", "", "agent role for bind create") + runtimeKind := fs.String("runtime", "", "agent runtime kind for bind create") + image := fs.String("image", "", "agent image for bind create") + fromTemplate := fs.String("from-template", "", "hub template for bind create") + modelID := fs.String("model-id", "", "agent model id for bind create") + var envValues envFlag + fs.Var(&envValues, "env", "agent image environment variable as KEY=VALUE (repeatable)") + if err := fs.Parse(args); err != nil { + return err + } + if len(fs.Args()) != 0 { + return fmt.Errorf("%s create does not accept positional arguments", c.Name()) + } + if strings.TrimSpace(*name) == "" { + return fmt.Errorf("%s create requires --name", c.Name()) + } + envMap, err := parseEnvAssignments(envValues) + if err != nil { + return err + } + + req := participantpkg.CreateRequest{ + ID: *id, + Channel: *channelName, + Type: *participantType, + Name: *name, + ChannelAppRef: *channelAppRef, + ChannelUser: participantpkg.ChannelUserSpec{ + Ref: *channelUserRef, + Kind: *channelUserKind, + }, + AgentBinding: participantpkg.AgentBindingSpec{ + Mode: *bindMode, + AgentID: *agentID, + }, + } + if strings.TrimSpace(*description) != "" { + req.Metadata = map[string]any{"description": strings.TrimSpace(*description)} + } + if strings.EqualFold(strings.TrimSpace(*bindMode), participantpkg.BindingModeCreate) { + spec := agent.CreateAgentSpec{ + ID: *agentID, + Name: *name, + Description: *description, + Role: *role, + RuntimeKind: *runtimeKind, + Image: *image, + FromTemplate: *fromTemplate, + } + if strings.TrimSpace(*modelID) != "" { + spec.AgentProfile.ModelID = *modelID + } + if len(envMap) > 0 { + spec.AgentProfile.Env = envMap + } + req.AgentBinding.Agent = &spec + } + + created, err := run.APIClient(globals).CreateParticipant(ctx, req) + if err != nil { + return err + } + return command.RenderParticipants(globals.Output, run.Stdout, []participantpkg.Participant{created}) +} + +type envFlag []string + +func (e *envFlag) String() string { + return strings.Join(*e, ",") +} + +func (e *envFlag) Set(value string) error { + *e = append(*e, value) + return nil +} + +func parseEnvAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + out := make(map[string]string, len(values)) + for _, raw := range values { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + key, value, ok := strings.Cut(raw, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, fmt.Errorf("invalid --env %q: expected KEY=VALUE", raw) + } + if _, exists := out[key]; exists { + return nil, fmt.Errorf("duplicate --env key %q", key) + } + out[key] = value + } + return out, nil +} + +func (c cmd) runDelete(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { + fs := run.NewFlagSet(c.Name()+" delete", run.Program+" "+c.Name()+" delete [flags]", "Delete a participant.") + channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") + deleteAgent := fs.String("delete-agent", "", "agent cleanup mode; supported: if_unreferenced") + if err := fs.Parse(args); err != nil { + return err + } + + rest := fs.Args() + if len(rest) != 1 { + return fmt.Errorf("%s delete requires exactly one id", c.Name()) + } + if err := run.APIClient(globals).DeleteParticipant(ctx, *channelName, rest[0], *deleteAgent); err != nil { + return err + } + return command.RenderAction(globals.Output, run.Stdout, command.ActionResult{ + Command: c.Name(), + Action: "delete", + Status: "deleted", + ID: rest[0], + Channel: *channelName, + Message: fmt.Sprintf("deleted %s participant %s", *channelName, rest[0]), + }) +} diff --git a/cli/room/room.go b/cli/room/room.go index 77aca5ee..5f5454a6 100644 --- a/cli/room/room.go +++ b/cli/room/room.go @@ -76,8 +76,8 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") title := fs.String("title", "", "room title") description := fs.String("description", "", "room description") - creatorID := fs.String("creator-id", "", "creator bot id") - memberIDs := fs.String("member-ids", "", "comma-separated member bot ids") + creatorID := fs.String("creator-id", "", "creator participant id") + memberIDs := fs.String("member-ids", "", "comma-separated member participant ids") locale := fs.String("locale", "", "room locale") if err := fs.Parse(args); err != nil { return err diff --git a/cli/serve/serve.go b/cli/serve/serve.go index a4dfc2c8..e26d7468 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -28,7 +28,6 @@ import ( "csgclaw/internal/apitypes" "csgclaw/internal/app/channelwiring" "csgclaw/internal/app/runtimewiring" - "csgclaw/internal/bot" "csgclaw/internal/channel/codexbridge" csgclawchannel "csgclaw/internal/channel/csgclaw" "csgclaw/internal/channel/feishu" @@ -39,6 +38,7 @@ import ( "csgclaw/internal/llm" "csgclaw/internal/modelprovider" internalonboard "csgclaw/internal/onboard" + "csgclaw/internal/participant" agentruntime "csgclaw/internal/runtime" runtimecodex "csgclaw/internal/runtime/codex" "csgclaw/internal/sandboxproviders" @@ -51,7 +51,6 @@ import ( var ( RunServer = server.Run NewAgentService = newAgentService - NewBotService = newBotService NewIMService = newIMService NewFeishuService = newFeishuService NewLLMService = newLLMService @@ -63,8 +62,14 @@ var ( ShutdownCLIProxy = func(ctx context.Context) error { return cliproxy.Default().Shutdown(ctx) } - DetectBootstrapState = internalonboard.DetectState - EnsureBootstrapState = internalonboard.EnsureState + DetectBootstrapState = internalonboard.DetectState + EnsureBootstrapState = internalonboard.EnsureState + EnsureBootstrapManager = func(ctx context.Context, svc *agent.Service) error { + if svc == nil { + return nil + } + return svc.EnsureBootstrapManager(ctx, false) + } StartConfiguredAgents = func(ctx context.Context, svc *agent.Service) error { if svc == nil { return nil @@ -254,11 +259,7 @@ func (c internalServeCmd) Run(ctx context.Context, run *command.Context, args [] if err != nil { return err } - botSvc, err := NewBotService() - if err != nil { - return err - } - return startServerWithConfigPath(ctx, run, cfg, svc, botSvc, imSvc, imBus, feishuSvc, *configPathFlag, globals.Output) + return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, *configPathFlag, globals.Output) } func serveForeground(ctx context.Context, run *command.Context, cfg config.Config, output string) error { @@ -280,10 +281,6 @@ func serveForegroundWithConfigPath(ctx context.Context, run *command.Context, cf if err != nil { return err } - botSvc, err := NewBotService() - if err != nil { - return err - } apiURL := apiBaseURL(cfg.Server) imURL := imOpenURL(apiURL) @@ -303,7 +300,7 @@ func serveForegroundWithConfigPath(ctx context.Context, run *command.Context, cf fmt.Fprintf(run.Stdout, "CSGClaw IM is available at: %s\n", imURL) } - return startServerWithConfigPath(ctx, run, cfg, svc, botSvc, imSvc, imBus, feishuSvc, configPath, output) + return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, configPath, output) } func serveBackground(run *command.Context, cfg config.Config, globals command.GlobalOptions, logPath, pidPath, logLevel string) error { @@ -410,11 +407,11 @@ func parseServeLogLevel(level string) (slog.Level, error) { } } -func startServer(ctx context.Context, run *command.Context, cfg config.Config, svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, imBus *im.Bus, feishuSvc *feishu.Service, output string) error { - return startServerWithConfigPath(ctx, run, cfg, svc, botSvc, imSvc, imBus, feishuSvc, "", output) +func startServer(ctx context.Context, run *command.Context, cfg config.Config, svc *agent.Service, imSvc *im.Service, imBus *im.Bus, feishuSvc *feishu.Service, output string) error { + return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, "", output) } -func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg config.Config, svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, imBus *im.Bus, feishuSvc *feishu.Service, configPath, output string) error { +func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg config.Config, svc *agent.Service, imSvc *im.Service, imBus *im.Bus, feishuSvc *feishu.Service, configPath, output string) error { _ = EnsureCLIProxy(ctx) defer func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -431,8 +428,9 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co if codexBridgeMgr != nil { defer codexBridgeMgr.Close() } - if botSvc != nil { - botSvc.SetDependencies(svc, imSvc, feishuSvc) + participantSvc, err := newParticipantService(svc, imSvc) + if err != nil { + return err } llmSvc, err := NewLLMService(cfg, svc) if err != nil { @@ -475,25 +473,25 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co return err } return RunServer(server.Options{ - ListenAddr: cfg.Server.ListenAddr, - Service: svc, - Hub: hubSvc, - Bot: botSvc, - IM: imSvc, - IMBus: imBus, - BotBridge: im.NewBotBridge(cfg.Server.AccessToken), - Feishu: feishuSvc, - LLM: llmSvc, - Team: teamSvc, - TeamAdapter: teamAdapter, - Upgrade: upgradeManager, - ActivityDecider: channelActivityDecider(codexBridgeMgr), - ConfigPath: configPath, - AccessToken: cfg.Server.AccessToken, - NoAuth: cfg.Server.NoAuth, - Context: ctx, + ListenAddr: cfg.Server.ListenAddr, + Service: svc, + Hub: hubSvc, + Participant: participantSvc, + IM: imSvc, + IMBus: imBus, + ParticipantBridge: im.NewParticipantBridge(cfg.Server.AccessToken), + Feishu: feishuSvc, + LLM: llmSvc, + Team: teamSvc, + TeamAdapter: teamAdapter, + Upgrade: upgradeManager, + ActivityDecider: channelActivityDecider(codexBridgeMgr), + ConfigPath: configPath, + AccessToken: cfg.Server.AccessToken, + NoAuth: cfg.Server.NoAuth, + Context: ctx, OnReady: func(handler *api.Handler, router chi.Router) { - deliver := channelwiring.WireNotificationBotPull(ctx, botSvc, imSvc, apiURL, cfg.Server.AccessToken) + deliver := channelwiring.WireNotificationParticipantPull(ctx, participantSvc, imSvc, apiURL, cfg.Server.AccessToken) handler.SetNotificationDeliver(deliver) if output != "json" && run != nil { go func() { @@ -509,6 +507,9 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co }() } go func() { + if err := EnsureBootstrapManager(ctx, svc); err != nil { + slog.Warn("bootstrap manager failed to start", "error", err) + } if err := StartConfiguredAgents(ctx, svc); err != nil { slog.Warn("some configured agents failed to start", "error", err) } @@ -910,11 +911,7 @@ func (m *serveCodexBridgeManager) Start(ctx context.Context) error { startErr = errors.Join(startErr, fmt.Errorf("%s: %w", a.Name, err)) continue } - if err := m.bridge.StartBot(ctx, codexbridge.Binding{ - BotID: a.ID, - RuntimeID: strings.TrimSpace(a.RuntimeID), - SessionID: session.SessionID, - }); err != nil { + if err := m.bridge.StartBot(ctx, codexBridgeBindingForAgent(a, session.SessionID)); err != nil { startErr = errors.Join(startErr, fmt.Errorf("%s: %w", a.Name, err)) } } @@ -940,12 +937,16 @@ func (m *serveCodexBridgeManager) EnsureAgent(ctx context.Context, a agent.Agent // Force a fresh bot-event subscription even when the binding is unchanged. // This repairs cases where the bridge worker exists but missed its initial // subscription window and would otherwise be treated as a no-op restart. - m.bridge.StopBot(a.ID) - return m.bridge.StartBot(ctx, codexbridge.Binding{ - BotID: a.ID, + m.stopAgentBridge(a) + return m.bridge.StartBot(ctx, codexBridgeBindingForAgent(a, session.SessionID)) +} + +func codexBridgeBindingForAgent(a agent.Agent, sessionID string) codexbridge.Binding { + return codexbridge.Binding{ + BotID: agent.ParticipantIDForAgent(a.Name, a.ID), RuntimeID: strings.TrimSpace(a.RuntimeID), - SessionID: session.SessionID, - }) + SessionID: strings.TrimSpace(sessionID), + } } func (m *serveCodexBridgeManager) beginEnsure(agentID string) bool { @@ -976,7 +977,22 @@ func (m *serveCodexBridgeManager) StopAgent(agentID string) { if m == nil || m.bridge == nil { return } - m.bridge.StopBot(agentID) + m.bridge.StopBot(strings.TrimSpace(agentID)) + participantID := agent.ParticipantIDForAgent("", agentID) + if participantID != strings.TrimSpace(agentID) { + m.bridge.StopBot(participantID) + } +} + +func (m *serveCodexBridgeManager) stopAgentBridge(a agent.Agent) { + if m == nil || m.bridge == nil { + return + } + m.bridge.StopBot(strings.TrimSpace(a.ID)) + participantID := agent.ParticipantIDForAgent(a.Name, a.ID) + if participantID != strings.TrimSpace(a.ID) { + m.bridge.StopBot(participantID) + } } func (m *serveCodexBridgeManager) Close() { @@ -1042,16 +1058,20 @@ func newIMService(bus *im.Bus) (*im.Service, error) { return im.NewServiceFromPathWithBus(imStatePath, bus) } -func newBotService() (*bot.Service, error) { +func newParticipantService(agentSvc *agent.Service, imSvc *im.Service) (*participant.Service, error) { imStatePath, err := config.DefaultIMStatePath() if err != nil { return nil, err } - store, err := bot.NewStore(filepath.Join(filepath.Dir(imStatePath), "bots.json")) + store, err := participant.NewStore(filepath.Join(filepath.Dir(imStatePath), "participants.json")) if err != nil { return nil, err } - return bot.NewService(store) + return participant.NewService( + store, + participant.WithAgentService(agentSvc), + participant.WithIMService(imSvc), + ), nil } func newTeamService(imSvc *im.Service) (*team.Service, team.TeamChannelAdapter, error) { diff --git a/cli/serve/serve_test.go b/cli/serve/serve_test.go index c98af351..33afc83a 100644 --- a/cli/serve/serve_test.go +++ b/cli/serve/serve_test.go @@ -15,12 +15,12 @@ import ( "csgclaw/cli/command" "csgclaw/internal/agent" - "csgclaw/internal/bot" "csgclaw/internal/channel/feishu" "csgclaw/internal/config" "csgclaw/internal/im" "csgclaw/internal/llm" internalonboard "csgclaw/internal/onboard" + "csgclaw/internal/participant" agentruntime "csgclaw/internal/runtime" "csgclaw/internal/sandboxproviders" "csgclaw/internal/server" @@ -141,12 +141,12 @@ func TestServeRunSkipsAutoBootstrapWhenStateComplete(t *testing.T) { t.Fatalf("DetectStateOptions.ConfigPath = %q, want %q", opts.ConfigPath, configPath) } return internalonboard.DetectStateResult{ - ConfigPath: configPath, - ConfigExists: true, - ConfigComplete: true, - IMBootstrapComplete: true, - ManagerAgentComplete: true, - ManagerBotComplete: true, + ConfigPath: configPath, + ConfigExists: true, + ConfigComplete: true, + IMBootstrapComplete: true, + ManagerAgentComplete: true, + ManagerParticipantComplete: true, }, nil } EnsureBootstrapState = func(context.Context, internalonboard.EnsureStateOptions) (internalonboard.EnsureStateResult, error) { @@ -171,15 +171,15 @@ func TestServeRunSkipsBootstrapWhenStateComplete(t *testing.T) { restore := stubServeDependencies(t) defer restore() t.Setenv("HOME", t.TempDir()) - origCreateManagerBot := internalonboard.CreateManagerBot + origCreateManagerParticipant := internalonboard.CreateManagerParticipant origEnsureIMBootstrapState := internalonboard.EnsureIMBootstrapState t.Cleanup(func() { - internalonboard.CreateManagerBot = origCreateManagerBot + internalonboard.CreateManagerParticipant = origCreateManagerParticipant internalonboard.EnsureIMBootstrapState = origEnsureIMBootstrapState }) internalonboard.EnsureIMBootstrapState = func(string) error { return nil } - internalonboard.CreateManagerBot = func(_ context.Context, _, _ string, cfg config.Config) (bot.Bot, error) { - return bot.Bot{ID: agent.ManagerUserID}, nil + internalonboard.CreateManagerParticipant = func(_ context.Context, _, _ string, cfg config.Config) (participant.Participant, error) { + return participant.Participant{ID: agent.ManagerParticipantID}, nil } origDetectBootstrapState := DetectBootstrapState @@ -214,12 +214,12 @@ debian_registries_override = [] t.Fatalf("DetectStateOptions.ConfigPath = %q, want %q", opts.ConfigPath, configPath) } return internalonboard.DetectStateResult{ - ConfigPath: configPath, - ConfigExists: true, - ConfigComplete: true, - IMBootstrapComplete: true, - ManagerAgentComplete: true, - ManagerBotComplete: true, + ConfigPath: configPath, + ConfigExists: true, + ConfigComplete: true, + IMBootstrapComplete: true, + ManagerAgentComplete: true, + ManagerParticipantComplete: true, }, nil } EnsureBootstrapState = func(_ context.Context, opts internalonboard.EnsureStateOptions) (internalonboard.EnsureStateResult, error) { @@ -420,12 +420,12 @@ func TestServeRunRepeatedAutoBootstrapRemainsIdempotent(t *testing.T) { t.Fatalf("DetectStateOptions.ConfigPath = %q, want %q", opts.ConfigPath, configPath) } return internalonboard.DetectStateResult{ - ConfigPath: configPath, - ConfigExists: true, - ConfigComplete: complete, - IMBootstrapComplete: complete, - ManagerAgentComplete: complete, - ManagerBotComplete: complete, + ConfigPath: configPath, + ConfigExists: true, + ConfigComplete: complete, + IMBootstrapComplete: complete, + ManagerAgentComplete: complete, + ManagerParticipantComplete: complete, }, nil } EnsureBootstrapState = func(_ context.Context, opts internalonboard.EnsureStateOptions) (internalonboard.EnsureStateResult, error) { @@ -460,10 +460,10 @@ func TestServeRunRepeatedAutoBootstrapRemainsIdempotent(t *testing.T) { func TestServeForegroundPassesContextToServer(t *testing.T) { origRunServer := RunServer origNewAgentService := NewAgentService - origNewBotService := NewBotService origNewIMService := NewIMService origNewFeishuService := NewFeishuService origNewLLMService := NewLLMService + origEnsureBootstrapManager := EnsureBootstrapManager origStartConfiguredAgents := StartConfiguredAgents origNewCodexBridgeManager := NewCodexBridgeManager origEnsureCLIProxy := EnsureCLIProxy @@ -471,10 +471,10 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { t.Cleanup(func() { RunServer = origRunServer NewAgentService = origNewAgentService - NewBotService = origNewBotService NewIMService = origNewIMService NewFeishuService = origNewFeishuService NewLLMService = origNewLLMService + EnsureBootstrapManager = origEnsureBootstrapManager StartConfiguredAgents = origStartConfiguredAgents NewCodexBridgeManager = origNewCodexBridgeManager EnsureCLIProxy = origEnsureCLIProxy @@ -490,10 +490,6 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { NewIMService = func(*im.Bus) (*im.Service, error) { return nil, nil } - wantBotSvc := &bot.Service{} - NewBotService = func() (*bot.Service, error) { - return wantBotSvc, nil - } NewFeishuService = func(provider feishu.Provider) (*feishu.Service, error) { if provider == nil { return nil, fmt.Errorf("provider = nil, want configured provider") @@ -510,7 +506,16 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { startCalled := make(chan struct{}) releaseStart := make(chan struct{}) startReturned := make(chan struct{}) - startErrors := make(chan string, 4) + startErrors := make(chan string, 6) + EnsureBootstrapManager = func(gotCtx context.Context, gotSvc *agent.Service) error { + if gotCtx != ctx { + startErrors <- fmt.Sprintf("EnsureBootstrapManager context = %v, want %v", gotCtx, ctx) + } + if gotSvc != svc { + startErrors <- fmt.Sprintf("EnsureBootstrapManager service = %p, want %p", gotSvc, svc) + } + return nil + } StartConfiguredAgents = func(gotCtx context.Context, gotSvc *agent.Service) error { defer close(startReturned) if gotCtx != ctx { @@ -528,9 +533,6 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { if opts.Context != ctx { return fmt.Errorf("Context = %v, want %v", opts.Context, ctx) } - if opts.Bot != wantBotSvc { - return fmt.Errorf("Bot = %v, want injected bot service", opts.Bot) - } if !opts.NoAuth { return fmt.Errorf("NoAuth = false, want true") } @@ -690,7 +692,7 @@ func TestStartServerWithConfigPathLoadsPersistedUpgradeFailure(t *testing.T) { }, } - if err := startServerWithConfigPath(context.Background(), run, cfg, nil, nil, nil, nil, nil, configPath, "table"); err != nil { + if err := startServerWithConfigPath(context.Background(), run, cfg, nil, nil, nil, nil, configPath, "table"); err != nil { t.Fatalf("startServerWithConfigPath() error = %v", err) } if _, err := os.Stat(artifacts.StatusPath); !errors.Is(err, os.ErrNotExist) { @@ -752,6 +754,44 @@ func TestServeForegroundStartsConfiguredAgentsOnReady(t *testing.T) { } } +func TestServeForegroundEnsuresBootstrapManagerBeforeConfiguredAgents(t *testing.T) { + restore := stubServeDependencies(t) + defer restore() + + calls := make(chan string, 2) + EnsureBootstrapManager = func(context.Context, *agent.Service) error { + calls <- "manager" + return nil + } + StartConfiguredAgents = func(context.Context, *agent.Service) error { + calls <- "configured-agents" + return nil + } + RunServer = func(opts server.Options) error { + if opts.OnReady == nil { + return fmt.Errorf("OnReady is nil") + } + opts.OnReady(nil, nil) + return nil + } + + if err := serveForeground(context.Background(), testContext(), config.Config{Server: config.ServerConfig{ListenAddr: "127.0.0.1:18080"}}, "json"); err != nil { + t.Fatalf("serveForeground() error = %v", err) + } + got := make([]string, 0, 2) + for len(got) < 2 { + select { + case call := <-calls: + got = append(got, call) + case <-time.After(time.Second): + t.Fatalf("startup calls = %v, want manager before configured-agents", got) + } + } + if want := []string{"manager", "configured-agents"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("startup calls = %v, want %v", got, want) + } +} + func TestServeForegroundPassesConfigPathToServer(t *testing.T) { restore := stubServeDependencies(t) defer restore() @@ -900,13 +940,31 @@ func TestShouldStartCodexBridge(t *testing.T) { } } +func TestCodexBridgeBindingUsesParticipantIDForWorker(t *testing.T) { + binding := codexBridgeBindingForAgent(agent.Agent{ + ID: "u-agent-3l6htd", + Name: "dev", + RuntimeKind: agent.RuntimeKindCodex, + RuntimeID: "rt-u-agent-3l6htd", + }, "sess-dev") + + if binding.BotID != "agent-3l6htd" { + t.Fatalf("BotID = %q, want participant ID agent-3l6htd", binding.BotID) + } + if binding.RuntimeID != "rt-u-agent-3l6htd" || binding.SessionID != "sess-dev" { + t.Fatalf("binding = %+v, want runtime/session preserved", binding) + } +} + func TestServeForegroundPreservesBootstrapDefaultTemplates(t *testing.T) { origRunServer := RunServer origNewAgentService := NewAgentService + origEnsureBootstrapManager := EnsureBootstrapManager origStartConfiguredAgents := StartConfiguredAgents t.Cleanup(func() { RunServer = origRunServer NewAgentService = origNewAgentService + EnsureBootstrapManager = origEnsureBootstrapManager StartConfiguredAgents = origStartConfiguredAgents }) RunServer = func(opts server.Options) error { @@ -915,6 +973,7 @@ func TestServeForegroundPreservesBootstrapDefaultTemplates(t *testing.T) { } return nil } + EnsureBootstrapManager = func(context.Context, *agent.Service) error { return nil } StartConfiguredAgents = func(context.Context, *agent.Service) error { return nil } cfg := config.Config{ @@ -1306,10 +1365,10 @@ func stubServeDependencies(t *testing.T) func() { t.Helper() origRunServer := RunServer origNewAgentService := NewAgentService - origNewBotService := NewBotService origNewIMService := NewIMService origNewFeishuService := NewFeishuService origNewLLMService := NewLLMService + origEnsureBootstrapManager := EnsureBootstrapManager origStartConfiguredAgents := StartConfiguredAgents origNewCodexBridgeManager := NewCodexBridgeManager origEnsureCLIProxy := EnsureCLIProxy @@ -1328,10 +1387,10 @@ func stubServeDependencies(t *testing.T) func() { NewAgentService = func(config.Config, feishu.BotCredentialProvider) (*agent.Service, error) { return &agent.Service{}, nil } - NewBotService = func() (*bot.Service, error) { return &bot.Service{}, nil } NewIMService = func(*im.Bus) (*im.Service, error) { return nil, nil } NewFeishuService = func(feishu.Provider) (*feishu.Service, error) { return nil, nil } NewLLMService = func(config.Config, *agent.Service) (*llm.Service, error) { return nil, nil } + EnsureBootstrapManager = func(context.Context, *agent.Service) error { return nil } StartConfiguredAgents = func(context.Context, *agent.Service) error { return nil } NewCodexBridgeManager = func(config.Config, *agent.Service) (codexBridgeManager, error) { return nil, nil } EnsureCLIProxy = func(context.Context) error { return nil } @@ -1341,21 +1400,21 @@ func stubServeDependencies(t *testing.T) func() { WaitForHealthy = func(string, time.Duration) error { return nil } DetectBootstrapState = func(internalonboard.DetectStateOptions) (internalonboard.DetectStateResult, error) { return internalonboard.DetectStateResult{ - ConfigExists: true, - ConfigComplete: true, - IMBootstrapComplete: true, - ManagerAgentComplete: true, - ManagerBotComplete: true, + ConfigExists: true, + ConfigComplete: true, + IMBootstrapComplete: true, + ManagerAgentComplete: true, + ManagerParticipantComplete: true, }, nil } EnsureBootstrapState = internalonboard.EnsureState return func() { RunServer = origRunServer NewAgentService = origNewAgentService - NewBotService = origNewBotService NewIMService = origNewIMService NewFeishuService = origNewFeishuService NewLLMService = origNewLLMService + EnsureBootstrapManager = origEnsureBootstrapManager StartConfiguredAgents = origStartConfiguredAgents NewCodexBridgeManager = origNewCodexBridgeManager EnsureCLIProxy = origEnsureCLIProxy diff --git a/docs/agent_teams.md b/docs/agent_teams.md index c2ba092b..5e51d278 100644 --- a/docs/agent_teams.md +++ b/docs/agent_teams.md @@ -113,11 +113,11 @@ Task/Approval/Presence = server 端权威结构化状态 | 能力 | 当前落点 | 在 Agent Teams 中的作用 | | --- | --- | --- | | agent runtime 生命周期 | `internal/agent` | manager/worker 的执行实体 | -| bot 到 agent/user 绑定 | `internal/bot` | team member 的稳定身份 | +| participant 到 agent/user 绑定 | `internal/participant` | team member 的稳定身份 | | channel-neutral 协作能力 | 建议新增 `team.ChannelAdapter` 或等价接口 | 统一表达 create room / add member / send message / list messages | | 内建 csgclaw channel | `internal/im` | 内建 channel 的 room/member/message 存储与投影实现 | | 内建 csgclaw 实时事件 | `internal/im.Bus` | 仅用于内建 Web UI/SSE 的实时更新 | -| 内建 csgclaw bot delivery | `internal/im.BotBridge` | 仅用于内建 channel runtime 接收 room 消息;不是 Feishu/Matrix 的通用 mailbox | +| 内建 csgclaw participant delivery | `internal/im.ParticipantBridge` | 仅用于内建 channel runtime 接收 room 消息;不是 Feishu/Matrix 的通用 mailbox | | 外部 channel | `internal/channel/*` + `/api/v1/channels/{channel}` | Feishu/未来 Matrix 的 room/member/message 适配边界 | | Web conversation UI | `web/app/src/pages/ConversationPage` | 内建 csgclaw channel 的第一版可见性主入口 | @@ -139,7 +139,7 @@ Phase 0 定义 adapter 时应按最弱 channel 的能力下限设计,而不是 - `SendMessage` 是 MVP 的核心能力,返回可用于排查的 `MessageRef` 即可; - channel 特有能力通过 optional capability 逐步补充,不提前放进基础接口。 -内建 `csgclaw` adapter 可以包一层 `internal/im.Service`、`internal/im.Bus` 和 `internal/im.BotBridge`;Feishu adapter 则通过 `internal/channel/feishu` 发送消息;未来 Matrix adapter 走 Matrix 的 room/member/message API。`ListMessages` 不作为 MVP 必需能力;只有在做外部 channel 的消息 cursor 恢复、人工恢复或历史同步时,才作为 optional capability 加回 adapter,避免把 task 状态恢复依赖 room history。 +内建 `csgclaw` adapter 可以包一层 `internal/im.Service`、`internal/im.Bus` 和 `internal/im.ParticipantBridge`;Feishu adapter 则通过 `internal/channel/feishu` 发送消息;未来 Matrix adapter 走 Matrix 的 room/member/message API。`ListMessages` 不作为 MVP 必需能力;只有在做外部 channel 的消息 cursor 恢复、人工恢复或历史同步时,才作为 optional capability 加回 adapter,避免把 task 状态恢复依赖 room history。 推荐的概念映射: @@ -218,7 +218,7 @@ Agent Teams 的高级语义由 `internal/team` 解释和维护,再投影成普 | room/member/message | 本地完整读写 | 通过 channel adapter 调用外部 API | | room history 审计 | 本地可完整读取 | 受外部平台权限和历史可见性限制 | | structured event 展示 | Web UI 可增强渲染 | 默认投影为普通文本,后续可加卡片 | -| bot delivery | 可复用 `internal/im.BotBridge` | 需要各自 webhook/event adapter | +| participant delivery | 可复用 `internal/im.ParticipantBridge` | 需要各自 webhook/event adapter | | sideband control | 后续可本地实现 | 后续按渠道能力单独适配 | --- @@ -673,7 +673,7 @@ GET /api/v1/teams/{team_id}/events CLI 复用现有 bot 身份环境变量: ```bash -PICOCLAW_CHANNELS_CSGCLAW_BOT_ID=bot-alice +PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID=bot-alice ``` 执行任务期间,`team_id` 从 `claim` / `claim-next` / `task list` 的响应或显式 `--team` 参数获取。 @@ -684,7 +684,7 @@ CLI 默认根据 stdout 自动选择输出: - stdout 被 pipe 或脚本消费:输出 JSON,方便 agent 和脚本解析; - 需要显式覆盖时使用 `--output json|table`。 -`--bot` 参数默认读取 `PICOCLAW_CHANNELS_CSGCLAW_BOT_ID`,只有调试或 manager 代操作时才显式传入。 +`--bot` 参数默认读取 `PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID`,只有调试或 manager 代操作时才显式传入。 ### 10.2 命令集分层 diff --git a/docs/api.md b/docs/api.md index 284222a1..d6a66deb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,27 +8,25 @@ This document is generated from the HTTP routes and behaviors currently implemen - Time fields use RFC3339 / ISO8601 - Most non-streaming errors are returned as plain-text response bodies - SSE endpoints use `text/event-stream` -- The current API is mainly grouped into 4 areas: +- The current API is mainly grouped into 3 areas: - Core API: `/api/v1/*` - - Channel API: `/api/v1/channels/*` - - Bot compatibility API: `/api/bots/*` + - Channel and participant API: `/api/v1/channels/*` - Health check: `/healthz` ## Authentication - Most `/api/v1/*` endpoints do not require authentication by default - The following endpoints require `Authorization: Bearer `, where the token is the server access token: - - `GET /api/v1/channels/feishu/bots/{id}/events` - - `GET /api/bots/{id}/events` - - `POST /api/bots/{id}/messages/send` - - `GET /api/bots/{id}/llm/models` - - `GET /api/bots/{id}/llm/v1/models` - - `POST /api/bots/{id}/llm/chat/completions` - - `POST /api/bots/{id}/llm/v1/chat/completions` - - `GET /api/bots/{id}/llm/responses` - - `GET /api/bots/{id}/llm/v1/responses` - - `POST /api/bots/{id}/llm/responses` - - `POST /api/bots/{id}/llm/v1/responses` + - `GET /api/v1/channels/{channel}/participants/{id}/events` + - `POST /api/v1/channels/csgclaw/participants/{id}/messages` + - `GET /api/v1/agents/{id}/llm/models` + - `GET /api/v1/agents/{id}/llm/v1/models` + - `POST /api/v1/agents/{id}/llm/chat/completions` + - `POST /api/v1/agents/{id}/llm/v1/chat/completions` + - `POST /api/v1/agents/{id}/llm/responses` + - `POST /api/v1/agents/{id}/llm/v1/responses` + - `GET /api/v1/agents/{id}/llm/responses` + - `GET /api/v1/agents/{id}/llm/v1/responses` - If the server runs with `no_auth`, the checks above are skipped ## Health Check @@ -88,13 +86,15 @@ On success, returns `202 Accepted`: Returns `503 Service Unavailable` if the upgrade manager is not configured. -## Bot Management API +## Participant API -These endpoints are exposed under the channel API namespace and are still backed by the unified `internal/bot` service. The route shape is channel-scoped, but bot lifecycle orchestration is not split into separate per-channel bot services. `role` only supports `manager` and `worker`, and `channel` only supports `csgclaw` and `feishu`. +Participants are channel-scoped identities used by rooms, messages, mentions, +notifications, and runtime bridges. A participant can represent a human, an +agent-backed channel identity, or a notification sender. -### `GET /api/v1/channels/{channel}/bots` +### `GET /api/v1/channels/{channel}/participants` -Returns the bot list for the specified channel. +Returns participants for the specified channel. Path parameters: @@ -102,29 +102,36 @@ Path parameters: Optional query parameters: -- `role` +- `type`: `human`, `agent`, or `notification` +- `agent_id` Response fields: - `id` -- `name` -- `description` -- `role` - `channel` +- `type` +- `name` +- `avatar` +- `channel_user_ref` +- `channel_user_kind` +- `channel_app_ref` - `agent_id` -- `user_id` -- `available` -- `runtime_kind` +- `lifecycle_status` +- `presence` +- `mentionable` +- `metadata` - `created_at` +- `updated_at` Examples: -- `GET /api/v1/channels/csgclaw/bots` -- `GET /api/v1/channels/feishu/bots?role=worker` +- `GET /api/v1/channels/csgclaw/participants` +- `GET /api/v1/channels/csgclaw/participants?type=notification` +- `GET /api/v1/channels/feishu/participants?agent_id=u-worker` -### `POST /api/v1/channels/{channel}/bots` +### `POST /api/v1/channels/{channel}/participants` -Creates a bot in the specified channel. +Creates a participant in the specified channel. Path parameters: @@ -134,42 +141,60 @@ Example request body: ```json { - "id": "u-alice", - "name": "alice", - "role": "worker", - "runtime_kind": "codex", - "from_template": "local.review-bot" + "id": "qa", + "type": "agent", + "name": "QA", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "name": "QA", + "role": "worker", + "runtime_kind": "picoclaw_sandbox", + "from_template": "builtin.picoclaw-worker" + } + } } ``` Notes: +- `type` is required and must be `human`, `agent`, or `notification` - `name` is required -- `role` is required and must be either `manager` or `worker` - The effective channel comes from the route path rather than the request body -- A `worker` bot is associated with a backend agent -- `manager` and `worker` creation behavior can differ by channel +- `agent` participants can create or reuse an Agent through `agent_binding` +- `human` and `notification` participants do not create runtime agents +- In the example above, `qa` is the participant ID; `u-qa` is used only as the local channel user ref and generated backing agent ID. +- For `csgclaw`, `channel_user.ref` is a local IM user ID +- For `feishu`, `channel_user.ref` is the channel-native open ID Examples: -- `POST /api/v1/channels/csgclaw/bots` -- `POST /api/v1/channels/feishu/bots` +- `POST /api/v1/channels/csgclaw/participants` +- `POST /api/v1/channels/feishu/participants` -### `DELETE /api/v1/channels/{channel}/bots/{id}` +### `GET /api/v1/channels/{channel}/participants/{id}` -Deletes the specified bot in the specified channel. +Returns one participant. -Path parameters: +### `PATCH /api/v1/channels/{channel}/participants/{id}` -- `channel`: `csgclaw` or `feishu` -- `id`: bot ID +Updates editable participant fields such as `name`, `avatar`, `mentionable`, and +`metadata`. + +### `DELETE /api/v1/channels/{channel}/participants/{id}` + +Deletes the specified participant in the specified channel. Returns `204 No Content` on success. Examples: -- `DELETE /api/v1/channels/csgclaw/bots/u-alice` -- `DELETE /api/v1/channels/feishu/bots/u-alice` +- `DELETE /api/v1/channels/csgclaw/participants/qa` +- `DELETE /api/v1/channels/feishu/participants/qa` ## Agent API @@ -533,26 +558,6 @@ Notes: - Missing `provider` returns `400` - Login failure returns `502 Bad Gateway` -### `POST /api/v1/cliproxy/auth/logout` - -Disables local provider auth. - -Request body: - -```json -{ - "provider": "codex" -} -``` - -Returns the current provider auth status on success. - -Notes: - -- Missing `provider` returns `400` -- Logout failure returns `502 Bad Gateway` -- Logout blocks immediate auto-import from the same Codex home auth or Claude Keychain entry. - ## Bootstrap Config API ### `GET /api/v1/config/bootstrap` @@ -656,7 +661,7 @@ Request body: ```json { - "id": "u-alice", + "id": "alice", "name": "Alice", "handle": "alice", "role": "worker" @@ -668,7 +673,7 @@ Notes: - `id` is required - `name` is required - `handle` defaults to `name` when omitted -- For `worker` or `agent` roles, if bot service and agent service are both enabled, the server may create a worker bot and its backing agent instead +- For `worker` or `agent` roles, if participant and agent services are both enabled, prefer the participant API for agent-backed identities ### `DELETE /api/v1/users/{id}` @@ -702,8 +707,8 @@ Request body: { "title": "Launch", "description": "coordination", - "creator_id": "u-admin", - "member_ids": ["u-alice", "u-bob"], + "creator_id": "manager", + "member_ids": ["alice", "bob"], "locale": "en" } ``` @@ -728,8 +733,8 @@ Request body: ```json { - "inviter_id": "u-admin", - "user_ids": ["u-bob"], + "inviter_id": "manager", + "user_ids": ["bob"], "locale": "en" } ``` @@ -748,8 +753,8 @@ Request body: ```json { "room_id": "room-1", - "inviter_id": "u-admin", - "user_ids": ["u-bob"], + "inviter_id": "manager", + "user_ids": ["bob"], "locale": "en" } ``` @@ -773,9 +778,9 @@ Request body: ```json { "room_id": "room-1", - "sender_id": "u-admin", + "sender_id": "manager", "content": "hello @alice", - "mention_id": "u-alice" + "mention_id": "alice" } ``` @@ -908,6 +913,9 @@ Optional query parameters: - `bot_id` +`bot_id` is the current Feishu credential/config key field name. It is not a +participant ID; participant-facing routes continue to use participant IDs. + Example response: ```json @@ -959,17 +967,20 @@ Example response: } ``` -### Bot Events +`feishu_bots` is the current response field name for reloaded Feishu +credential keys. Values are target agent IDs, not participant IDs. + +### Participant Events -#### `GET /api/v1/channels/feishu/bots/{id}/events` +#### `GET /api/v1/channels/feishu/participants/{id}/events` -Subscribes to mention events for the specified bot in Feishu. +Subscribes to mention events for the specified participant in Feishu. Characteristics: - Requires Bearer Token - Returns `text/event-stream` -- Only forwards events whose message mentions the bot open_id +- Only forwards events whose message mentions the participant open_id - Writes `: connected` immediately after the stream is established ### Users @@ -1010,8 +1021,8 @@ Example add-members request: ```json { - "inviter_id": "u-manager", - "user_ids": ["ou_member"], + "inviter_id": "manager", + "user_ids": ["dev"], "locale": "zh-CN" } ``` @@ -1026,22 +1037,24 @@ Example send-message request: ```json { "room_id": "oc_xxx", - "sender_id": "u-manager", + "sender_id": "manager", "content": "hello", - "mention_id": "u-worker" + "mention_id": "worker" } ``` -## Bot Compatibility API +## Runtime Bridge API -These endpoints live under `/api/bots/{id}` and exist for compatibility with the older PicoClaw bot integration. +Runtime clients use participant-scoped routes for channel messages and +agent-scoped routes for LLM provider traffic. The legacy `/api/bots/*` routes +are not registered. -For thread/session isolation rules used by the bot and Codex bridges, see +For thread/session isolation rules used by runtime and Codex bridges, see [im-threads.md](./im-threads.md). -### `GET /api/bots/{id}/events` +### `GET /api/v1/channels/{channel}/participants/{id}/events` -Subscribes to the bot event stream. +Subscribes to the participant event stream. Characteristics: @@ -1057,18 +1070,18 @@ Example single event: ```text id: msg-1 event: message -data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"room-1","sender_id":"u-admin","text":"hello","thread_root_id":"msg-root","context":{"channel":"csgclaw","chat_id":"room-1","chat_type":"direct","topic_id":"msg-root","sender_id":"u-admin","message_id":"msg-1"},"thread_context":{"root_message_id":"msg-root","context":[{"id":"msg-root","sender_id":"u-admin","content":"root text"}],"summary":{"root_excerpt":"root text","message_count":1,"before_count":0,"after_count":0}}} +data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"room-1","sender_id":"admin","text":"hello","thread_root_id":"msg-root","context":{"channel":"csgclaw","chat_id":"room-1","chat_type":"direct","topic_id":"msg-root","sender_id":"admin","message_id":"msg-1"},"thread_context":{"root_message_id":"msg-root","context":[{"id":"msg-root","sender_id":"admin","content":"root text"}],"summary":{"root_excerpt":"root text","message_count":1,"before_count":0,"after_count":0}}} ``` For thread replies, `thread_root_id` is the root message ID and `thread_context` carries the deterministic hidden context captured when the -thread was started. Bot/LLM bridges use it as prompt context; it is not a list +thread was started. Runtime/LLM bridges use it as prompt context; it is not a list of thread replies. PicoClaw-native clients can use `context.topic_id` as the same thread/session identifier. -### `POST /api/bots/{id}/messages/send` +### `POST /api/v1/channels/csgclaw/participants/{id}/messages` -Sends a message through the bot compatibility channel. +Sends a message as the specified local CSGClaw participant. Example request body: @@ -1081,9 +1094,9 @@ Example request body: ``` `thread_root_id`, `topic_id`, and `context.topic_id` are optional thread/topic -identifiers. When one is present, the bot response is sent as a reply inside +identifiers. When one is present, the participant response is sent as a reply inside that IM thread. When all are omitted, the response is sent as a top-level room/DM -message; the server does not infer a thread from the bot's most recent room +message; the server does not infer a thread from the participant's most recent room event. PicoClaw outbound message shape is also accepted: @@ -1100,9 +1113,9 @@ PicoClaw outbound message shape is also accepted: } ``` -### `GET /api/bots/{id}/llm/models` +### `GET /api/v1/agents/{id}/llm/models` -### `GET /api/bots/{id}/llm/v1/models` +### `GET /api/v1/agents/{id}/llm/v1/models` Forwards model-list requests to the LLM bridge. @@ -1111,9 +1124,9 @@ Notes: - Requires Bearer Token - Response content type and body are determined by the upstream bridge -### `POST /api/bots/{id}/llm/chat/completions` +### `POST /api/v1/agents/{id}/llm/chat/completions` -### `POST /api/bots/{id}/llm/v1/chat/completions` +### `POST /api/v1/agents/{id}/llm/v1/chat/completions` Forwards chat-completions requests to the LLM bridge. @@ -1134,17 +1147,17 @@ Notes: } ``` -### `POST /api/bots/{id}/llm/responses` +### `POST /api/v1/agents/{id}/llm/responses` -### `POST /api/bots/{id}/llm/v1/responses` +### `POST /api/v1/agents/{id}/llm/v1/responses` -Forwards OpenAI-compatible Responses API requests to the LLM bridge. Codex runtime uses this entrypoint for provider traffic. If the selected upstream provider returns an unsupported Responses endpoint status, the bridge falls back to upstream chat completions and wraps the result in a Responses-compatible response for Codex. +### `GET /api/v1/agents/{id}/llm/responses` -### `GET /api/bots/{id}/llm/responses` +### `GET /api/v1/agents/{id}/llm/v1/responses` -### `GET /api/bots/{id}/llm/v1/responses` +Forwards OpenAI-compatible Responses API requests to the LLM bridge. Codex runtime uses this entrypoint for provider traffic. If the selected upstream provider returns an unsupported Responses endpoint status, the bridge falls back to upstream chat completions and wraps the result in a Responses-compatible response for Codex. -Upgrades to an OpenAI-compatible Responses WebSocket. Codex runtime enables this path only when the selected model provider is Codex, so the bridge can forward Codex ACP WebSocket traffic through the embedded CLIProxy Codex provider. +The `GET` variants are websocket upgrade endpoints for Responses API sessions. Example request body: @@ -1164,13 +1177,12 @@ Notes: - The `model` field is overwritten with the agent's resolved `model_id` - Responses forwarding does not inject the chat-only top-level `reasoning_effort` - Upstream Responses headers, status, and body are copied through, including streaming responses such as `text/event-stream` -- Responses WebSocket `response.create` payloads also have profile request options merged before they are forwarded upstream ## Compatibility Notes - `CreateRoomRequest.participant_ids` is still accepted and mapped to `member_ids` - `Message.mentions` remains backward-compatible with the legacy format: - - New format: `[{ "id": "u-alice", "name": "Alice" }]` + - New format: `[{ "id": "alice", "name": "Alice" }]` - Legacy format: `["u-alice"]` - The local `csgclaw` channel routes are effectively mirrored entrypoints for `/api/v1/users|rooms|messages` @@ -1179,4 +1191,10 @@ Notes: The following paths often seen in older docs are no longer registered in the current router and should not be treated as public APIs: - `/api/v1/notify/{agent_id}` +- `/api/v1/channels/{channel}/bots` +- `/api/v1/channels/{channel}/bots/{id}` +- `/api/v1/channels/feishu/bots/{id}/events` +- `/api/bots/{id}/events` +- `/api/bots/{id}/messages/send` +- `/api/bots/{id}/llm/*` - Any other legacy path not registered in `internal/api/router.go` diff --git a/docs/api.zh.md b/docs/api.zh.md index 9d95a11a..fc311096 100644 --- a/docs/api.zh.md +++ b/docs/api.zh.md @@ -8,27 +8,25 @@ - 时间字段使用 RFC3339 / ISO8601 - 常规错误通常返回纯文本错误正文 - SSE 接口返回 `text/event-stream` -- 当前 API 主要分为 4 组: +- 当前 API 主要分为 3 组: - 核心 API:`/api/v1/*` - - Channel API:`/api/v1/channels/*` - - Bot 兼容 API:`/api/bots/*` + - Channel 与 participant API:`/api/v1/channels/*` - 健康检查:`/healthz` ## 认证 - 默认大多数 `/api/v1/*` 接口不要求认证 - 以下接口要求 `Authorization: Bearer `,其中 token 为服务端 access token - - `GET /api/v1/channels/feishu/bots/{id}/events` - - `GET /api/bots/{id}/events` - - `POST /api/bots/{id}/messages/send` - - `GET /api/bots/{id}/llm/models` - - `GET /api/bots/{id}/llm/v1/models` - - `POST /api/bots/{id}/llm/chat/completions` - - `POST /api/bots/{id}/llm/v1/chat/completions` - - `GET /api/bots/{id}/llm/responses` - - `GET /api/bots/{id}/llm/v1/responses` - - `POST /api/bots/{id}/llm/responses` - - `POST /api/bots/{id}/llm/v1/responses` + - `GET /api/v1/channels/{channel}/participants/{id}/events` + - `POST /api/v1/channels/csgclaw/participants/{id}/messages` + - `GET /api/v1/agents/{id}/llm/models` + - `GET /api/v1/agents/{id}/llm/v1/models` + - `POST /api/v1/agents/{id}/llm/chat/completions` + - `POST /api/v1/agents/{id}/llm/v1/chat/completions` + - `POST /api/v1/agents/{id}/llm/responses` + - `POST /api/v1/agents/{id}/llm/v1/responses` + - `GET /api/v1/agents/{id}/llm/responses` + - `GET /api/v1/agents/{id}/llm/v1/responses` - 若服务端开启 `no_auth`,上述鉴权会被跳过 ## 健康检查 @@ -88,13 +86,13 @@ ok 若升级管理器未配置,返回 `503 Service Unavailable`。 -## Bot 管理 API +## Participant API -这组接口挂在 channel API 命名空间下,但底层仍由统一的 `internal/bot` 服务负责编排,当前并没有按 channel 拆成独立 bot service。`role` 仅支持 `manager` 和 `worker`,`channel` 仅支持 `csgclaw` 和 `feishu`。 +Participant 是 channel-scoped identity,用于房间、消息、mention、通知和 runtime bridge。Participant 可以表示 human、agent-backed channel identity 或 notification sender。 -### `GET /api/v1/channels/{channel}/bots` +### `GET /api/v1/channels/{channel}/participants` -获取指定 channel 下的 bot 列表。 +获取指定 channel 下的 participant 列表。 路径参数: @@ -102,29 +100,36 @@ ok 可选查询参数: -- `role` +- `type`:`human`、`agent` 或 `notification` +- `agent_id` 响应字段: - `id` -- `name` -- `description` -- `role` - `channel` +- `type` +- `name` +- `avatar` +- `channel_user_ref` +- `channel_user_kind` +- `channel_app_ref` - `agent_id` -- `user_id` -- `available` -- `runtime_kind` +- `lifecycle_status` +- `presence` +- `mentionable` +- `metadata` - `created_at` +- `updated_at` 示例: -- `GET /api/v1/channels/csgclaw/bots` -- `GET /api/v1/channels/feishu/bots?role=worker` +- `GET /api/v1/channels/csgclaw/participants` +- `GET /api/v1/channels/csgclaw/participants?type=notification` +- `GET /api/v1/channels/feishu/participants?agent_id=u-worker` -### `POST /api/v1/channels/{channel}/bots` +### `POST /api/v1/channels/{channel}/participants` -在指定 channel 下创建 bot。 +在指定 channel 下创建 participant。 路径参数: @@ -134,42 +139,59 @@ ok ```json { - "id": "u-alice", - "name": "alice", - "role": "worker", - "runtime_kind": "codex", - "from_template": "local.review-bot" + "id": "qa", + "type": "agent", + "name": "QA", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "name": "QA", + "role": "worker", + "runtime_kind": "picoclaw_sandbox", + "from_template": "builtin.picoclaw-worker" + } + } } ``` 说明: +- `type` 必填,且只能是 `human`、`agent` 或 `notification` - `name` 必填 -- `role` 必填,且只能是 `manager` 或 `worker` - 实际 channel 由路由路径决定,而不是由请求体决定 -- `worker` bot 会关联后端 agent -- `manager` / `worker` 在不同 channel 上的创建行为可能不同 +- `agent` participant 可通过 `agent_binding` 创建或复用 Agent +- `human` 与 `notification` participant 不创建 runtime agent +- 上面的示例中,`qa` 是 participant ID;`u-qa` 只作为本地 channel user ref 和生成的 backing agent ID。 +- 对 `csgclaw` 来说,`channel_user.ref` 是本地 IM user ID +- 对 `feishu` 来说,`channel_user.ref` 是 channel-native open ID 示例: -- `POST /api/v1/channels/csgclaw/bots` -- `POST /api/v1/channels/feishu/bots` +- `POST /api/v1/channels/csgclaw/participants` +- `POST /api/v1/channels/feishu/participants` -### `DELETE /api/v1/channels/{channel}/bots/{id}` +### `GET /api/v1/channels/{channel}/participants/{id}` -删除指定 channel 下的 bot。 +获取单个 participant。 -路径参数: +### `PATCH /api/v1/channels/{channel}/participants/{id}` -- `channel`:`csgclaw` 或 `feishu` -- `id`:bot ID +更新 `name`、`avatar`、`mentionable`、`metadata` 等可编辑 participant 字段。 + +### `DELETE /api/v1/channels/{channel}/participants/{id}` + +删除指定 channel 下的 participant。 成功返回 `204 No Content`。 示例: -- `DELETE /api/v1/channels/csgclaw/bots/u-alice` -- `DELETE /api/v1/channels/feishu/bots/u-alice` +- `DELETE /api/v1/channels/csgclaw/participants/qa` +- `DELETE /api/v1/channels/feishu/participants/qa` ## Agent API @@ -533,26 +555,6 @@ ok - 缺少 `provider` 返回 `400` - 登录失败返回 `502 Bad Gateway` -### `POST /api/v1/cliproxy/auth/logout` - -禁用本地 provider 鉴权。 - -请求体: - -```json -{ - "provider": "codex" -} -``` - -成功返回 provider 当前鉴权状态。 - -说明: - -- 缺少 `provider` 返回 `400` -- Logout 失败返回 `502 Bad Gateway` -- Logout 会阻止同一个 Codex home auth 或 Claude Keychain 记录被立刻自动导入。 - ## Bootstrap Config API ### `GET /api/v1/config/bootstrap` @@ -656,7 +658,7 @@ bootstrap 响应中的 room 消息列表遵循默认时间线契约:只包含 ```json { - "id": "u-alice", + "id": "alice", "name": "Alice", "handle": "alice", "role": "worker" @@ -668,7 +670,7 @@ bootstrap 响应中的 room 消息列表遵循默认时间线契约:只包含 - `id` 必填 - `name` 必填 - `handle` 省略时默认等于 `name` -- 对于 `worker/agent` 角色,如果 bot service 与 agent service 已启用,服务端可能转而创建一个 worker bot 及其 backing agent +- 对于 `worker/agent` 角色,如果 participant service 与 agent service 已启用,应优先使用 participant API 创建 agent-backed 身份 ### `DELETE /api/v1/users/{id}` @@ -701,8 +703,8 @@ room 消息列表默认不包含 thread reply;当 thread 存在时,root mess { "title": "Launch", "description": "coordination", - "creator_id": "u-admin", - "member_ids": ["u-alice", "u-bob"], + "creator_id": "manager", + "member_ids": ["alice", "bob"], "locale": "en" } ``` @@ -727,8 +729,8 @@ room 消息列表默认不包含 thread reply;当 thread 存在时,root mess ```json { - "inviter_id": "u-admin", - "user_ids": ["u-bob"], + "inviter_id": "manager", + "user_ids": ["bob"], "locale": "en" } ``` @@ -747,8 +749,8 @@ room 消息列表默认不包含 thread reply;当 thread 存在时,root mess ```json { "room_id": "room-1", - "inviter_id": "u-admin", - "user_ids": ["u-bob"], + "inviter_id": "manager", + "user_ids": ["bob"], "locale": "en" } ``` @@ -771,9 +773,9 @@ room 消息列表默认不包含 thread reply;当 thread 存在时,root mess ```json { "room_id": "room-1", - "sender_id": "u-admin", + "sender_id": "manager", "content": "hello @alice", - "mention_id": "u-alice" + "mention_id": "alice" } ``` @@ -902,6 +904,9 @@ context 不会被渲染成 thread 内消息;它是给 LLM-backed agent 使用 - `bot_id` +`bot_id` 是当前 Feishu 凭证/config key 的字段名,不是 participant ID;面向 +participant 的路由仍使用 participant ID。 + 响应示例: ```json @@ -953,17 +958,20 @@ context 不会被渲染成 thread 内消息;它是给 LLM-backed agent 使用 } ``` -### Bot 事件 +`feishu_bots` 是当前响应字段名,表示已重新加载的飞书凭证 key。 +其中的值是目标 agent ID,不是 participant ID。 + +### Participant 事件 -#### `GET /api/v1/channels/feishu/bots/{id}/events` +#### `GET /api/v1/channels/feishu/participants/{id}/events` -订阅指定 bot 在飞书中的被提及消息事件。 +订阅指定 participant 在飞书中的被提及消息事件。 特点: - 需要 Bearer Token - 返回 `text/event-stream` -- 只转发“消息里 mention 到该 bot open_id”的事件 +- 只转发“消息里 mention 到该 participant open_id”的事件 - 建立连接后先输出 `: connected` ### 用户 @@ -1004,8 +1012,8 @@ context 不会被渲染成 thread 内消息;它是给 LLM-backed agent 使用 ```json { - "inviter_id": "u-manager", - "user_ids": ["ou_member"], + "inviter_id": "manager", + "user_ids": ["dev"], "locale": "zh-CN" } ``` @@ -1020,22 +1028,22 @@ context 不会被渲染成 thread 内消息;它是给 LLM-backed agent 使用 ```json { "room_id": "oc_xxx", - "sender_id": "u-manager", + "sender_id": "manager", "content": "hello", - "mention_id": "u-worker" + "mention_id": "worker" } ``` -## Bot 兼容 API +## Runtime Bridge API -这组接口位于 `/api/bots/{id}`,用于兼容旧的 PicoClaw Bot 接入方式。 +Runtime client 使用 participant-scoped 路由处理 channel 消息,使用 agent-scoped 路由处理 LLM provider 流量。旧的 `/api/bots/*` 路由不再注册。 -Bot 和 Codex bridge 使用的 thread/session 隔离规则见 +Runtime 和 Codex bridge 使用的 thread/session 隔离规则见 [im-threads.zh.md](./im-threads.zh.md)。 -### `GET /api/bots/{id}/events` +### `GET /api/v1/channels/{channel}/participants/{id}/events` -订阅 bot 事件流。 +订阅 participant 事件流。 特点: @@ -1051,17 +1059,17 @@ Bot 和 Codex bridge 使用的 thread/session 隔离规则见 ```text id: msg-1 event: message -data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"room-1","sender_id":"u-admin","text":"hello","thread_root_id":"msg-root","context":{"channel":"csgclaw","chat_id":"room-1","chat_type":"direct","topic_id":"msg-root","sender_id":"u-admin","message_id":"msg-1"},"thread_context":{"root_message_id":"msg-root","context":[{"id":"msg-root","sender_id":"u-admin","content":"root text"}],"summary":{"root_excerpt":"root text","message_count":1,"before_count":0,"after_count":0}}} +data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"room-1","sender_id":"admin","text":"hello","thread_root_id":"msg-root","context":{"channel":"csgclaw","chat_id":"room-1","chat_type":"direct","topic_id":"msg-root","sender_id":"admin","message_id":"msg-1"},"thread_context":{"root_message_id":"msg-root","context":[{"id":"msg-root","sender_id":"admin","content":"root text"}],"summary":{"root_excerpt":"root text","message_count":1,"before_count":0,"after_count":0}}} ``` 对于 thread replies,`thread_root_id` 是 root message ID,`thread_context` -携带 thread 开启时记录的确定性隐藏上下文。Bot/LLM bridge 会把它作为 +携带 thread 开启时记录的确定性隐藏上下文。Runtime/LLM bridge 会把它作为 prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client 可以把 `context.topic_id` 当作同一个 thread/session 标识。 -### `POST /api/bots/{id}/messages/send` +### `POST /api/v1/channels/csgclaw/participants/{id}/messages` -向 bot 兼容通道发送消息。 +以指定本地 CSGClaw participant 身份发送消息。 请求体示例: @@ -1074,8 +1082,8 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client ``` `thread_root_id`、`topic_id` 和 `context.topic_id` 都是可选的 thread/topic -标识;传入任一字段时 bot 响应会发送到该 IM thread 中。全部省略时, -响应会作为 room/DM 顶层消息发送;服务端不会根据 bot 在房间中最近收到的 +标识;传入任一字段时 participant 响应会发送到该 IM thread 中。全部省略时, +响应会作为 room/DM 顶层消息发送;服务端不会根据 participant 在房间中最近收到的 事件推断 thread。 也接受 PicoClaw outbound message 形态: @@ -1092,9 +1100,9 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client } ``` -### `GET /api/bots/{id}/llm/models` +### `GET /api/v1/agents/{id}/llm/models` -### `GET /api/bots/{id}/llm/v1/models` +### `GET /api/v1/agents/{id}/llm/v1/models` 转发模型列表请求到 LLM bridge。 @@ -1103,9 +1111,9 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client - 需要 Bearer Token - 返回内容类型和响应体由上游 bridge 决定 -### `POST /api/bots/{id}/llm/chat/completions` +### `POST /api/v1/agents/{id}/llm/chat/completions` -### `POST /api/bots/{id}/llm/v1/chat/completions` +### `POST /api/v1/agents/{id}/llm/v1/chat/completions` 转发聊天补全请求到 LLM bridge。 @@ -1126,17 +1134,17 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client } ``` -### `POST /api/bots/{id}/llm/responses` +### `POST /api/v1/agents/{id}/llm/responses` -### `POST /api/bots/{id}/llm/v1/responses` +### `POST /api/v1/agents/{id}/llm/v1/responses` -转发 OpenAI-compatible Responses API 请求到 LLM bridge。Codex runtime 使用这个入口发送 provider 流量。如果所选上游 provider 返回不支持 Responses endpoint 的状态,bridge 会回退到上游 chat completions,并把结果包装成 Codex 可消费的 Responses-compatible response。 +### `GET /api/v1/agents/{id}/llm/responses` -### `GET /api/bots/{id}/llm/responses` +### `GET /api/v1/agents/{id}/llm/v1/responses` -### `GET /api/bots/{id}/llm/v1/responses` +转发 OpenAI-compatible Responses API 请求到 LLM bridge。Codex runtime 使用这个入口发送 provider 流量。如果所选上游 provider 返回不支持 Responses endpoint 的状态,bridge 会回退到上游 chat completions,并把结果包装成 Codex 可消费的 Responses-compatible response。 -升级为 OpenAI-compatible Responses WebSocket。只有当选择的模型 provider 是 Codex 时,Codex runtime 才会启用这条路径,让 bridge 可以把 Codex ACP 的 WebSocket 流量转发到内置 CLIProxy 的 Codex provider。 +`GET` 形式是 Responses API session 的 websocket upgrade 入口。 请求体示例: @@ -1156,13 +1164,12 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client - `model` 字段会被覆盖为 agent 已解析出的 `model_id` - Responses 转发不会注入 chat-only 的顶层 `reasoning_effort` - 上游 Responses 的 headers、status 和 body 会被透传,包括 `text/event-stream` 这类流式响应 -- Responses WebSocket 的 `response.create` payload 同样会在转发前合并 profile request options ## 兼容性说明 - `CreateRoomRequest.participant_ids` 仍兼容旧字段,会映射到 `member_ids` - `Message.mentions` 兼容旧格式: - - 新格式:`[{ "id": "u-alice", "name": "Alice" }]` + - 新格式:`[{ "id": "alice", "name": "Alice" }]` - 旧格式:`["u-alice"]` - 本地 `csgclaw` channel 路由本质上是 `/api/v1/users|rooms|messages` 的镜像入口 @@ -1171,4 +1178,10 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client 以下旧文档中常见路径,当前路由里已不存在,不应再作为对外 API 使用: - `/api/v1/notify/{agent_id}` +- `/api/v1/channels/{channel}/bots` +- `/api/v1/channels/{channel}/bots/{id}` +- `/api/v1/channels/feishu/bots/{id}/events` +- `/api/bots/{id}/events` +- `/api/bots/{id}/messages/send` +- `/api/bots/{id}/llm/*` - 任何未在 `internal/api/router.go` 中注册的旧路径 diff --git a/docs/architecture.md b/docs/architecture.md index 1d8a92ca..52fa8529 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,13 +30,13 @@ The following diagram shows the relationships among the main CSGClaw concepts. | dependency v +----------------------------------------------------------------------------------------+ -| Bot | +| Participant | | | | +--------------------------+ +----------------------------+ +--------------------+ | -| | Normal Bot | | Notification Bot | | A2A Bot (planned) | | +| | Agent Participant | | Notification Participant | | Human Participant | | | | | | | | | | -| | User <-------> Agent | | User <-------> Pull/Push | | User <----> A2A | | -| | | | | Notification | | Agent | | +| | User <-------> Agent | | User <-------> Pull/Push | | User identity | | +| | | | | Notification | | | | | +-----------------|--------+ +----------------------------+ +--------------------+ | +--------------------|-------------------------------------------------------------------+ | @@ -67,41 +67,42 @@ The following diagram shows the relationships among the main CSGClaw concepts. -CSGClaw is a Go-based local multi-agent platform. It runs a single local HTTP server, serves the Web UI, exposes REST/SSE/WebSocket APIs, and manages channels, rooms, bots, runtimes, sandboxes, users, and messages. +CSGClaw is a Go-based local multi-agent platform. It runs a single local HTTP server, serves the Web UI, exposes REST/SSE/WebSocket APIs, and manages channels, rooms, participants, agents, runtimes, sandboxes, users, and messages. The ASCII diagram describes the system as five layers: - **Channel**: the external or built-in interaction surface, such as `csgclaw` IM, Feishu / Lark, or a planned Matrix integration. -- **Room**: the collaboration container controlled by a channel. Each room typically contains one `manager` bot and multiple `worker` bots. -- **Bot**: the product-facing identity inside a room. Current bot shapes are a normal bot and a notification bot, with A2A bot support planned. -- **Runtime**: the executable agent runtime behind a bot, such as PicoClaw Sandbox, OpenClaw Sandbox, or Codex. +- **Room**: the collaboration container controlled by a channel. Each room can contain humans, agent participants, and notification participants. +- **Participant**: the product-facing channel identity inside a room. Participant types are `human`, `agent`, and `notification`. +- **Agent**: the runtime-managed execution identity optionally bound to an `agent` participant. +- **Runtime**: the executable agent runtime, such as PicoClaw Sandbox, OpenClaw Sandbox, or Codex. - **Sandbox**: the isolation backend used by a runtime, such as BoxLite, Docker, or CSGHub. The dependency direction in the diagram is intentional: ```text -channel -> room -> bot -> runtime -> sandbox +channel -> room -> participant -> agent -> runtime -> sandbox ``` -Each upper layer orchestrates the layer below it. A channel controls rooms, a room coordinates manager and worker bots, a bot delegates execution to a runtime, and the runtime relies on a sandbox provider for isolation. +Each upper layer orchestrates the layer below it. A channel controls rooms, a room contains participants, an agent participant may bind to an Agent, the Agent delegates execution to a runtime, and the runtime relies on a sandbox provider for isolation. -Within that model, a bot remains the stable binding object exposed to users: +Within that model, a participant is the stable binding object exposed to users: ```text -bot - ├─ role: manager | worker - ├─ room_id ───────────► collaboration context in a channel room - ├─ agent_id ───────────► runtime instance - └─ channel + user_id ───► user identity in the selected channel +participant + ├─ type: human | agent | notification + ├─ channel + participant_id ─► stable channel identity + ├─ channel_user_ref ─────────► user identity in the selected channel + └─ agent_id ─────────────────► optional runtime Agent ``` -This keeps channel messaging in `internal/im` and `internal/channel`, room-level collaboration in the room and message services, bot lifecycle logic in `internal/bot`, runtime execution in `internal/runtime` / `internal/agent`, and sandbox integration behind the runtime and sandbox packages. +This keeps channel messaging in `internal/im` and `internal/channel`, room-level collaboration in the room and message services, participant provisioning in `internal/participant`, runtime execution in `internal/runtime` / `internal/agent`, and sandbox integration behind the runtime and sandbox packages. In the current codebase, those layers map roughly as follows: - **Channel layer**: implemented by the built-in `internal/im` services and external adapters under `internal/channel/*`. - **Room layer**: represented by room, membership, message, and thread flows exposed through the IM and channel APIs. -- **Bot layer**: implemented by `internal/bot`, including normal bot and notification bot lifecycle. +- **Participant layer**: implemented by `internal/participant`, including human, agent, and notification participants. - **Runtime layer**: implemented primarily by `internal/runtime/*` and `internal/agent`. - **Sandbox layer**: implemented by sandbox backends such as `internal/sandbox/boxlitecli`, plus runtime-specific sandbox integration paths. @@ -114,7 +115,7 @@ The local HTTP server and Web UI sit beside these layers as operator and user en - `cmd/csgclaw` and `cmd/csgclaw-cli` stay thin. They should only start their CLI entrypoints. - `cli` owns command parsing, HTTP calls, and output formatting. - `internal/api` owns HTTP request/response handling only. -- `internal/bot` owns bot creation and listing. It coordinates `agent` and channel user creation. +- `internal/participant` owns participant creation and listing. It coordinates `agent` and channel user creation when needed. - `internal/agent` owns agent lifecycle and logs through `internal/sandbox`. - `internal/im` owns the built-in `csgclaw` IM. - `internal/channel` owns external channel integrations such as Feishu. @@ -129,8 +130,8 @@ Threads are root-message-anchored sub-conversations inside a room or DM. They use Matrix-shaped `m.thread` relation metadata while staying inside the existing CSGClaw IM API surface. -Thread replies are hidden from the main room timeline by default. Bot and Codex -runtime bridges scope normal conversations by `room_id` and thread conversations +Thread replies are hidden from the main room timeline by default. Runtime and Codex +bridges scope normal conversations by `room_id` and thread conversations by `room_id:thread_root_id`, so each thread starts with clean runtime context plus the hidden root context snapshot. @@ -146,7 +147,7 @@ cli/csgclawcli/ csgclaw-cli app wiring and global flag handling cli/message/ shared message command implementation for csgclaw and csgclaw-cli internal/server/ local HTTP server and static UI wiring internal/api/ HTTP handlers and route registration -internal/bot/ bot lifecycle and agent/user binding +internal/participant/ participant lifecycle and optional agent/user binding internal/agent/ agent runtime and storage internal/sandbox/ runtime-neutral sandbox interfaces internal/sandbox/boxlitecli/ BoxLite CLI sandbox implementation @@ -158,35 +159,35 @@ web/app/ Web UI development source and Vite project web/static-dist/ generated Web UI assets for Go embed; run make build-web ``` -`internal/bot` is the new business boundary for bot behavior. It should not be implemented as extra glue inside API handlers. +`internal/participant` is the business boundary for participant behavior. It should not be implemented as extra glue inside API handlers. --- -## Bot Model +## Participant Model -The bot record is the stable object exposed to users and higher-level workflows. +The participant record is the stable channel identity exposed to users and higher-level workflows. Typical fields: ```json { - "id": "bot-alice", - "name": "alice", - "role": "worker", + "id": "alice", "channel": "csgclaw", - "agent_id": "agent-alice", - "user_id": "u-alice" + "type": "agent", + "name": "Alice", + "channel_user_ref": "u-alice", + "channel_user_kind": "local_user_id", + "agent_id": "u-alice" } ``` -Rules: +Legacy notes: -- `role` must be `manager` or `worker`. -- `channel` defaults to `csgclaw`. -- `channel` may be `csgclaw` or `feishu`. -- each bot maps to exactly one agent. -- each bot maps to exactly one user in the selected channel. -- bot creation should create or bind both underlying identities, then persist the bot mapping. +- Product-facing collaboration identities are participants, not bots. +- A participant is scoped to a channel and has `type=human|agent|notification`. +- `agent` participants may create or bind a runtime Agent. +- In the example above, `alice` is the participant ID; `u-alice` is not a participant ID. +- Channel user identity belongs to participant state, while runtime state belongs to Agent. --- @@ -195,9 +196,12 @@ Rules: All new product APIs should live under `/api/v1`. ```text -# Bot -GET /api/v1/channels/{channel}/bots List bots -POST /api/v1/channels/{channel}/bots Create a bot +# Participant +GET /api/v1/channels/{channel}/participants List participants +POST /api/v1/channels/{channel}/participants Create a participant +GET /api/v1/channels/{channel}/participants/{id} Get a participant +PATCH /api/v1/channels/{channel}/participants/{id} Update a participant +DELETE /api/v1/channels/{channel}/participants/{id} Delete a participant # Agent GET /api/v1/agents List agents @@ -229,17 +233,17 @@ POST /api/v1/channels/feishu/rooms/{room_id}/members POST /api/v1/channels/feishu/messages ``` -`POST /api/v1/channels/{channel}/bots` should be handled as a bot use case: +`POST /api/v1/channels/{channel}/participants` should be handled as a participant provisioning use case: ```text API handler - └─► internal/bot.Create - ├─► create or bind agent through internal/agent + └─► internal/participant.Create + ├─► create or bind Agent through internal/agent when type=agent ├─► create or bind channel user through internal/im or internal/channel - └─► persist bot mapping + └─► persist participant identity ``` -The API layer should not directly duplicate bot orchestration logic. +The API layer should not directly duplicate participant provisioning logic. --- @@ -247,14 +251,14 @@ The API layer should not directly duplicate bot orchestration logic. Both CLIs are thin HTTP clients. They should not call stores, BoxLite, or channel SDKs directly. -`csgclaw` is the full local management CLI for human operators. It owns server lifecycle, agent runtime commands, and the shared bot/room/member/user/message workflows. +`csgclaw` is the full local management CLI for human operators. It owns server lifecycle, agent runtime commands, and the shared participant/room/member/user/message workflows. -`csgclaw-cli` is the lightweight CLI primarily intended for agents and scripts. It exposes only the bot, room, member, and message workflows that agents need for collaboration, and does not manage the local server lifecycle or agent runtime directly. +`csgclaw-cli` is the lightweight CLI primarily intended for agents and scripts. It exposes only the participant, room, member, and message workflows that agents need for collaboration, and does not manage the local server lifecycle or agent runtime directly. At a high level: - `csgclaw` includes local operator workflows such as `serve`, `stop`, and agent management, plus shared collaboration commands. -- `csgclaw-cli` keeps only the collaboration-oriented command groups needed by bots, agents, and scripts. +- `csgclaw-cli` keeps only the collaboration-oriented command groups needed by participants, agents, and scripts. - Shared collaboration commands select the target channel through flags and call the same local HTTP API surface. For the current command tree, flags, defaults, and examples, see [cli.md](./cli.md) or [cli.zh.md](./cli.zh.md). @@ -264,17 +268,17 @@ For the current command tree, flags, defaults, and examples, see [cli.md](./cli. ## Creation Flow ```text -csgclaw bot create --channel feishu - └─► POST /api/v1/channels/feishu/bots - └─► internal/bot.Create - ├─► internal/agent creates BoxLite-backed agent - ├─► internal/channel creates Feishu user - └─► internal/bot saves: - bot_id - role +csgclaw participant create --channel feishu --type agent + └─► POST /api/v1/channels/feishu/participants + └─► internal/participant.Create + ├─► internal/agent creates or reuses runtime Agent + ├─► internal/channel binds Feishu channel identity + └─► internal/participant saves: + participant_id + type channel agent_id - user_id + channel_user_ref ``` For the built-in channel, the same flow uses `internal/im` to create the user identity. @@ -288,16 +292,16 @@ Filesystem storage remains the default persistence layer. Each domain owns its own records: - `agent`: runtime metadata and sandbox state references -- `bot`: bot-to-agent-to-channel-user mapping +- `participant`: channel identity and optional agent binding - `im`: built-in rooms, users, messages, and events - `channel`: external channel integration state when needed -Do not store channel-specific details directly inside the agent record. The agent should remain the runtime object; channel identity belongs to bot/channel state. +Do not store channel-specific details directly inside the agent record. The agent should remain the runtime object; channel identity belongs to participant/channel state. --- ## Notes -- Existing compatibility routes, such as PicoClaw-specific bot APIs or older IM aliases, can remain for compatibility, but new bot lifecycle work should use `/api/v1/channels/{channel}/bots`. -- Feishu support should live behind `internal/channel`, while bot lifecycle decisions stay in `internal/bot`. -- When changing config fields or defaults for bot/channel behavior, update loader, saver, bootstrap initialization flow, tests, and docs together. +- Legacy bot compatibility routes are removed from the target API. Runtime clients should use participant-scoped event/message routes and agent-scoped LLM routes. +- Feishu support should live behind `internal/channel`, while participant provisioning decisions stay in `internal/participant`. +- When changing config fields or defaults for participant/channel behavior, update loader, saver, bootstrap initialization flow, tests, and docs together. diff --git a/docs/channel/csgclaw.md b/docs/channel/csgclaw.md index cfa47b16..8d81c1db 100644 --- a/docs/channel/csgclaw.md +++ b/docs/channel/csgclaw.md @@ -32,6 +32,7 @@ Some flows (notably Feishu manager setup) return a structured card object in the ### Required behavior - `type` must be exactly `csgclaw.action_card`. +- `bot_id` is a legacy payload field used by the existing setup helper; its value is the target agent ID, not a participant ID. - `actions[0].id` must be `rebuild-manager`. - `actions[0].method` must be `manager-bootstrap-replace`. - Frontend must render this payload directly as the complete chat content (no prose, no markdown table, no markdown code fence). @@ -54,21 +55,21 @@ Do not call `POST /api/v1/agents/u-manager/recreate` for this flow. - Never return or log secret values (for example `app_secret`, API keys, tokens). - If any sensitive value appears in logs, use masked forms such as `present`. -## Notification bots +## Notification participants -Notification bots are channel bots with `type=notification`. They do not create backing worker agents; delivery configuration is stored on `bot.runtime_options` in `bots.json`. Default bot id is `n-{name}` (separate from worker agent ids `u-{name}`); you may set `id` explicitly, but it must not collide with an existing agent or channel bot. +Notification senders are CSGClaw participants with `type=notification`. They do not create backing worker agents; delivery configuration is stored in participant `metadata`. Default participant id is `n-{name}` (separate from worker agent ids `u-{name}`); you may set `id` explicitly, but it must not collide with an existing participant in the channel. -- List: `GET /api/v1/channels/csgclaw/bots` (includes `type=notification` bots; feishu bot list excludes them) -- Create: `POST /api/v1/channels/csgclaw/bots` with `"type":"notification"` and flat `runtime_options` (`delivery_mode`, `webhook_token`, `remote_url`, …) -- Update: `PATCH /api/v1/channels/csgclaw/bots/{id}` -- Delete: `DELETE /api/v1/channels/csgclaw/bots/{id}` -- Push (webhook): `POST /api/v1/channels/csgclaw/bots/{id}/notifications` with `Authorization: Bearer ` +- List: `GET /api/v1/channels/csgclaw/participants?type=notification` +- Create: `POST /api/v1/channels/csgclaw/participants` with `"type":"notification"` and `metadata` (`delivery_mode`, `webhook_token`, `remote_url`, ...) +- Update: `PATCH /api/v1/channels/csgclaw/participants/{id}` +- Delete: `DELETE /api/v1/channels/csgclaw/participants/{id}` +- Push (webhook): `POST /api/v1/channels/csgclaw/participants/{id}/notifications` with `Authorization: Bearer ` -Implementation: `internal/channel/csgclaw/notification_bot/`, `internal/bot/notification.go`. +Implementation: `internal/channel/csgclaw/notification/`. ## csgclaw.notify_card payload -Notification deliveries (GitLab/GitHub webhooks, and so on) to the CSGClaw Web IM use this type: the message **`content` is a single JSON object** produced by `internal/channel/csgclaw/notification_bot`, and the Web UI renders it as a structured card (title, badge, meta rows, optional link, optional collapsible raw JSON). +Notification deliveries (GitLab/GitHub webhooks, and so on) to the CSGClaw Web IM use this type: the message **`content` is a single JSON object** produced by `internal/channel/csgclaw/notification`, and the Web UI renders it as a structured card (title, badge, meta rows, optional link, optional collapsible raw JSON). ```json { @@ -105,5 +106,5 @@ Notification deliveries (GitLab/GitHub webhooks, and so on) to the CSGClaw Web I - Frontend parser/renderer: `web/app/src/components/business/MessageContent/MessageContent.tsx`, `web/app/src/components/business/MessageContent/structuredMessages.ts` - Action-card and notifier-card test coverage: `web/app/tests/legacy-contract.test.ts`, `web/app/tests/components/MessageContent/structuredMessages.test.ts` -- Notification card encoding: `internal/channel/csgclaw/notification_bot/notify_card.go`, `internal/channel/csgclaw/notification_bot/notify_webhooks.go` +- Notification card encoding: `internal/channel/csgclaw/notification/notify_card.go`, `internal/channel/csgclaw/notification/notify_webhooks.go` - Feishu setup command output: `internal/templates/embed/runtimes/picoclaw/manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py` diff --git a/docs/channel/csgclaw.zh.md b/docs/channel/csgclaw.zh.md index 4e1d678b..a9bda2d0 100644 --- a/docs/channel/csgclaw.zh.md +++ b/docs/channel/csgclaw.zh.md @@ -32,6 +32,7 @@ ### 必填行为 - `type` 必须是 `csgclaw.action_card`。 +- `bot_id` 是现有 setup helper 沿用的旧 payload 字段;其值表示目标 agent ID,不是 participant ID。 - `actions[0].id` 必须是 `rebuild-manager`。 - `actions[0].method` 必须是 `manager-bootstrap-replace`。 - 前端必须将该 payload 直接作为完整聊天内容渲染(不允许附加普通文本、markdown 表格或 markdown 代码块)。 @@ -54,21 +55,21 @@ Content-Type: application/json - 不要返回或记录敏感凭证(如 `app_secret`、API key、token)。 - 若敏感值出现在日志中,应使用掩码形式(例如 `present`)。 -## Notification bot(通知机器人) +## Notification participant(通知参与者) -通知机器人是 `type=notification` 的 channel bot,不创建 backing worker agent;投递配置保存在 `bots.json` 的 `bot.runtime_options` 中。默认 bot id 为 `n-{name}`(与 worker agent 的 `u-{name}` 区分);创建时也可显式指定 `id`,但不得与已有 agent 或其它 channel bot 冲突。 +通知发送者是 `type=notification` 的 CSGClaw participant,不创建 backing worker agent;投递配置保存在 participant `metadata` 中。默认 participant id 为 `n-{name}`(与 worker agent 的 `u-{name}` 区分);创建时也可显式指定 `id`,但不得与同 channel 下已有 participant 冲突。 -- 列表:`GET /api/v1/channels/csgclaw/bots`(含 `type=notification`;feishu channel 列表不包含通知 bot) -- 创建:`POST /api/v1/channels/csgclaw/bots`,请求体含 `"type":"notification"` 与扁平 `runtime_options` -- 更新:`PATCH /api/v1/channels/csgclaw/bots/{id}` -- 删除:`DELETE /api/v1/channels/csgclaw/bots/{id}` -- 推送(webhook):`POST /api/v1/channels/csgclaw/bots/{id}/notifications`,请求头 `Authorization: Bearer ` +- 列表:`GET /api/v1/channels/csgclaw/participants?type=notification` +- 创建:`POST /api/v1/channels/csgclaw/participants`,请求体含 `"type":"notification"` 与 `metadata` +- 更新:`PATCH /api/v1/channels/csgclaw/participants/{id}` +- 删除:`DELETE /api/v1/channels/csgclaw/participants/{id}` +- 推送(webhook):`POST /api/v1/channels/csgclaw/participants/{id}/notifications`,请求头 `Authorization: Bearer ` -实现:`internal/channel/csgclaw/notification_bot/`、`internal/bot/notification.go`。 +实现:`internal/channel/csgclaw/notification/`。 ## `csgclaw.notify_card` 结构 -通知投递(GitLab/GitHub webhook 等)到 CSGClaw Web IM 时使用该类型:**整条消息的 `content` 即一段 JSON**,由服务端 `internal/channel/csgclaw/notification_bot` 生成,Web 前端按 `type` 渲染为结构化卡片(标题、徽章、元数据行、可选链接与折叠原始 JSON)。 +通知投递(GitLab/GitHub webhook 等)到 CSGClaw Web IM 时使用该类型:**整条消息的 `content` 即一段 JSON**,由服务端 `internal/channel/csgclaw/notification` 生成,Web 前端按 `type` 渲染为结构化卡片(标题、徽章、元数据行、可选链接与折叠原始 JSON)。 ```json { @@ -105,5 +106,5 @@ Content-Type: application/json - 前端解析与渲染:`web/app/src/components/business/MessageContent/MessageContent.tsx`、`web/app/src/components/business/MessageContent/structuredMessages.ts` - Action card 与 Notifier card 单测:`web/app/tests/legacy-contract.test.ts`、`web/app/tests/components/MessageContent/structuredMessages.test.ts` -- 通知卡片生成:`internal/channel/csgclaw/notification_bot/notify_card.go`、`internal/channel/csgclaw/notification_bot/notify_webhooks.go` +- 通知卡片生成:`internal/channel/csgclaw/notification/notify_card.go`、`internal/channel/csgclaw/notification/notify_webhooks.go` - Feishu setup 命令输出:`internal/templates/embed/runtimes/picoclaw/manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py` diff --git a/docs/channel/feishu.md b/docs/channel/feishu.md index b2e62558..71602608 100644 --- a/docs/channel/feishu.md +++ b/docs/channel/feishu.md @@ -4,7 +4,7 @@ English | [中文](feishu.zh.md) This document explains the standalone Feishu channel configuration file. -CSGClaw uses this file to map one human Feishu administrator and multiple Feishu bot applications into local CSGClaw identities. +CSGClaw uses this file to store Feishu bot application credentials for target CSGClaw agents, plus the human Feishu administrator `open_id` used by setup flows. ## Configuration Structure @@ -35,39 +35,40 @@ app_secret = "your_feishu_app_secret" Use this field for the human administrator who manages or coordinates CSGClaw from Feishu. It is not a bot app ID and it is not a bot credential. -## Bot Entries +## Feishu App Credential Entries -Each nested table such as `[bots.u-dev]` defines one Feishu bot application for one CSGClaw bot identity. +Each nested table such as `[bots.u-dev]` defines one Feishu bot application credential entry. The `bots.` prefix is the current on-disk format, but the key after `bots.` is a target CSGClaw **agent ID**, not a participant ID. -The table key is the CSGClaw bot ID: +The table key is the CSGClaw agent ID: -- `u-manager` is a reserved ID. -- Other bot IDs should follow the `u-{name}` format, such as `u-dev` or `u-qa`. +- `u-manager` is the reserved manager agent ID. +- Other target agent IDs should follow the `u-{name}` format, such as `u-dev` or `u-qa`. -For each bot ID: +For each target agent ID: - `app_id` is the Feishu bot application's App ID. - `app_secret` is the Feishu bot application's App Secret. -In other words, `u-dev`, `u-manager`, and `u-qa` are CSGClaw bot IDs, while the values inside each table are Feishu bot credentials. +In other words, `u-dev`, `u-manager`, and `u-qa` are CSGClaw agent IDs used to select Feishu credentials. Channel API calls and room membership should use participant IDs, such as `dev`, `manager`, or `qa`, when those differ from agent IDs. ## Naming Rules -- `u-manager` is reserved for the manager bot used by CSGClaw. -- Custom bot IDs should use the `u-{name}` pattern. -- Do not use a human user's `open_id` as a bot table key. -- Do not place bot `app_id` or `app_secret` under `admin_open_id`. +- `u-manager` is reserved for the manager agent used by CSGClaw. +- Custom target agent IDs should use the `u-{name}` pattern. +- Do not use a participant ID, such as `manager` or `dev`, as a credential table key unless it is also the real target agent ID. +- Do not use a human user's `open_id` as a credential table key. +- Do not place Feishu app `app_id` or `app_secret` under `admin_open_id`. ## Example Interpretation Given the sample structure: - `admin_open_id` identifies one real Feishu user. -- `u-manager` identifies the reserved CSGClaw manager bot. -- `u-dev` identifies a CSGClaw bot backed by one Feishu bot app. -- `u-qa` identifies another CSGClaw bot backed by another Feishu bot app. +- `u-manager` identifies the reserved CSGClaw manager agent. +- `u-dev` identifies a CSGClaw worker agent backed by one Feishu bot app. +- `u-qa` identifies another CSGClaw worker agent backed by another Feishu bot app. -Each bot entry must have its own Feishu `app_id` and `app_secret`. +Each credential entry must have its own Feishu `app_id` and `app_secret`. ## Security Note diff --git a/docs/channel/feishu.zh.md b/docs/channel/feishu.zh.md index d2bebb82..1d15ec37 100644 --- a/docs/channel/feishu.zh.md +++ b/docs/channel/feishu.zh.md @@ -4,7 +4,7 @@ 本文说明独立的飞书 channel 配置文件格式。 -CSGClaw 通过这个文件,把一个真人飞书管理员和多个飞书机器人应用映射为本地的 CSGClaw 身份。 +CSGClaw 通过这个文件保存目标 CSGClaw agent 对应的飞书机器人应用凭证,以及初始化流程使用的真人飞书管理员 `open_id`。 ## 配置结构 @@ -35,39 +35,40 @@ app_secret = "your_feishu_app_secret" 这个字段用于表示在飞书侧管理或协调 CSGClaw 的管理员用户。它不是机器人的 App ID,也不是机器人的凭证。 -## 机器人条目 +## 飞书应用凭证条目 -每个子表,例如 `[bots.u-dev]`,都表示一个飞书机器人应用,对应一个 CSGClaw 机器人身份。 +每个子表,例如 `[bots.u-dev]`,都表示一个飞书机器人应用凭证条目。`bots.` 前缀是当前磁盘格式,`bots.` 后面的 key 是目标 CSGClaw **agent ID**,不是 participant ID。 -子表的 key 就是 CSGClaw 机器人的 ID: +子表的 key 是 CSGClaw agent ID: -- `u-manager` 是保留 ID。 -- 其他机器人 ID 应遵循 `u-{name}` 格式,例如 `u-dev`、`u-qa`。 +- `u-manager` 是保留的 manager agent ID。 +- 其他目标 agent ID 应遵循 `u-{name}` 格式,例如 `u-dev`、`u-qa`。 -对于每个机器人 ID: +对于每个目标 agent ID: - `app_id` 是该飞书机器人应用的 App ID。 - `app_secret` 是该飞书机器人应用的 App Secret。 -也就是说,`u-dev`、`u-manager`、`u-qa` 这些是 CSGClaw 机器人的 ID;每个子表里的值才是对应飞书机器人的凭证。 +也就是说,`u-dev`、`u-manager`、`u-qa` 是用于选择飞书凭证的 CSGClaw agent ID。Channel API 调用和房间成员应使用 participant ID,例如 `dev`、`manager`、`qa`,当它们与 agent ID 不同时不能混用。 ## 命名规则 -- `u-manager` 保留给 CSGClaw 的 manager 机器人使用。 -- 自定义机器人 ID 应使用 `u-{name}` 格式。 -- 不要把真人用户的 `open_id` 用作机器人子表的 key。 -- 不要把机器人的 `app_id` 或 `app_secret` 填到 `admin_open_id` 里。 +- `u-manager` 保留给 CSGClaw 的 manager agent 使用。 +- 自定义目标 agent ID 应使用 `u-{name}` 格式。 +- 不要把 `manager`、`dev` 这类 participant ID 当作凭证表 key,除非它同时也是实际目标 agent ID。 +- 不要把真人用户的 `open_id` 用作凭证子表的 key。 +- 不要把飞书应用的 `app_id` 或 `app_secret` 填到 `admin_open_id` 里。 ## 示例解读 按照示例结构: - `admin_open_id` 标识一个真人飞书用户。 -- `u-manager` 标识 CSGClaw 保留的 manager 机器人。 -- `u-dev` 标识一个由某个飞书机器人应用驱动的 CSGClaw 机器人。 -- `u-qa` 标识另一个由不同飞书机器人应用驱动的 CSGClaw 机器人。 +- `u-manager` 标识 CSGClaw 保留的 manager agent。 +- `u-dev` 标识一个由某个飞书机器人应用驱动的 CSGClaw worker agent。 +- `u-qa` 标识另一个由不同飞书机器人应用驱动的 CSGClaw worker agent。 -每个机器人条目都必须配置自己独立的飞书 `app_id` 和 `app_secret`。 +每个凭证条目都必须配置自己独立的飞书 `app_id` 和 `app_secret`。 ## 安全说明 diff --git a/docs/cli.md b/docs/cli.md index 07c2da1e..6ec7c386 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,7 +6,7 @@ This document supplements the CLI section in [architecture.md](./architecture.md `csgclaw` is the full local operator CLI. It manages local server lifecycle, agent runtime operations, and the shared collaboration workflows. -`csgclaw-cli` is the lightweight HTTP client intended for bots, agents, and scripts. It exposes only collaboration-oriented workflows and does not manage config files or server lifecycle. +`csgclaw-cli` is the lightweight HTTP client intended for participants, agents, and scripts. It exposes only collaboration-oriented workflows and does not manage config files or server lifecycle. Both CLIs are thin HTTP clients over the local API. They do not talk to BoxLite, stores, or channel SDKs directly. @@ -80,8 +80,9 @@ Top-level commands: - `upgrade` - `agent` - `model` +- `participant` +- `pt` - `user` -- `bot` - `room` - `member` - `message` @@ -432,19 +433,20 @@ csgclaw user list csgclaw user list --channel feishu csgclaw user create --name Alice --handle alice --role worker csgclaw user create --channel feishu --name Alice --handle alice --role manager --avatar AL -csgclaw user delete u-alice +csgclaw user delete alice ``` ### Shared collaboration groups in `csgclaw` The following command groups are shared with `csgclaw-cli` and use the same flags and semantics. -#### `bot` +#### `participant` Usage: ```bash -csgclaw bot [flags] +csgclaw participant [flags] +csgclaw pt [flags] ``` Subcommands: @@ -452,28 +454,66 @@ Subcommands: - `list` - `create` - `delete` +- `config` -`bot list` flags: +`participant list` flags: - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. -- `--role string`: filter by `manager` or `worker`. +- `--type string`: filter by `human`, `agent`, or `notification`. +- `--agent-id string`: filter by bound agent ID. -`bot create` flags: +`participant create` flags: -- `--id string`: bot ID. -- `--name string`: required. -- `--description string`: bot description. -- `--role string`: required. `manager` or `worker`. - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. -- `--model-id string`: agent model ID. +- `--id string`: participant ID. +- `--name string`: required participant display name. +- `--description string`: participant metadata description and agent description for `--bind create`. +- `--type string`: `human`, `agent`, or `notification`. Default `agent`. +- `--channel-user-ref string`: channel user identity, such as a local user ID or Feishu open_id. +- `--channel-user-kind string`: channel user identity kind, such as `local_user_id` or `open_id`. +- `--channel-app-ref string`: channel app/config reference, such as a Feishu app_id. +- `--bind string`: agent binding mode: `create`, `reuse`, or `none`. Default `none`. +- `--agent-id string`: agent ID for `--bind reuse`, or optional agent ID for `--bind create`. +- `--role string`: agent role for `--bind create`. +- `--runtime string`: agent runtime kind for `--bind create`. +- `--image string`: agent image for `--bind create`. +- `--from-template string`: hub template for `--bind create`. +- `--model-id string`: agent model ID for `--bind create`. +- `--env KEY=VALUE`: agent image environment variable for `--bind create`; repeatable. -`bot delete` usage and flags: +`participant delete` usage and flags: ```bash -csgclaw bot delete [flags] +csgclaw participant delete [flags] ``` - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. +- `--delete-agent string`: agent cleanup mode. Supported value: `if_unreferenced`. + +`participant config` manages participant channel configuration through the local HTTP API. +Only Feishu is currently supported. + +`participant config` flags: + +- `--channel string`: only `feishu` is supported. Default `feishu`. +- `--get`: get masked Feishu config. +- `--set`: set Feishu config. +- `--reload`: reload Feishu config. +- `--bot-id string`: Feishu config key used by the current server API. +- `--app-id string`: Feishu app id. Required with `--set`. +- `--admin-open-id string`: optional Feishu admin open_id. +- `--app-secret-file string`: read Feishu app secret from a file. +- `--app-secret-env string`: read Feishu app secret from an environment variable. +- `--app-secret-stdin`: read Feishu app secret from stdin. +- `--no-reload`: write config without reloading the running server. + +`participant config` behavior: + +- Exactly one of `--get`, `--set`, or `--reload` is required. +- `--get` and `--set` require `--bot-id`. +- `--set` requires exactly one app secret source. +- The returned `app_secret` value is a status marker, not the real secret. +- `pt config` is equivalent to `participant config`. #### `room` @@ -498,11 +538,11 @@ Subcommands: - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. - `--title string`: room title. - `--description string`: room description. -- `--creator-id string`: creator bot ID, such as `u-manager`. -- `--member-ids string`: comma-separated bot IDs, such as `u-manager,u-dev`. +- `--creator-id string`: creator participant ID, such as `manager`. +- `--member-ids string`: comma-separated participant IDs, such as `manager,dev`. - `--locale string`: room locale. -Design note for `csgclaw-cli`: room creation should expose CSGClaw bot IDs, not channel user IDs, agent IDs, Feishu open IDs, Feishu app IDs, or app credentials. In the Feishu channel, the channel adapter resolves bot IDs to the configured Feishu app credentials and bot identifiers internally. When Feishu group creation needs a real human owner ID, CSGClaw continues to use the configured `admin_open_id` internally; callers should still pass bot IDs at the CLI boundary. +Design note for `csgclaw-cli`: room creation should expose CSGClaw participant IDs, not channel user IDs, agent IDs, Feishu open IDs, Feishu app IDs, or app credentials. In the Feishu channel, the channel adapter resolves participant IDs to the configured Feishu app credentials and channel identifiers internally. When Feishu group creation needs a real human owner ID, CSGClaw continues to use the configured `admin_open_id` internally; callers should still pass participant IDs at the CLI boundary. `room delete` usage and flags: @@ -534,14 +574,14 @@ Subcommands: - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. - `--room-id string`: target room ID. -- `--user-id string`: required. Bot ID to add, such as `u-dev`. -- `--inviter-id string`: inviter bot ID, such as `u-manager`. +- `--user-id string`: required. Participant ID to add, such as `dev`. +- `--inviter-id string`: inviter participant ID, such as `manager`. - `--locale string`: room locale. `member create` behavior: - `--user-id` is required. -- `csgclaw-cli` room membership commands should use bot IDs consistently across channels. Feishu open IDs and app IDs are channel implementation details. +- `csgclaw-cli` room membership commands should use participant IDs consistently across channels. Feishu open IDs and app IDs are channel implementation details. #### `message` @@ -565,9 +605,9 @@ Subcommands: - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. - `--room-id string`: required. -- `--sender-id string`: required sender bot ID. +- `--sender-id string`: required sender participant ID. - `--content string`: required. -- `--mention-id string`: optional mentioned bot ID. +- `--mention-id string`: optional mentioned participant ID. `message list` behavior: @@ -576,12 +616,14 @@ Subcommands: Examples: ```bash -csgclaw bot list -csgclaw bot create --name alice --role worker --model-id gpt-5.4-mini -csgclaw room create --title "release-room" --creator-id u-manager --member-ids u-manager,u-alice -csgclaw member create --room-id room-1 --user-id u-alice --inviter-id u-manager +csgclaw participant list +csgclaw participant create --name alice --bind create --role worker --model-id gpt-5.4-mini +csgclaw participant config --channel feishu --get --bot-id u-manager +csgclaw pt config --channel feishu --set --bot-id u-manager --app-id cli_xxx --app-secret-env FEISHU_APP_SECRET +csgclaw room create --title "release-room" --creator-id manager --member-ids manager,alice +csgclaw member create --room-id room-1 --user-id alice --inviter-id manager csgclaw message list --room-id room-1 -csgclaw message create --channel csgclaw --room-id room-1 --sender-id u-manager --content hello +csgclaw message create --channel csgclaw --room-id room-1 --sender-id manager --content hello ``` ## `csgclaw-cli` @@ -603,7 +645,8 @@ Global flags: Top-level commands: -- `bot` +- `participant` +- `pt` - `room` - `member` - `message` @@ -623,9 +666,14 @@ csgclaw-cli completion fish `csgclaw-cli` reuses the same implementations as `csgclaw` for: -- `bot list` -- `bot create` -- `bot delete` +- `participant list` +- `participant create` +- `participant delete` +- `pt list` +- `pt create` +- `pt delete` +- `participant config` +- `pt config` - `room list` - `room create` - `room delete` @@ -639,12 +687,14 @@ That means flags, defaults, validations, and JSON shapes are identical between t Examples: ```bash -csgclaw-cli bot list --channel feishu -csgclaw-cli bot create --name manager --role manager --channel feishu -csgclaw-cli room create --channel feishu --title "ops-room" --creator-id u-manager --member-ids u-manager,u-dev +csgclaw-cli participant list --channel feishu --type agent +csgclaw-cli pt create --name manager --channel feishu --type agent --bind create --role manager +csgclaw-cli participant config --channel feishu --get --bot-id u-manager +csgclaw-cli pt config --channel feishu --reload +csgclaw-cli room create --channel feishu --title "ops-room" --creator-id manager --member-ids manager,dev csgclaw-cli member list --channel feishu --room-id oc_x -csgclaw-cli member create --channel feishu --room-id oc_x --user-id u-dev --inviter-id u-manager -csgclaw-cli message create --channel feishu --room-id oc_x --sender-id u-manager --mention-id u-dev --content hello +csgclaw-cli member create --channel feishu --room-id oc_x --user-id dev --inviter-id manager +csgclaw-cli message create --channel feishu --room-id oc_x --sender-id manager --mention-id dev --content hello ``` -`csgclaw-cli` is the bot-facing CLI. It should not require callers to know or pass agent IDs, Feishu open IDs, Feishu app IDs, App ID/App Secret, or other channel credentials in room, member, or message commands. Channel-specific adapters are responsible for exchanging bot IDs for the identifiers required by the target channel. +`csgclaw-cli` is the participant-facing CLI. Room, member, and message commands should not require callers to know or pass agent IDs, Feishu open IDs, Feishu app IDs, App ID/App Secret, or other channel credentials. Channel-specific adapters are responsible for exchanging participant IDs for the identifiers required by the target channel. diff --git a/docs/cli.zh.md b/docs/cli.zh.md index 79c294cd..96605f80 100644 --- a/docs/cli.zh.md +++ b/docs/cli.zh.md @@ -6,7 +6,7 @@ `csgclaw` 是完整的本地运维 CLI,用于管理初始化、本地服务生命周期、Agent 运行时,以及共享的协作命令。 -`csgclaw-cli` 是轻量级 HTTP 客户端,主要面向 Bot、Agent 和脚本。它只暴露协作相关命令,不负责初始化、配置文件管理或本地服务生命周期。 +`csgclaw-cli` 是轻量级 HTTP 客户端,主要面向 participant、Agent 和脚本。它只暴露协作相关命令,不负责初始化、配置文件管理或本地服务生命周期。 两个 CLI 都是本地 API 的薄客户端,不会直接操作 BoxLite、底层存储或渠道 SDK。 @@ -80,8 +80,9 @@ csgclaw [global-flags] [args] - `upgrade` - `agent` - `model` +- `participant` +- `pt` - `user` -- `bot` - `room` - `member` - `message` @@ -432,19 +433,20 @@ csgclaw user list csgclaw user list --channel feishu csgclaw user create --name Alice --handle alice --role worker csgclaw user create --channel feishu --name Alice --handle alice --role manager --avatar AL -csgclaw user delete u-alice +csgclaw user delete alice ``` ### `csgclaw` 中共享的协作命令组 以下命令组与 `csgclaw-cli` 共享同一套实现,因此参数和行为完全一致。 -#### `bot` +#### `participant` 用法: ```bash -csgclaw bot [flags] +csgclaw participant [flags] +csgclaw pt [flags] ``` 子命令: @@ -452,28 +454,65 @@ csgclaw bot [flags] - `list` - `create` - `delete` +- `config` -`bot list` 参数: +`participant list` 参数: - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 -- `--role string`:按 `manager` 或 `worker` 过滤。 +- `--type string`:按 `human`、`agent` 或 `notification` 过滤。 +- `--agent-id string`:按绑定的 Agent ID 过滤。 -`bot create` 参数: +`participant create` 参数: -- `--id string`:Bot ID。 -- `--name string`:必填。 -- `--description string`:Bot 描述。 -- `--role string`:必填,取值为 `manager` 或 `worker`。 - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 -- `--model-id string`:Agent model ID。 +- `--id string`:participant ID。 +- `--name string`:必填,participant 显示名。 +- `--description string`:participant metadata 描述;`--bind create` 时也会作为 Agent 描述。 +- `--type string`:`human`、`agent` 或 `notification`,默认 `agent`。 +- `--channel-user-ref string`:渠道用户身份,例如本地 user ID 或飞书 open_id。 +- `--channel-user-kind string`:渠道用户身份类型,例如 `local_user_id` 或 `open_id`。 +- `--channel-app-ref string`:渠道 app/config 引用,例如飞书 app_id。 +- `--bind string`:Agent 绑定模式:`create`、`reuse` 或 `none`,默认 `none`。 +- `--agent-id string`:`--bind reuse` 时的 Agent ID;`--bind create` 时也可指定要创建的 Agent ID。 +- `--role string`:`--bind create` 时的 Agent role。 +- `--runtime string`:`--bind create` 时的 Agent runtime kind。 +- `--image string`:`--bind create` 时的 Agent image。 +- `--from-template string`:`--bind create` 时使用的 hub template。 +- `--model-id string`:`--bind create` 时的 Agent model ID。 +- `--env KEY=VALUE`:`--bind create` 时的 Agent image 环境变量,可重复传入。 -`bot delete` 用法与参数: +`participant delete` 用法与参数: ```bash -csgclaw bot delete [flags] +csgclaw participant delete [flags] ``` - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 +- `--delete-agent string`:Agent 清理模式,支持 `if_unreferenced`。 + +`participant config` 通过本地 HTTP API 管理 participant channel 配置。当前仅支持 Feishu。 + +`participant config` 参数: + +- `--channel string`:仅支持 `feishu`,默认 `feishu`。 +- `--get`:读取脱敏后的 Feishu 配置。 +- `--set`:写入 Feishu 配置。 +- `--reload`:重新加载 Feishu 配置。 +- `--bot-id string`:当前服务端 API 使用的 Feishu config key。 +- `--app-id string`:Feishu app id,`--set` 时必填。 +- `--admin-open-id string`:可选 Feishu admin open_id。 +- `--app-secret-file string`:从文件读取 Feishu app secret。 +- `--app-secret-env string`:从环境变量读取 Feishu app secret。 +- `--app-secret-stdin`:从 stdin 读取 Feishu app secret。 +- `--no-reload`:只写入配置,不重新加载运行中的服务。 + +`participant config` 行为说明: + +- 必须且只能传入 `--get`、`--set`、`--reload` 之一。 +- `--get` 和 `--set` 需要 `--bot-id`。 +- `--set` 必须且只能指定一种 app secret 来源。 +- 返回中的 `app_secret` 是状态标记,不是真实 secret。 +- `pt config` 与 `participant config` 完全等价。 #### `room` @@ -498,11 +537,11 @@ csgclaw room [flags] - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 - `--title string`:房间标题。 - `--description string`:房间描述。 -- `--creator-id string`:创建者 bot ID,例如 `u-manager`。 -- `--member-ids string`:逗号分隔的 bot ID 列表,例如 `u-manager,u-dev`。 +- `--creator-id string`:创建者 participant ID,例如 `manager`。 +- `--member-ids string`:逗号分隔的 participant ID 列表,例如 `manager,dev`。 - `--locale string`:房间 locale。 -`csgclaw-cli` 设计约束:创建 room 时只暴露 CSGClaw bot ID,不暴露 channel user ID、agent ID、飞书 open_id、飞书 app_id 或应用凭证。Feishu 渠道由 adapter 在内部把 bot ID 兑换为已配置的飞书应用凭证和 bot 标识。飞书建群需要真人 owner ID 时,代码仍使用配置里的 `admin_open_id`,CLI 调用方仍只传 bot ID。 +`csgclaw-cli` 设计约束:创建 room 时只暴露 CSGClaw participant ID,不暴露 channel user ID、agent ID、飞书 open_id、飞书 app_id 或应用凭证。Feishu 渠道由 adapter 在内部把 participant ID 兑换为已配置的飞书应用凭证和渠道标识。飞书建群需要真人 owner ID 时,代码仍使用配置里的 `admin_open_id`,CLI 调用方仍只传 participant ID。 `room delete` 用法与参数: @@ -534,14 +573,14 @@ csgclaw member [flags] - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 - `--room-id string`:目标房间 ID。 -- `--user-id string`:必填,要加入房间的 bot ID,例如 `u-dev`。 -- `--inviter-id string`:邀请人 bot ID,例如 `u-manager`。 +- `--user-id string`:必填,要加入房间的 participant ID,例如 `dev`。 +- `--inviter-id string`:邀请人 participant ID,例如 `manager`。 - `--locale string`:房间 locale。 `member create` 行为说明: - `--user-id` 为必填。 -- `csgclaw-cli` 的成员操作在所有渠道下都应使用 bot ID。飞书 open_id 和 app_id 是渠道内部实现细节。 +- `csgclaw-cli` 的成员操作在所有渠道下都应使用 participant ID。飞书 open_id 和 app_id 是渠道内部实现细节。 #### `message` @@ -565,9 +604,9 @@ csgclaw message [flags] - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 - `--room-id string`:必填。 -- `--sender-id string`:必填,发送方 bot ID。 +- `--sender-id string`:必填,发送方 participant ID。 - `--content string`:必填。 -- `--mention-id string`:可选,被提及 bot ID。 +- `--mention-id string`:可选,被提及 participant ID。 `message list` 行为说明: @@ -576,12 +615,14 @@ csgclaw message [flags] 示例: ```bash -csgclaw bot list -csgclaw bot create --name alice --role worker --model-id gpt-5.4-mini -csgclaw room create --title "release-room" --creator-id u-manager --member-ids u-manager,u-alice -csgclaw member create --room-id room-1 --user-id u-alice --inviter-id u-manager +csgclaw participant list +csgclaw participant create --name alice --bind create --role worker --model-id gpt-5.4-mini +csgclaw participant config --channel feishu --get --bot-id u-manager +csgclaw pt config --channel feishu --set --bot-id u-manager --app-id cli_xxx --app-secret-env FEISHU_APP_SECRET +csgclaw room create --title "release-room" --creator-id manager --member-ids manager,alice +csgclaw member create --room-id room-1 --user-id alice --inviter-id manager csgclaw message list --room-id room-1 -csgclaw message create --channel csgclaw --room-id room-1 --sender-id u-manager --content hello +csgclaw message create --channel csgclaw --room-id room-1 --sender-id manager --content hello ``` ## `csgclaw-cli` @@ -603,7 +644,8 @@ csgclaw-cli [global-flags] [args] 顶层命令: -- `bot` +- `participant` +- `pt` - `room` - `member` - `message` @@ -623,9 +665,14 @@ csgclaw-cli completion fish `csgclaw-cli` 与 `csgclaw` 复用完全相同的实现,包含: -- `bot list` -- `bot create` -- `bot delete` +- `participant list` +- `participant create` +- `participant delete` +- `pt list` +- `pt create` +- `pt delete` +- `participant config` +- `pt config` - `room list` - `room create` - `room delete` @@ -639,12 +686,14 @@ csgclaw-cli completion fish 示例: ```bash -csgclaw-cli bot list --channel feishu -csgclaw-cli bot create --name manager --role manager --channel feishu -csgclaw-cli room create --channel feishu --title "ops-room" --creator-id u-manager --member-ids u-manager,u-dev +csgclaw-cli participant list --channel feishu --type agent +csgclaw-cli pt create --name manager --channel feishu --type agent --bind create --role manager +csgclaw-cli participant config --channel feishu --get --bot-id u-manager +csgclaw-cli pt config --channel feishu --reload +csgclaw-cli room create --channel feishu --title "ops-room" --creator-id manager --member-ids manager,dev csgclaw-cli member list --channel feishu --room-id oc_x -csgclaw-cli member create --channel feishu --room-id oc_x --user-id u-dev --inviter-id u-manager -csgclaw-cli message create --channel feishu --room-id oc_x --sender-id u-manager --mention-id u-dev --content hello +csgclaw-cli member create --channel feishu --room-id oc_x --user-id dev --inviter-id manager +csgclaw-cli message create --channel feishu --room-id oc_x --sender-id manager --mention-id dev --content hello ``` -`csgclaw-cli` 是面向 bot 的 CLI。room、member、message 命令不应要求调用方理解或传入 agent ID、飞书 open_id、飞书 app_id、App ID/App Secret 或其他渠道凭证。各 channel adapter 负责把 bot ID 转换成目标渠道需要的标识。 +`csgclaw-cli` 是面向 participant 的 CLI。room、member、message 命令不应要求调用方理解或传入 agent ID、飞书 open_id、飞书 app_id、App ID/App Secret 或其他渠道凭证。各 channel adapter 负责把 participant ID 转换成目标渠道需要的标识。 diff --git a/docs/config.md b/docs/config.md index 67b2076c..157e6ae3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,7 +12,7 @@ English | [中文](config.zh.md) Use `advertise_base_url` when the automatically inferred address is not reachable from BoxLite boxes, such as when you need a LAN address, a tunnel URL, or a host alias. -`access_token` protects authenticated API routes, including the PicoClaw bot routes. When authentication is enabled, clients must send `Authorization: Bearer `. +`access_token` protects authenticated API routes, including the PicoClaw participant bridge routes. When authentication is enabled, clients must send `Authorization: Bearer `. `no_auth` controls whether CSGClaw skips the bearer-token check. The default is `false`. Set it to `true` only for trusted local or development environments. diff --git a/docs/config.zh.md b/docs/config.zh.md index a883655f..be6cb85b 100644 --- a/docs/config.zh.md +++ b/docs/config.zh.md @@ -12,7 +12,7 @@ 当自动推断出的地址无法从 BoxLite box 内访问时,可以设置 `advertise_base_url`,例如使用局域网地址、隧道地址或 host alias。 -`access_token` 用来保护需要认证的 API 路由,包括 PicoClaw bot 路由。启用鉴权时,客户端必须发送 `Authorization: Bearer `。 +`access_token` 用来保护需要认证的 API 路由,包括 PicoClaw participant bridge 路由。启用鉴权时,客户端必须发送 `Authorization: Bearer `。 `no_auth` 控制 CSGClaw 是否跳过 bearer token 检查,默认值是 `false`。仅建议在可信的本地或开发环境中设置为 `true`。 diff --git a/docs/im-threads.md b/docs/im-threads.md index 46a9a20d..1e94a588 100644 --- a/docs/im-threads.md +++ b/docs/im-threads.md @@ -1,7 +1,7 @@ # CSGClaw IM Threads This document describes the CSGClaw local IM thread model. It is meant for -maintainers who need to understand how thread storage, APIs, bot compatibility, +maintainers who need to understand how thread storage, APIs, participant bridges, agent context, and UI behavior fit together. ## Summary @@ -9,7 +9,7 @@ agent context, and UI behavior fit together. CSGClaw uses an incremental Matrix-shaped thread model inside the existing IM APIs. It does not implement the full Matrix Client-Server protocol today. The goal is to adopt the useful shape of Matrix relationships while preserving the -current CSGClaw room, user, bot, auth, and local state model. +current CSGClaw room, user, participant, auth, and local state model. A thread is a sub-conversation in a room or DM. It starts from one existing top-level message, called the root message. The canonical thread ID is the root @@ -136,17 +136,17 @@ Thread-aware clients should apply `thread.created` and `thread.updated` to the root message summary and thread list, while applying `message.created` to the main timeline only when the message is not a thread reply. -## Bot Compatibility and PicoClaw +## Participant Bridge and PicoClaw -The bot compatibility API is the bridge used by PicoClaw-style integrations and -the Codex bridge: +The participant API is the message bridge used by PicoClaw-style integrations +and the Codex bridge: ```text -GET /api/bots/{id}/events -POST /api/bots/{id}/messages/send +GET /api/v1/channels/csgclaw/participants/{id}/events +POST /api/v1/channels/csgclaw/participants/{id}/messages ``` -Thread-aware bot events may include: +Thread-aware participant events may include: - `thread_root_id`: root message ID when the event is inside a thread. - `thread_context`: hidden context snapshot and summary for the thread root. @@ -155,12 +155,12 @@ Thread-aware bot events may include: `thread_context` is prompt context, not visible thread history. -Bot sends may include either CSGClaw fields (`room_id`, `text`, +Participant sends may include either CSGClaw fields (`room_id`, `text`, `thread_root_id`) or PicoClaw outbound fields (`chat_id`, `content`, `context.topic_id`). When a thread root/topic is present, the message is sent as -a reply in that thread. Bot sends that omit `thread_root_id`, `topic_id`, and +a reply in that thread. Participant sends that omit `thread_root_id`, `topic_id`, and `context.topic_id` are treated as top-level room/DM messages; CSGClaw does not -infer a thread from the bot's most recent room event. +infer a thread from the participant's most recent room event. This maps to PicoClaw/topic isolation requirements: a runtime should treat `room_id` as the normal conversation key and `room_id:thread_root_id` as the diff --git a/docs/im-threads.zh.md b/docs/im-threads.zh.md index 833a2b6a..955e454d 100644 --- a/docs/im-threads.zh.md +++ b/docs/im-threads.zh.md @@ -1,13 +1,13 @@ # CSGClaw IM Threads 本文说明 CSGClaw 本地 IM 的 thread 设计,方便维护者理解 thread 在存储、 -API、bot 兼容层、agent 上下文和 Web UI 中的协作方式。 +API、participant bridge、agent 上下文和 Web UI 中的协作方式。 ## 摘要 CSGClaw 在现有 IM API 内增量采用 Matrix 形状的 thread 模型,但当前不实现 完整 Matrix Client-Server 协议。这样可以复用 Matrix `m.thread` 关系语义, -同时保留 CSGClaw 现有的 room、user、bot、auth 和本地状态模型。 +同时保留 CSGClaw 现有的 room、user、participant、auth 和本地状态模型。 一个 thread 是 room 或 DM 内的子会话。它从一条已有顶层消息开启,这条消息 称为 root message。规范 thread ID 就是 root message ID。 @@ -127,16 +127,16 @@ Thread-aware 客户端应把 `thread.created` 和 `thread.updated` 应用到 roo message summary 和 thread 列表;处理 `message.created` 时,只有非 thread reply 才进入主时间线。 -## Bot 兼容与 PicoClaw +## Participant bridge 与 PicoClaw -Bot 兼容 API 是 PicoClaw 风格集成和 Codex bridge 使用的消息桥: +Participant API 是 PicoClaw 风格集成和 Codex bridge 使用的消息桥: ```text -GET /api/bots/{id}/events -POST /api/bots/{id}/messages/send +GET /api/v1/channels/csgclaw/participants/{id}/events +POST /api/v1/channels/csgclaw/participants/{id}/messages ``` -Thread-aware bot event 可能包含: +Thread-aware participant event 可能包含: - `thread_root_id`:事件位于 thread 内时的 root message ID。 - `thread_context`:该 thread root 的隐藏上下文快照和 summary。 @@ -145,11 +145,11 @@ Thread-aware bot event 可能包含: `thread_context` 是 prompt context,不是可见 thread 历史。 -Bot send 可以传入 CSGClaw 字段(`room_id`、`text`、`thread_root_id`), +Participant send 可以传入 CSGClaw 字段(`room_id`、`text`、`thread_root_id`), 也可以传入 PicoClaw outbound 字段(`chat_id`、`content`、 `context.topic_id`)。存在 thread root/topic 时,消息会作为该 thread 内的 -reply 发送。如果 bot send 同时省略 `thread_root_id`、`topic_id` 和 -`context.topic_id`,CSGClaw 会按 room/DM 顶层消息处理,不会根据该 bot 在 +reply 发送。如果 participant send 同时省略 `thread_root_id`、`topic_id` 和 +`context.topic_id`,CSGClaw 会按 room/DM 顶层消息处理,不会根据该 participant 在 房间中最近收到的事件推断 thread。 这对应 PicoClaw/topic 隔离需求:runtime 应把 `room_id` 视为普通会话 key, diff --git a/docs/im/im-chat-agent-history-cleanup.en.md b/docs/im/im-chat-agent-history-cleanup.en.md index d7289275..03c566f2 100644 --- a/docs/im/im-chat-agent-history-cleanup.en.md +++ b/docs/im/im-chat-agent-history-cleanup.en.md @@ -155,8 +155,8 @@ flowchart LR ChannelRecord --> Glue["Channel event glue"] Glue --> AgentSvc["internal/agent.Service.NewConversationAction"] AgentSvc --> Runtime["runtime ConversationStarter capability"] - Runtime --> Pico["PicoClaw BotEvent.Text = /clear"] - Runtime --> Open["OpenClaw BotEvent.Text = /new"] + Runtime --> Pico["PicoClaw ParticipantEvent.Text = /clear"] + Runtime --> Open["OpenClaw ParticipantEvent.Text = /new"] ``` CSGClaw local channel responsibilities: @@ -166,7 +166,7 @@ CSGClaw local channel responsibilities: - Do not treat `new` as a normal skill in the IM layer. - The CSGClaw channel normalizes user input into canonical slash form and sends messages through the existing channel/event flow. - Channel event glue recognizes canonical `/new` and calls `internal/agent.Service.NewConversationAction` to get the target runtime action. -- For PicoClaw/OpenClaw, output a BotEvent invocation and deliver it through the existing BotEvent protocol. +- For PicoClaw/OpenClaw, output a ParticipantEvent invocation and deliver it through the existing ParticipantEvent protocol. - Codex only responds to `/new` through the CSGClaw local channel. This document does not design an external channel or external Codex CLI integration. Add the Agent service use-case data structures: @@ -250,11 +250,11 @@ Module boundaries: - `internal/channel/csgclaw`: channel ingress/egress adaptation, mention/room parsing, and no runtime command mapping. - `internal/channel/feishu`: Feishu configuration, platform message send/query, fallback rendering, and internal MessageBus/SSE bridge only. It does not parse or normalize user-side slash input, does not recognize Agent `/new` reset, and does not maintain runtime-native command mappings. - `internal/im`: store CSGClaw local-channel messages, rooms, and threads only. It does not know runtime-native commands. -- `internal/agent.Service`: find agent/runtime/handle by bot id and call the runtime `ConversationStarter` capability. -- `internal/api` channel event glue: before delivering through the CSGClaw BotBridge, recognize canonical `/new` and write the Agent service action back into the existing event path. +- `internal/agent.Service`: find agent/runtime/handle by participant or bridge target ID and call the runtime `ConversationStarter` capability. +- `internal/api` channel event glue: before delivering through the CSGClaw participant event bridge, recognize canonical `/new` and write the Agent service action back into the existing event path. - PicoClaw/OpenClaw runtime: execute only their own native command or internal cleanup interface. -CSGClaw invokes Agent slash commands through the existing bot/event protocol, not through a new RPC: +CSGClaw invokes Agent slash commands through the existing participant event bridge, not through a new RPC: ```mermaid flowchart LR @@ -264,13 +264,13 @@ flowchart LR Capability --> Native{"Runtime action"} Native --> PicoCmd["PicoClaw: /clear"] Native --> OpenCmd["OpenClaw: /new"] - PicoCmd --> BotEvent["BotEvent.Text first token is /clear"] - OpenCmd --> OpenBotEvent["BotEvent.Text first token is /new"] - BotEvent --> Executor["Agent runtime command executor"] - OpenBotEvent --> Executor + PicoCmd --> ParticipantEvent["ParticipantEvent.Text first token is /clear"] + OpenCmd --> OpenParticipantEvent["ParticipantEvent.Text first token is /new"] + ParticipantEvent --> Executor["Agent runtime command executor"] + OpenParticipantEvent --> Executor ``` -To make runtime-native commands recognizable, the delivered `BotEvent.Text` must start with the native slash command as the first token. Do not put `` before the command, and do not send canonical XML directly to PicoClaw/OpenClaw expecting them to recognize it. +To make runtime-native commands recognizable, the delivered `ParticipantEvent.Text` must start with the native slash command as the first token. Do not put `` before the command, and do not send canonical XML directly to PicoClaw/OpenClaw expecting them to recognize it. #### 2.5.1 CSGClaw Local Channel Entry Point @@ -280,25 +280,25 @@ Current CSGClaw local channel message flow: internal/api.handleCreateMessage -> internal/channel/csgclaw.Service.SendMessage -> internal/im.Service.CreateMessage --> internal/api.Handler.PublishBotEvent --> internal/im.BotBridge.PublishMessageEvent --> /api/bots/{botID}/events +-> internal/api.Handler.PublishParticipantEvent +-> internal/im.ParticipantBridge.PublishMessageEvent +-> /api/v1/channels/csgclaw/participants/{participantID}/events ``` For `/new`: 1. `internal/channel/csgclaw.Service.SendMessage` continues to only canonical-normalize and write to `internal/im`. -2. `internal/im.BotBridge` continues to only queue events and deliver SSE. It does not query runtimes or maintain runtime-native command mappings. -3. Near `internal/api.Handler.PublishBotEvent`, detect whether `evt.Message.Content` is canonical `new conversation`. -4. If matched, call `agent.Service.NewConversationAction` for each bot that should actually be notified. -5. For PicoClaw/OpenClaw, replace `im.BotEvent.Text` with the runtime-native command `/clear` or `/new`. Keep the other room/thread/context fields produced by BotBridge. +2. `internal/im.ParticipantBridge` continues to only queue events and deliver SSE. It does not query runtimes or maintain runtime-native command mappings. +3. Near `internal/api.Handler.PublishParticipantEvent`, detect whether `evt.Message.Content` is canonical `new conversation`. +4. If matched, call `agent.Service.NewConversationAction` for each target Agent that should actually be notified. +5. For PicoClaw/OpenClaw, replace `im.ParticipantEvent.Text` with the runtime-native command `/clear` or `/new`. Keep the other room/thread/context fields produced by ParticipantBridge. 6. For Codex, only use `/new` through the CSGClaw local channel. Do not integrate through an external channel or external CLI here. -Note: `BotBridge` currently notifies by room membership, and `shouldNotifyBot` does not require mention. The `/new` implementation must tighten routing semantics: in a direct room with an Agent, mention is not required; in a group chat, `@agent` is required. If no Agent is mentioned, no cleanup is executed, and cleanup is not broadcast to all room Agents. API glue should filter targets using message mentions. +Note: `ParticipantBridge` currently notifies by room membership, and `shouldNotifyParticipant` does not require mention. The `/new` implementation must tighten routing semantics: in a direct room with an Agent, mention is not required; in a group chat, `@agent` is required. If no Agent is mentioned, no cleanup is executed, and cleanup is not broadcast to all room Agents. API glue should filter participant bridge targets using message mentions. Feishu notes: -- CSGClaw's `/api/v1/channels/feishu/bots/{botID}/events` is an internal SSE bridge, not a Feishu Open Platform inbound webhook. +- CSGClaw's `/api/v1/channels/feishu/participants/{participantID}/events` is an internal SSE bridge, not a Feishu Open Platform inbound webhook. - Real Feishu inbound messages are currently handled by the runtime's own Feishu/Lark channel. CSGClaw server does not translate `/new` to `/clear` on that path. - If users talk directly to PicoClaw's Feishu channel, history cleanup should use PicoClaw's native `/clear` command, or PicoClaw itself should decide whether to support an additional alias. @@ -325,36 +325,36 @@ PicoClaw already has an internal cleanup command: PicoClaw integration through the CSGClaw local channel: 1. CSGClaw Web/API normalizes `/new` into canonical slash. -2. The CSGClaw runtime slash adapter recognizes that the target bot is PicoClaw sandbox. +2. The CSGClaw runtime slash adapter recognizes that the target Agent uses PicoClaw sandbox. 3. The adapter maps the canonical command to the PicoClaw native command: ```text /clear ``` -4. `internal/im.BotBridge` or a dedicated Agent slash dispatcher delivers a BotEvent to the target PicoClaw bot. -5. PicoClaw subscribes through the CSGClaw bot compatibility protocol and receives the message event. +4. `internal/im.ParticipantBridge` or a dedicated Agent slash dispatcher delivers a ParticipantEvent to the target PicoClaw participant bridge. +5. PicoClaw subscribes through the CSGClaw participant bridge protocol and receives the message event. 6. PicoClaw command executor recognizes `/clear` before entering the LLM. 7. PicoClaw computes its own session key from event context: - `roomID` -8. PicoClaw clears this bot's internal conversation history for the current conversation. -9. PicoClaw replies through the CSGClaw bot compatibility protocol. +8. PicoClaw clears this Agent's internal conversation history for the current conversation. +9. PicoClaw replies through the CSGClaw participant bridge protocol. CSGClaw and PicoClaw communicate through HTTP/SSE: ```http -GET /api/bots/{botID}/events +GET /api/v1/channels/csgclaw/participants/{participantID}/events ``` - PicoClaw connects to CSGClaw using `CSGCLAW_BASE_URL` or `PICOCLAW_CHANNELS_CSGCLAW_BASE_URL`. - Requests include `Authorization: Bearer `. - CSGClaw returns `text/event-stream`; event name is `message`. -- Event data is `im.BotEvent`, containing `channel=csgclaw`, `room_id`, `chat_id`, `thread_root_id`, `text`, `context`, and `thread_context`. Thread fields are pass-through context and do not affect cleanup scope. +- Event data is `im.ParticipantEvent`, containing `channel=csgclaw`, `room_id`, `chat_id`, `thread_root_id`, `text`, `context`, and `thread_context`. Thread fields are pass-through context and do not affect cleanup scope. PicoClaw replies through: ```http -POST /api/bots/{botID}/messages/send +POST /api/v1/channels/csgclaw/participants/{participantID}/messages ``` Request body: @@ -367,7 +367,7 @@ Request body: } ``` -Important BotEvent fields delivered by CSGClaw to PicoClaw: +Important ParticipantEvent fields delivered by CSGClaw to PicoClaw: ```text text = "/clear" @@ -376,12 +376,12 @@ room_id = current room chat_id = current room thread_root_id = current thread root, optional pass-through and not part of cleanup scope context.channel = "csgclaw" -context.account = bot_id +context.account = participant_id context.chat_id = current room context.topic_id = current thread root, optional pass-through and not part of cleanup scope ``` -Therefore PicoClaw does not need a new standalone cleanup command for the current CSGClaw local channel capability. CSGClaw maps local-channel user-facing `/new` to PicoClaw native `/clear` and keeps BotEvent context pointing at the current room. This mapping does not cover PicoClaw's direct Feishu/Lark channel. +Therefore PicoClaw does not need a new standalone cleanup command for the current CSGClaw local channel capability. CSGClaw maps local-channel user-facing `/new` to PicoClaw native `/clear` and keeps ParticipantEvent context pointing at the current room. This mapping does not cover PicoClaw's direct Feishu/Lark channel. ### 2.7 OpenClaw Integration Plan @@ -396,20 +396,20 @@ CSGCLAW_BOT_ID OpenClaw integration: 1. CSGClaw parses the user-facing canonical slash command. -2. The runtime slash adapter recognizes that the target bot is OpenClaw sandbox. +2. The runtime slash adapter recognizes that the target Agent uses OpenClaw sandbox. 3. The adapter maps the canonical command to the OpenClaw native command: ```text /new ``` -4. `internal/im.BotBridge` or a dedicated Agent slash dispatcher delivers a BotEvent to the target OpenClaw bot. -5. OpenClaw receives the message event through the CSGClaw bot compatibility HTTP/SSE protocol. +4. `internal/im.ParticipantBridge` or a dedicated Agent slash dispatcher delivers a ParticipantEvent to the target OpenClaw participant bridge. +5. OpenClaw receives the message event through the CSGClaw participant bridge HTTP/SSE protocol. 6. The OpenClaw gateway/channel adapter forwards the event to the OpenClaw runtime. 7. The OpenClaw command executor recognizes `/new` before entering the model and resets the current session in place. -8. OpenClaw replies through `POST /api/bots/{botID}/messages/send`. +8. OpenClaw replies through `POST /api/v1/channels/csgclaw/participants/{participantID}/messages`. -Important BotEvent fields delivered by CSGClaw to OpenClaw: +Important ParticipantEvent fields delivered by CSGClaw to OpenClaw: ```text text = "/new" @@ -418,7 +418,7 @@ room_id = current room chat_id = current room thread_root_id = current thread root, optional pass-through and not part of cleanup scope context.channel = "csgclaw" -context.account = bot_id +context.account = participant_id context.chat_id = current room context.topic_id = current thread root, optional pass-through and not part of cleanup scope ``` @@ -438,7 +438,7 @@ Audit strategy: - IM keeps the user's slash cleanup command and the Agent confirmation message. - Cleared internal history content is not saved. -- Logs should record only bot id, room id, scope, and result. They must not record message content or history content. +- Logs should record only participant or agent id, room id, scope, and result. They must not record message content or history content. ### 2.9 End-to-End Scenarios @@ -472,7 +472,7 @@ Combined use: - Add `room.messages_cleared` SSE to synchronize multi-window state. - Add canonical `new` slash support. - Add optional `ConversationStarter` capability in `internal/runtime`. -- Add `NewConversationAction` use-case method in `internal/agent.Service`, centralizing bot -> agent/runtime/handle lookup and capability invocation. +- Add `NewConversationAction` use-case method in `internal/agent.Service`, centralizing participant/bridge target -> agent/runtime/handle lookup and capability invocation. - CSGClaw local channel recognizes canonical `/new` in the existing event glue and calls the Agent service use case. Do not add an `internal/channel/agentslash` package. - Feishu channel does not participate in Agent `/new` reset. `handleFeishuEvents` only filters MessageBus events by Feishu mention and forwards them as-is. - Codex is only used through `/new` in the CSGClaw local channel and is not listed as an independent integration item. diff --git a/docs/im/im-chat-agent-history-cleanup.zh.md b/docs/im/im-chat-agent-history-cleanup.zh.md index 66705298..546e6c67 100644 --- a/docs/im/im-chat-agent-history-cleanup.zh.md +++ b/docs/im/im-chat-agent-history-cleanup.zh.md @@ -155,8 +155,8 @@ flowchart LR ChannelRecord --> Glue["channel event glue"] Glue --> AgentSvc["internal/agent.Service.NewConversationAction"] AgentSvc --> Runtime["runtime ConversationStarter capability"] - Runtime --> Pico["PicoClaw BotEvent.Text = /clear"] - Runtime --> Open["OpenClaw BotEvent.Text = /new"] + Runtime --> Pico["PicoClaw ParticipantEvent.Text = /clear"] + Runtime --> Open["OpenClaw ParticipantEvent.Text = /new"] ``` CSGClaw 本地 channel 责任: @@ -166,7 +166,7 @@ CSGClaw 本地 channel 责任: - 不在 IM 层把 `new` 当作普通 skill。 - CSGClaw channel 负责把用户输入归一化为 canonical slash,并把消息送入现有 channel/event 链路。 - 在 channel event glue 中识别 canonical `/new`,调用 `internal/agent.Service.NewConversationAction` 获取目标 runtime action。 -- 对 PicoClaw/OpenClaw runtime 输出 BotEvent invocation,再走现有 BotEvent 协议投递。 +- 对 PicoClaw/OpenClaw runtime 输出 ParticipantEvent invocation,再走现有 ParticipantEvent 协议投递。 - Codex 仅在 CSGClaw 本地 channel 中响应 `/new`;不在本文设计外部 channel 或外部 Codex CLI 对接。 新增 agent service use-case 数据结构: @@ -250,11 +250,11 @@ type ConversationStarter interface { - `internal/channel/csgclaw`:只负责 channel 入站/出站适配、mention/room 解析,不维护 runtime 命令映射。 - `internal/channel/feishu`:只负责 Feishu 配置、平台消息发送/查询、fallback 展示和内部 MessageBus/SSE bridge;不解析或归一化用户侧 slash 输入,不识别 agent `/new` reset,不维护 runtime 原生命令映射。 - `internal/im`:只保存 CSGClaw 本地 channel 的消息、room、thread,不感知 runtime 原生命令。 -- `internal/agent.Service`:负责根据 bot id 找 agent/runtime/handle,并调用 runtime `ConversationStarter` capability。 -- `internal/api` 的 channel event glue:在 CSGClaw BotBridge 投递给 bot 前识别 canonical `/new`,并把 agent service 返回的 action 写回现有事件路径。 +- `internal/agent.Service`:负责根据 participant 或 bridge target ID 找 agent/runtime/handle,并调用 runtime `ConversationStarter` capability。 +- `internal/api` 的 channel event glue:在 CSGClaw participant event bridge 投递前识别 canonical `/new`,并把 agent service 返回的 action 写回现有事件路径。 - PicoClaw/OpenClaw runtime:只执行自己的原生命令或内部清理接口。 -CSGClaw 调用 Agent slash 的方式不是新增 RPC,而是复用现有 bot/event 协议: +CSGClaw 调用 Agent slash 的方式不是新增 RPC,而是复用现有 participant event bridge: ```mermaid flowchart LR @@ -264,13 +264,13 @@ flowchart LR Capability --> Native{"runtime action"} Native --> PicoCmd["PicoClaw: /clear"] Native --> OpenCmd["OpenClaw: /new"] - PicoCmd --> BotEvent["BotEvent.Text 首 token 是 /clear"] - OpenCmd --> OpenBotEvent["BotEvent.Text 首 token 是 /new"] - BotEvent --> Executor["Agent runtime command executor"] - OpenBotEvent --> Executor + PicoCmd --> ParticipantEvent["ParticipantEvent.Text 首 token 是 /clear"] + OpenCmd --> OpenParticipantEvent["ParticipantEvent.Text 首 token 是 /new"] + ParticipantEvent --> Executor["Agent runtime command executor"] + OpenParticipantEvent --> Executor ``` -为了让原生命令被 runtime 识别,投递给 Agent 的 `BotEvent.Text` 必须以原生 slash 命令作为第一个 token。不能把 `` mention 放在命令前面,也不能把 canonical XML 直接投给 PicoClaw/OpenClaw 期待它识别原生命令。 +为了让原生命令被 runtime 识别,投递给 Agent 的 `ParticipantEvent.Text` 必须以原生 slash 命令作为第一个 token。不能把 `` mention 放在命令前面,也不能把 canonical XML 直接投给 PicoClaw/OpenClaw 期待它识别原生命令。 #### 2.5.1 CSGClaw 本地 channel 落点 @@ -280,25 +280,25 @@ flowchart LR internal/api.handleCreateMessage -> internal/channel/csgclaw.Service.SendMessage -> internal/im.Service.CreateMessage --> internal/api.Handler.PublishBotEvent --> internal/im.BotBridge.PublishMessageEvent --> /api/bots/{botID}/events +-> internal/api.Handler.PublishParticipantEvent +-> internal/im.ParticipantBridge.PublishMessageEvent +-> /api/v1/channels/csgclaw/participants/{participantID}/events ``` 实现 `/new` 时: 1. `internal/channel/csgclaw.Service.SendMessage` 继续只做 canonical normalize 与 `internal/im` 写入。 -2. `internal/im.BotBridge` 继续只负责事件排队与 SSE 投递,不查询 runtime,也不维护 runtime 原生命令映射。 -3. 在 `internal/api.Handler.PublishBotEvent` 附近识别 `evt.Message.Content` 是否为 canonical `new conversation`。 -4. 命中后,对每个实际要通知的 bot 调用 `agent.Service.NewConversationAction`。 -5. 对 PicoClaw/OpenClaw,把投递给 bot 的 `im.BotEvent.Text` 替换成 runtime 原生命令 `/clear` 或 `/new`,其余 room/thread/context 字段仍复用 `BotBridge` 构造逻辑。 +2. `internal/im.ParticipantBridge` 继续只负责事件排队与 SSE 投递,不查询 runtime,也不维护 runtime 原生命令映射。 +3. 在 `internal/api.Handler.PublishParticipantEvent` 附近识别 `evt.Message.Content` 是否为 canonical `new conversation`。 +4. 命中后,对每个实际要通知的目标 Agent 调用 `agent.Service.NewConversationAction`。 +5. 对 PicoClaw/OpenClaw,把投递给目标 bridge 的 `im.ParticipantEvent.Text` 替换成 runtime 原生命令 `/clear` 或 `/new`,其余 room/thread/context 字段仍复用 `ParticipantBridge` 构造逻辑。 6. 对 Codex,仅在 CSGClaw 本地 channel 使用 `/new`,不通过外部 channel 或外部 CLI 对接。 -注意:`BotBridge` 当前按 room member 通知 bot,且 `shouldNotifyBot` 不要求 mention。`/new` 的实现必须收紧路由语义:在直接对某个 agent 的 room 中可以不带 mention 生效;在群聊中必须 `@agent`,未 mention 不执行清理,且只对被 mention 的目标 agent 生效。API glue 层需要用 message mentions 过滤目标 bot,避免广播给所有 room member。 +注意:`ParticipantBridge` 当前按 room member 通知目标 bridge,且 `shouldNotifyParticipant` 不要求 mention。`/new` 的实现必须收紧路由语义:在直接对某个 agent 的 room 中可以不带 mention 生效;在群聊中必须 `@agent`,未 mention 不执行清理,且只对被 mention 的目标 agent 生效。API glue 层需要用 message mentions 过滤 participant bridge target,避免广播给所有 room member。 Feishu 说明: -- CSGClaw 的 `/api/v1/channels/feishu/bots/{botID}/events` 是内部 SSE bridge,不是飞书开放平台入站 webhook。 +- CSGClaw 的 `/api/v1/channels/feishu/participants/{participantID}/events` 是内部 SSE bridge,不是飞书开放平台入站 webhook。 - 当前 Feishu 真实入站由 runtime 自己的 Feishu/Lark channel 处理,CSGClaw server 不在该路径中把 `/new` 翻译成 `/clear`。 - 如果用户直接在 PicoClaw 的 Feishu channel 对话,清理历史应使用 PicoClaw 原生命令 `/clear`,或者由 PicoClaw 自身决定是否支持额外别名。 @@ -325,36 +325,36 @@ PicoClaw 已有内部清理命令: PicoClaw 通过 CSGClaw 本地 channel 的对接方式: 1. CSGClaw Web/API 将 `/new` 归一化成 canonical slash。 -2. CSGClaw runtime slash adapter 识别目标 bot 是 PicoClaw sandbox。 +2. CSGClaw runtime slash adapter 识别目标 Agent 使用 PicoClaw sandbox。 3. adapter 将 canonical command 映射成 PicoClaw 原生命令: ```text /clear ``` -4. `internal/im.BotBridge` 或专门的 agent slash dispatcher 向目标 PicoClaw bot 投递 BotEvent。 -5. PicoClaw 通过 CSGClaw bot compatibility 协议订阅并收到 message event。 +4. `internal/im.ParticipantBridge` 或专门的 agent slash dispatcher 向目标 PicoClaw participant bridge 投递 ParticipantEvent。 +5. PicoClaw 通过 CSGClaw participant bridge 协议订阅并收到 message event。 6. PicoClaw command executor 在进入 LLM 前识别 `/clear`。 7. PicoClaw 根据 event context 计算自身 session key: - `roomID` -8. PicoClaw 清理该 bot 当前 conversation 的内部历史。 -9. PicoClaw 通过 CSGClaw bot compatibility 协议回一条确认消息。 +8. PicoClaw 清理该 Agent 当前 conversation 的内部历史。 +9. PicoClaw 通过 CSGClaw participant bridge 协议回一条确认消息。 CSGClaw 和 PicoClaw 的协议是 HTTP/SSE: ```http -GET /api/bots/{botID}/events +GET /api/v1/channels/csgclaw/participants/{participantID}/events ``` - PicoClaw 使用 `CSGCLAW_BASE_URL` 或 `PICOCLAW_CHANNELS_CSGCLAW_BASE_URL` 连接 CSGClaw。 - 请求带 `Authorization: Bearer `。 - CSGClaw 返回 `text/event-stream`,事件名是 `message`。 -- 事件 data 是 `im.BotEvent`,包含 `channel=csgclaw`、`room_id`、`chat_id`、`thread_root_id`、`text`、`context`、`thread_context`,其中线程相关字段仅用于透传,不参与清理范围判断。 +- 事件 data 是 `im.ParticipantEvent`,包含 `channel=csgclaw`、`room_id`、`chat_id`、`thread_root_id`、`text`、`context`、`thread_context`,其中线程相关字段仅用于透传,不参与清理范围判断。 PicoClaw 回消息使用: ```http -POST /api/bots/{botID}/messages/send +POST /api/v1/channels/csgclaw/participants/{participantID}/messages ``` 请求体: @@ -367,7 +367,7 @@ POST /api/bots/{botID}/messages/send } ``` -CSGClaw 投递给 PicoClaw 的 BotEvent 关键字段: +CSGClaw 投递给 PicoClaw 的 ParticipantEvent 关键字段: ```text text = "/clear" @@ -376,12 +376,12 @@ room_id = 当前 room chat_id = 当前 room thread_root_id = 当前 thread root,可为空(保留透传,不影响清理范围) context.channel = "csgclaw" -context.account = bot_id +context.account = participant_id context.chat_id = 当前 room context.topic_id = 当前 thread root,可为空(保留透传,不影响清理范围) ``` -因此 PicoClaw 不需要新增独立清理命令才能完成 CSGClaw 本地 channel 的当前能力。CSGClaw 要做的是把本地 channel 用户侧 `/new` 映射为 PicoClaw 原生 `/clear`,并确保 BotEvent 的上下文仍指向当前 room。这个映射不覆盖 PicoClaw 直连 Feishu/Lark channel。 +因此 PicoClaw 不需要新增独立清理命令才能完成 CSGClaw 本地 channel 的当前能力。CSGClaw 要做的是把本地 channel 用户侧 `/new` 映射为 PicoClaw 原生 `/clear`,并确保 ParticipantEvent 的上下文仍指向当前 room。这个映射不覆盖 PicoClaw 直连 Feishu/Lark channel。 ### 2.7 OpenClaw 对接方案 @@ -396,20 +396,20 @@ CSGCLAW_BOT_ID OpenClaw 对接方式: 1. CSGClaw 解析用户侧 canonical slash。 -2. runtime slash adapter 识别目标 bot 是 OpenClaw sandbox。 +2. runtime slash adapter 识别目标 Agent 使用 OpenClaw sandbox。 3. adapter 将 canonical command 映射成 OpenClaw 原生命令: ```text /new ``` -4. `internal/im.BotBridge` 或专门的 agent slash dispatcher 向目标 OpenClaw bot 投递 BotEvent。 -5. OpenClaw 通过 CSGClaw bot compatibility HTTP/SSE 协议接收 message event。 +4. `internal/im.ParticipantBridge` 或专门的 agent slash dispatcher 向目标 OpenClaw participant bridge 投递 ParticipantEvent。 +5. OpenClaw 通过 CSGClaw participant bridge HTTP/SSE 协议接收 message event。 6. OpenClaw gateway/channel adapter 将 event 交给 OpenClaw runtime。 7. OpenClaw command executor 在进入模型前识别 `/new`,原地 reset 当前 session。 -8. OpenClaw 通过 `POST /api/bots/{botID}/messages/send` 回确认。 +8. OpenClaw 通过 `POST /api/v1/channels/csgclaw/participants/{participantID}/messages` 回确认。 -CSGClaw 投递给 OpenClaw 的 BotEvent 关键字段: +CSGClaw 投递给 OpenClaw 的 ParticipantEvent 关键字段: ```text text = "/new" @@ -418,7 +418,7 @@ room_id = 当前 room chat_id = 当前 room thread_root_id = 当前 thread root,可为空(保留透传,不影响清理范围) context.channel = "csgclaw" -context.account = bot_id +context.account = participant_id context.chat_id = 当前 room context.topic_id = 当前 thread root,可为空(保留透传,不影响清理范围) ``` @@ -438,7 +438,7 @@ OpenClaw 官方文档要求 slash command 是以 `/` 开头的 standalone messag - IM 中保留用户发出的 slash 清理命令和 Agent 的确认消息。 - 不保存被清理的内部历史内容。 -- 日志只记录 bot id、room id、scope、结果,不记录消息正文或历史内容。 +- 日志只记录 participant 或 agent id、room id、scope、结果,不记录消息正文或历史内容。 ### 2.9 端到端场景 @@ -472,7 +472,7 @@ Agent slash 清理: - 新增 `room.messages_cleared` SSE,同步多窗口状态。 - 新增 `new` canonical slash 支持。 - 在 `internal/runtime` 增加 `ConversationStarter` optional capability。 -- 在 `internal/agent.Service` 增加 `NewConversationAction` use-case 方法,集中完成 bot -> agent/runtime/handle 查找与 capability 调用。 +- 在 `internal/agent.Service` 增加 `NewConversationAction` use-case 方法,集中完成 participant/bridge target -> agent/runtime/handle 查找与 capability 调用。 - CSGClaw 本地 channel 在现有 event glue 中识别 canonical `/new`,再调用 agent service use-case;不新增 `internal/channel/agentslash` 包。 - Feishu channel 不参与 agent `/new` reset;`handleFeishuEvents` 只按 Feishu mention 过滤 MessageBus 事件并原样转发。 - Codex 仅通过 CSGClaw 本地 channel 的 `/new` 使用,不列为独立对接项。 diff --git a/docs/im/im-chat-room-cleanup.en.md b/docs/im/im-chat-room-cleanup.en.md index 10d42a71..40b9819f 100644 --- a/docs/im/im-chat-room-cleanup.en.md +++ b/docs/im/im-chat-room-cleanup.en.md @@ -11,7 +11,7 @@ This plan keeps the two requirements separate: Non-goals: -- UI chat cleanup does not delete the room, members, users, or bots. +- UI chat cleanup does not delete the room, members, users, participants, or agents. - UI chat cleanup does not directly delete PicoClaw, OpenClaw, or Codex internal memory, sessions, workspace memory, or files. - Agent slash cleanup does not clear IM room messages in reverse, unless a combined operation is designed separately later. @@ -63,7 +63,7 @@ Main write scenarios: | `CreateRoom` | Creates a room and appends a `room_created` event message | | `AddRoomMembers` | Updates members and appends a `room_members_added` event message | | `CreateMessage` | Appends a user or frontend message to `room.Messages` | -| `DeliverMessage` | Appends or overwrites a bot/runtime message idempotently by message ID | +| `DeliverMessage` | Appends or overwrites an agent/runtime message idempotently by message ID | | `StartThread` | Creates `ThreadState` for the root message and saves the thread context snapshot | | `DeleteRoom` | Saves state after deleting the room, and `cleanupSessionFiles` removes JSONL and blob files that are no longer referenced | @@ -131,7 +131,7 @@ Response: { "id": "room-123", "title": "general", - "members": ["u-admin", "u-manager"], + "members": ["admin", "manager"], "messages": [], "threads": [] } @@ -154,7 +154,7 @@ No dedicated response DTO is added. The HTTP response directly returns the clear Layering: - The HTTP layer parses `roomID` from the URL and directly calls local `internal/im.Service.ClearRoomMessages(roomID)`. -- `internal/channel/csgclaw.Service` remains responsible for CSGClaw channel adaptation, such as bot ID/user ID conversion, slash content normalization, and future permission checks, but it does not own the ability to clear IM room messages. +- `internal/channel/csgclaw.Service` remains responsible for CSGClaw channel adaptation, such as participant/user ID conversion, slash content normalization, and future permission checks, but it does not own the ability to clear IM room messages. - `internal/im.Service` handles local IM room/message data and owns the full "clear, persist, publish domain event" operation boundary. The current message sending path uses channel adaptation: @@ -208,7 +208,7 @@ room.Threads = []RoomThread{} This clears mainline messages, thread roots, thread replies, thread state summaries, and thread context snapshots. Persistence truncates `sessions/.jsonl` and removes `sessions/blobs//`; no second cleanup flow is needed for threads. -Cleanup semantics are bounded by the room messages that have been persisted at call time. Messages delivered after cleanup may appear. For example, if a bot/runtime reply was already triggered before cleanup but only arrives through `DeliverMessage` after cleanup finishes, it can continue to appear as a new room message. This API does not attempt to cancel or filter such in-flight replies. +Cleanup semantics are bounded by the room messages that have been persisted at call time. Messages delivered after cleanup may appear. For example, if an agent/runtime reply was already triggered before cleanup but only arrives through `DeliverMessage` after cleanup finishes, it can continue to appear as a new room message. This API does not attempt to cancel or filter such in-flight replies. ```mermaid flowchart LR @@ -298,7 +298,7 @@ api.Handler internal/channel/csgclaw.Service - channel adapter for channel-owned operations - - bot/user id normalization if needed + - participant/user ID normalization if needed - delegate to im.Service internal/im.Service diff --git a/docs/im/im-chat-room-cleanup.zh.md b/docs/im/im-chat-room-cleanup.zh.md index f1681068..db274fcc 100644 --- a/docs/im/im-chat-room-cleanup.zh.md +++ b/docs/im/im-chat-room-cleanup.zh.md @@ -11,7 +11,7 @@ Issue 2219 要求在 CSGClaw IM 的房间工具中支持清空聊天记录。关 非目标: -- UI 清空聊天记录不删除 room,不删除 members,不删除 users,不删除 bots。 +- UI 清空聊天记录不删除 room,不删除 members,不删除 users,不删除 participants 或 agents。 - UI 清空聊天记录不直接删除 PicoClaw、OpenClaw、Codex 的内部记忆、会话、workspace memory 或文件。 - Agent slash 清理不反向清空 IM 房间消息,除非后续单独设计组合操作。 @@ -63,7 +63,7 @@ Issue 2219 要求在 CSGClaw IM 的房间工具中支持清空聊天记录。关 | `CreateRoom` | 创建 room,并追加 `room_created` event message | | `AddRoomMembers` | 修改 members,并追加 `room_members_added` event message | | `CreateMessage` | 用户或前端发送消息,追加到 `room.Messages` | -| `DeliverMessage` | Bot/runtime 回写消息,按 message id 幂等追加或覆盖 | +| `DeliverMessage` | Agent/runtime 回写消息,按 message id 幂等追加或覆盖 | | `StartThread` | 给 root message 创建 `ThreadState`,并保存 thread context snapshot | | `DeleteRoom` | 删除 room 后保存 state,`cleanupSessionFiles` 清理不再被引用的 JSONL 和 blob | @@ -131,7 +131,7 @@ POST /api/v1/channels/{channel}/rooms/{room}:clearMessages { "id": "room-123", "title": "general", - "members": ["u-admin", "u-manager"], + "members": ["admin", "manager"], "messages": [], "threads": [] } @@ -154,7 +154,7 @@ POST /api/v1/channels/{channel}/rooms/{room}:clearMessages 接口分层如下: - HTTP 层从 URL 解析出 `roomID`,直接调用本地 `internal/im.Service.ClearRoomMessages(roomID)`。 -- `internal/channel/csgclaw.Service` 继续负责 CSGClaw channel 适配,例如 bot id/user id 转换、slash 内容归一化、后续权限校验,但不承载清空 IM room 消息的能力。 +- `internal/channel/csgclaw.Service` 继续负责 CSGClaw channel 适配,例如 participant/user ID 转换、slash 内容归一化、后续权限校验,但不承载清空 IM room 消息的能力。 - `internal/im.Service` 处理本地 IM room/message 数据,负责“清空、落盘、发布领域事件”的完整操作边界。 当前消息发送链路也是这个模式: @@ -208,7 +208,7 @@ room.Threads = []RoomThread{} 这会同时清理主线消息、thread root、thread reply、thread 状态摘要和 thread context snapshot。落盘时统一截断 `sessions/.jsonl`,删除 `sessions/blobs//`,不再为 thread 单独设计第二套清理流程。 -清空语义以调用时刻已经落盘的 room 消息为边界:清空只删除调用时刻之前已落盘消息,之后到达的消息允许出现。例如清空前已经触发但尚未回写的 bot/runtime reply,如果在清空完成后才通过 `DeliverMessage` 到达,可以作为新的 room 消息继续出现;本接口不尝试取消或过滤这类 in-flight 回复。 +清空语义以调用时刻已经落盘的 room 消息为边界:清空只删除调用时刻之前已落盘消息,之后到达的消息允许出现。例如清空前已经触发但尚未回写的 agent/runtime reply,如果在清空完成后才通过 `DeliverMessage` 到达,可以作为新的 room 消息继续出现;本接口不尝试取消或过滤这类 in-flight 回复。 ```mermaid flowchart LR @@ -298,7 +298,7 @@ api.Handler internal/channel/csgclaw.Service - channel adapter for channel-owned operations - - bot/user id normalization if needed + - participant/user ID normalization if needed - delegate to im.Service internal/im.Service diff --git a/docs/participant-architecture.md b/docs/participant-architecture.md new file mode 100644 index 00000000..a22806b3 --- /dev/null +++ b/docs/participant-architecture.md @@ -0,0 +1,981 @@ +# Participant Identity Architecture and API Plan + +## Quick Review Points + +- The core change is to separate `Participant`, `Agent`, `ChannelUser`, and the + product-facing word `Bot`: participants are the collaboration identities used + by rooms, messages, members, and mentions; agents are runtime execution + entities; channel users are channel-internal identity/profile records; bot is + no longer a backend API or storage model. +- When the UI creates an Agent that should appear in the built-in CSGClaw IM, it + should call `POST /api/v1/channels/csgclaw/participants` with `type=agent` and + `agent_binding.mode=create`. The server provisions the Agent, ChannelUser, and + Participant in one operation. +- For a newly created agent-backed participant, the generated Agent ID keeps the + old relationship: `agent_id = u-{participant_id}`. The participant ID should + come from an explicit `id` or stable key, not from a later-editable `name`. +- Cross-channel reuse no longer depends on equal IDs. One Agent can have many + participants, such as `csgclaw:qa -> agent:u-qa` and + `feishu:test -> agent:u-qa`. +- Mentions belong to the participant identity layer. Feishu human mentions + resolve through `channel_user_ref` plus `channel_app_ref` / + `channel_user_kind`; humans do not need their own bot app/config. +- A notification is a `type=notification` participant representing a webhook, + system event, or pull relay source. It does not bind to an Agent or expose an + LLM bridge by default. +- New message APIs use `mentions` / `mention_ids` arrays; room membership moves + from `user_ids` to `participant_ids` or structured `ParticipantRef` values. +- Deleting a participant deletes only the channel identity binding by default, + not the underlying Agent. Agent cleanup requires explicit semantics such as + `delete_agent=if_unreferenced`. +- This is a coordinated breaking API change across frontend, backend, runtime + bridge, CLI, and embedded templates. API compatibility is not the migration + focus; old bot routes and public `/users` routes do not keep compatibility + aliases. +- Legacy on-disk data such as `bots.json` should still be migratable. If an old + runtime image or template contract is outdated, show a recreate warning in the + UI; the current recreate flow only promises to preserve user-installed skills. +- Matrix alignment is limited to identity, membership, message, mention, and + thread shapes touched by this work. It does not implement a full Matrix + homeserver or Client-Server API. + +## Migration Priorities + +Frontend and backend ship together, so API breaking changes are not the +migration risk. Migration focuses on local on-disk state and old runtime images. + +- **`bots.json`**: legacy bot records must still load and migrate into + participant records. Normal bots become `type=agent` participants while + preserving the original `agent_id` / `channel_user_ref`; notification bots + become `type=notification` participants. +- **IM state**: identity references in `im/state.json` and `im/sessions/*.jsonl` + must migrate from old user/bot IDs to participant IDs, including `users`, room + `members`, message `sender_id`, `mentions`, and thread context. +- **Feishu config**: app/config entries in `channels/feishu.toml` currently keyed + by old `bot_id` must migrate to participant/channel-app semantics so Feishu + sending and mention resolution do not lose configuration. +- **Team state**: `teams/*` fields such as `lead_bot_id`, `member_bot_ids`, + `bot_id`, `actor_id`, `created_by`, `assigned_to`, `requested_by`, and + `approver_id` must migrate to participant IDs. +- **Agents state**: `agents/state.json` does not need Agent ID rewrites; new + participant records keep pointing at existing Agents through `agent_id`. +- **Outdated image warning**: whenever the runtime image/template contract is + detected as outdated, show a recreate warning in the UI. +- **Recreate preserves skills**: the current recreate flow only promises to + preserve user-installed skills; preserving workspace/project state is not + added in this plan. + +## Background + +CSGClaw currently uses `bot`, `agent`, and channel `user` concepts in ways that +work for the first local multi-agent workflow, but do not scale cleanly to +multi-channel and human-in-the-loop collaboration. + +The immediate problem is mention identity. Agents need to mention real people in +the built-in CSGClaw IM, Feishu, and future IM integrations. The current bot +model assumes that message senders and mentions are bot-like identities. In +Feishu, that means a mention is resolved through configured bot app identities, +so an agent cannot cleanly mention a real human open_id. + +There is also a naming and ownership problem. The UI exposes many workflows as +agent management, while parts of the client call channel bot APIs. At the same +time, the underlying runtime agent is already reusable across channels. For +example, a Feishu bot can be modeled as a Feishu channel identity plus an +underlying agent that was originally created from the CSGClaw channel. Today +that reuse depends heavily on matching IDs such as `u-manager`. If the CSGClaw +agent is `u-qa` and the Feishu-facing participant is `test`, the relationship +cannot be represented cleanly. + +The target design separates these concerns: + +- `Participant` is the collaboration identity used by rooms, messages, members, + and mentions. +- `Agent` is the runtime execution entity that owns model, profile, lifecycle, + logs, and sandbox state. +- `Bot` is removed from the API and storage model. It may remain only as UI copy + where the product intentionally says "bot" to a user. + +## Goals + +- Agents can mention real people in CSGClaw IM, Feishu, and future IMs. +- Rooms and messages can include humans, agent-backed participants, and + notification participants with one unified participant reference. +- One underlying agent can be reused by many channel participants without + relying on equal IDs. +- `GET /api/v1/agents` can show all runtime agents and the participants that + expose them in any supported IM, including Feishu. +- The UI can support both creating a new agent-backed participant and adding an + existing agent to another channel without forcing users to understand the + internal model first. +- The frontend, backend, runtime bridge, and embedded templates are updated + together. Existing bot routes are removed rather than kept as old aliases. +- The new shape stays close to Matrix concepts where this work already touches + rooms, members, messages, mentions, and threads. + +## Non-Goals + +- Do not merge the same real person across multiple channels in the first + implementation. A later `Identity` layer can group channel-specific human + participants if product requirements need cross-channel audit or permissions. +- Do not require every participant to have an agent. Humans and notification + participants do not need runtime execution. +- Do not implement a full Matrix homeserver or complete Client-Server API in + this change. Only the identity, membership, message, mention, and thread + shapes touched by this work should be Matrix-friendly. + +## Target Model + +### Agent + +An agent is global to CSGClaw and independent of any channel. + +```text +Agent + id + name + role + runtime_id + runtime_kind + image + runtime_options + status + agent_profile + created_at +``` + +Rules: + +- Agent IDs are global. +- When an agent-backed participant is created and the request does not specify + an Agent ID, the server generates it as `u-{participant_id}`. This preserves + the old worker/bot ID habit; for example participant `qa` maps to agent + `u-qa`. +- The bootstrap manager is the reserved exception: its default CSGClaw + participant ID is `manager`, while its Agent ID remains `u-manager`. +- If the caller specifies an Agent ID explicitly, it must still be globally + unique and does not need to match the participant ID. Cross-channel reuse + usually passes an existing `agent_id`. +- Agent lifecycle operations remain under `/api/v1/agents`. +- Agent profile, model, runtime, logs, start, stop, restart, and recreation stay + owned by the agent service. + +### Participant + +A participant is the identity visible inside one channel. + +```text +Participant + id # stable within its channel + channel # csgclaw | feishu | matrix | ... + type # human | agent | notification + name + avatar + channel_user_ref # csgclaw user id, feishu open_id, matrix user_id, ... + channel_user_kind # local_user_id | open_id | matrix_user_id | ... + channel_app_ref # optional, for bot app/config identity such as Feishu app_id + agent_id # optional FK -> Agent.id, only meaningful for type=agent + lifecycle_status # provisioning | active | disabled | failed + presence # optional channel presence or room member view + mentionable + metadata + created_at + updated_at +``` + +Rules: + +- The canonical participant key is `(channel, id)`. +- `channel_user_ref` is required for participants that can send, receive, or be + mentioned in the channel. +- Active participants should be unique by that channel's channel-user identity. + For simple channels this is `(channel, channel_user_ref)`. For app-scoped + identities such as Feishu, `channel_app_ref` and `channel_user_kind` must also + be part of the unique key. +- `lifecycle_status` describes whether the participant record itself is usable; + `presence` describes the channel or room online/offline view; runtime running + state comes from the bound Agent and should not be persisted on the participant. +- `mentionable=false` means the participant may remain visible in lists or + history, but should not be available as a new mention target. +- `type=human` must not require `agent_id`. +- `type=notification` must not require `agent_id`. +- `type=agent` may bind an existing agent, create a new agent, or be registered + without a runtime binding and bound later. +- A single agent can have many participants across channels: + +```text +csgclaw:qa -> agent:u-qa +feishu:test -> agent:u-qa +matrix:qa-bot -> agent:u-qa +``` + +### Participant ID Generation + +Participant IDs are channel identities that users and the CLI see often, so they +should prefer readable and stable values instead of defaulting to bare UUIDs. +UUIDs can remain the internal random source or fallback, but exposing them +directly makes room membership, mentions, and CLI operations harder to read. + +Participant IDs must not be generated from `name`. `name` is a display name and +may become editable later; once an ID is referenced by room membership, messages, +mentions, agent binding, and CLI commands, it must remain stable. The common +industry pattern is "stable slug plus short random collision suffix", such as +Kubernetes object names or many SaaS workspace slugs. Opaque type-prefixed IDs +such as `usr_...` or `agt_...` fit internal-only objects better than +user-operated participant IDs. + +Recommended algorithm: + +1. If the request explicitly passes `id`, normalize it and verify uniqueness. +2. If no `id` is provided, derive a slug only from stable sources such as a + separate `slug` / `handle` field in the create request, an embedded template + key, a role key, an immutable external channel handle, or a legacy bot/user ID + during migration. Do not use editable display `name`. +3. Slug rules: lowercase; trim surrounding whitespace; replace consecutive + non-`[a-z0-9]` characters with `-`; collapse repeated `-`; trim leading and + trailing `-`; keep length between 3 and 48 characters. +4. If the slug is empty, use a readable type prefix plus a short random suffix, + such as `agent-8f3k2m`, `human-8f3k2m`, or `notification-8f3k2m`. +5. If the slug already exists, append a short random suffix, such as + `qa-8f3k2m`. The suffix can be a truncated base32/base36 value derived from + UUID, ULID, or nanoid. +6. The server returns the final participant ID. Repeated requests with the same + `request_id` or `client_transaction_id` must return the same ID. + +For agent-backed participants, the default Agent ID rule remains: + +```text +agent_id = "u-" + participant_id +``` + +For example, creating a CSGClaw IM Agent as participant `qa` generates agent +`u-qa` by default, while the built-in CSGClaw channel user ref can also remain +`u-qa`. This preserves old runtime, workspace, and mention habits. + +### Channel User / Channel Identity + +The current public `User` model should not remain as a top-level product API +after participants are introduced. It should be replaced by participant APIs. + +A user-like record is still needed internally, but its meaning changes: it is a +channel-scoped identity/profile record owned by the channel adapter, not the +primary collaboration identity. + +```text +ChannelUser + channel # csgclaw | feishu | matrix | ... + ref # csgclaw local user id, Feishu open_id/union_id, Matrix user_id + kind # local_user_id | open_id | matrix_user_id | ... + app_ref # optional, used for Feishu app_id/tenant-scoped identity + display_name + handle + avatar_url + presence + raw_profile + updated_at +``` + +Rules: + +- `Participant.channel_user_ref` points to a channel user or equivalent + channel-native identity. +- For the built-in CSGClaw channel, this internal record replaces the current + `User` storage shape for profile, avatar, handle, and presence. +- For Feishu, this is an adapter-owned record. With `kind=open_id`, `ref` is an + app-scoped open_id and must be interpreted together with `app_ref`. If later + implementations use `union_id` or `user_id`, that choice must be explicit in + `kind`. +- For Matrix, this maps naturally to Matrix user IDs and member profile fields + such as `displayname`, `avatar_url`, and membership state. +- Public clients should create, list, update, and delete people through + participants. They should not call a separate `/users` API. + +### Feishu Identity Scope + +Feishu user identity cannot be modeled as a bare `open_id` string. The same real +person can have different IDs across apps, tenants, or identity types, and the +same string must not be reused across scopes implicitly. + +Rules: + +- `channel_app_ref` identifies the Feishu app/config for this participant, for + example `cli_xxx`. +- With `channel_user_kind=open_id`, `channel_user_ref` is the open_id in that app + scope. The unique key should include at least + `(channel, channel_app_ref, channel_user_kind, channel_user_ref)`. +- `type=agent` participants need `channel_app_ref` so the sender credentials are + unambiguous. +- `type=human` participants use `channel_user_ref` as the mention target and do + not require that human to have a bot app/config. +- If an adapter receives only `user_id`, `union_id`, or another identity from a + Feishu event, it should store that raw identity with an explicit + `channel_user_kind` first, then resolve it explicitly when sending or + mentioning. It must not treat the value as a bot ID implicitly. + +### Notification Participant + +A notification is also a channel participant, but by default it is not a runtime +agent. + +```text +Participant(type=notification) + channel_user_ref # notification sender identity or local webhook identity + channel_app_ref # optional, app/config needed by an external channel + metadata.notification # webhook, remote_pull, subscription, delivery config +``` + +Rules: + +- Notification participants can send room messages so system notifications, + third-party webhooks, or pull relays have a visible source. +- Notification participants do not have `agent_id` by default and do not expose an + LLM bridge. +- Notification webhook/pull config belongs on participant metadata or a dedicated + notification profile, not on a bot-shaped API. +- The old `POST /api/v1/channels/csgclaw/bots/{id}/notifications` should become a + participant-scoped notification endpoint: + +```text +POST /api/v1/channels/{channel}/participants/{id}/notifications +``` + +### Room, Message, and Mention + +Rooms and messages should reference participants, not agents. + +```text +Room + channel + members: ParticipantRef[] + +Message + event_id + room_id + sender: ParticipantRef + mentions: ParticipantRef[] + content + relates_to + +ParticipantRef + channel + id +``` + +New APIs should avoid bot-shaped names. Fields such as `sender_id`, +`member_ids`, and `user_ids` are acceptable only when their meaning is explicitly +participant or Matrix user identity, not legacy bot identity. New message APIs +should prefer `mentions: ParticipantRef[]` or `mention_ids: []`, not a single +`mention_id`. Where ambiguity exists, prefer `participant_id`, +`participant_ids`, or a structured `ParticipantRef`. + +### Future Chat History Sync Compatibility + +Chat history sync is not part of this implementation plan. It is a compatibility +constraint for the participant model. + +If a user chats in Feishu and CSGClaw later syncs the room, the imported message +must be able to pass through the same participant resolution path as locally +created messages. The participant model therefore must not assume that every +message was authored locally or that every sender is an agent-backed +participant. + +Future sync work should be able to add a `MessageEvent` or adjacent sync record +with fields such as: + +- `external_room_ref`, such as Feishu `chat_id` or Matrix room ID; +- `external_event_id`, such as Feishu `message_id` or Matrix event ID; +- `origin_server_ts` from the source IM; +- `received_at` as the local ingestion time; +- `sync_batch` or cursor metadata for resumable sync; +- `raw_event` as a redacted or bounded source payload for debugging. + +This plan should not implement sync storage, sync APIs, or backfill jobs now. +It only keeps sender, mention, room, and event identity compatible with that +future work. + +### Optional Future Identity Layer + +If CSGClaw later needs to know that the same person appears as a CSGClaw local +user, Feishu open_id, and Matrix user_id, add an identity grouping layer: + +```text +Identity 1 -> N Participant(type=human) +``` + +This is not required to solve agent-to-human mentions or cross-channel agent +reuse. + +## Matrix Alignment + +The planned Matrix direction should influence the new participant model where it +overlaps with this work. The goal is not to implement the full Matrix +Client-Server API now, but to avoid creating a second incompatible IM model. + +Use the following alignment points: + +- Matrix user IDs map to `Participant.channel_user_ref` with + `channel_user_kind=matrix_user_id`, for example `@qa:example.org`. +- Matrix room IDs map to channel room references, for example + `!roomid:example.org`; room aliases can be stored as channel metadata. +- Room membership should be representable as `m.room.member` state: + `membership`, `displayname`, and `avatar_url` belong on the room membership or + participant view, not on the runtime agent. +- Text messages should be representable as `m.room.message` events with + `msgtype=m.text`, `body`, and optional `format` / `formatted_body`. +- Mentions should be representable as Matrix `m.mentions`, especially + `m.mentions.user_ids` for user mentions and `m.mentions.room` for room + mentions. +- Existing thread metadata should continue to use Matrix-shaped + `m.relates_to.rel_type=m.thread` and root event IDs. +- Future CSGClaw sync APIs should follow the same high-level shape as Matrix + `/sync`: clients receive joined room timelines and a resumable batch token + similar to `next_batch` / `since`. + +This makes CSGClaw's own IM easier to move toward Matrix later while still +letting Feishu and other adapters keep their native transport details. + +## API Plan + +### Participant APIs + +Introduce participant APIs under the channel namespace. These replace the old +channel bot CRUD routes. + +```text +GET /api/v1/channels/{channel}/participants +POST /api/v1/channels/{channel}/participants +GET /api/v1/channels/{channel}/participants/{id} +PATCH /api/v1/channels/{channel}/participants/{id} +DELETE /api/v1/channels/{channel}/participants/{id} +``` + +The following routes should be deleted: + +```text +GET /api/v1/channels/{channel}/bots +POST /api/v1/channels/{channel}/bots +GET /api/v1/channels/{channel}/bots/{id} +PATCH /api/v1/channels/{channel}/bots/{id} +DELETE /api/v1/channels/{channel}/bots/{id} +GET /api/v1/channels/feishu/bots/{id}/events +POST /api/v1/channels/csgclaw/bots/{id}/notifications +GET /api/bots/{id}/events +POST /api/bots/{id}/messages/send +GET /api/bots/{id}/llm/models +GET /api/bots/{id}/llm/v1/models +POST /api/bots/{id}/llm/chat/completions +POST /api/bots/{id}/llm/v1/chat/completions +POST /api/bots/{id}/llm/responses +POST /api/bots/{id}/llm/v1/responses +``` + +The current public user routes should also be removed from the product API +surface: + +```text +GET /api/v1/users +POST /api/v1/users +DELETE /api/v1/users/{id} +GET /api/v1/channels/csgclaw/users +POST /api/v1/channels/csgclaw/users +DELETE /api/v1/channels/csgclaw/users/{id} +``` + +Use participants instead: + +```text +GET /api/v1/channels/{channel}/participants?type=human +POST /api/v1/channels/{channel}/participants +``` + +List query parameters: + +- `type=human|agent|notification` +- `agent_id=` +- `include_agent=true` +- `include_channel_user=true` + +Create an agent-backed participant by creating a new agent: + +```json +{ + "id": "qa", + "type": "agent", + "name": "qa", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "id": "u-qa", + "name": "qa", + "role": "worker", + "runtime_kind": "codex", + "agent_profile": { + "provider": "api", + "model_id": "gpt-5.4" + } + } + } +} +``` + +This endpoint is the replacement for the old UI "create bot" behavior that +created both an agent and a channel user. It must be implemented as one +provisioning operation owned by the server, not as UI-side chaining of +`POST /api/v1/agents` followed by participant creation. + +For the built-in CSGClaw channel, `agent_binding.mode=create` must create: + +```text +1. Agent +2. Channel user / Matrix-shaped member identity +3. Participant(type=agent, agent_id=) +``` + +The operation should commit only after all three resources are valid. If user or +participant creation fails after the agent is created, the server must either +roll back the created agent or mark the operation as failed and retry-safe with +an idempotency key. + +For external channels, true distributed transactions are not available. The API +still stays single-call from the UI, but the server must use idempotency and +compensation: + +- accept an optional `request_id` or `client_transaction_id`; +- make repeated requests with the same key return the same final resource; +- record partially completed steps; +- retry or compensate failed channel-user provisioning before exposing the + participant as active. + +Create a channel participant that reuses an existing agent: + +```json +{ + "id": "test", + "type": "agent", + "name": "QA", + "channel_user": { + "ref": "ou_xxx", + "kind": "open_id" + }, + "channel_app_ref": "cli_xxx", + "agent_binding": { + "mode": "reuse", + "agent_id": "u-qa" + } +} +``` + +Create a human participant: + +```json +{ + "id": "alice", + "type": "human", + "name": "Alice", + "channel_user": { + "ref": "ou_alice", + "kind": "open_id" + } +} +``` + +Supported `agent_binding.mode` values: + +- `create`: create a new agent and bind it to this participant. +- `reuse`: bind this participant to an existing agent. +- `none`: create the participant without runtime binding. This is valid for + humans, notifications, and draft agent participants. + +Validation: + +- `type=human` rejects `agent_binding.mode=create`. +- `type=notification` rejects `agent_binding.mode=create` unless a future + notification runtime explicitly needs it. +- `type=agent` with `mode=reuse` requires `agent_id`. +- `type=agent` with `mode=create` requires enough agent fields to create a + valid worker or manager. +- Participant ID and agent ID are not required to match. + +Deletion rules: + +- `DELETE /api/v1/channels/{channel}/participants/{id}` deletes the participant + and its channel binding by default. It does not delete the underlying Agent. +- If a `type=agent` participant is bound to an Agent, deleting the participant + only removes that channel identity from the Agent. Other participants for the + same Agent are unaffected. +- If the caller wants to clean up an Agent that is no longer referenced, it must + use an explicit parameter such as `delete_agent=if_unreferenced`. If any other + participant still references that Agent, the server must reject Agent deletion. +- Channel users / channel identities may be cleaned up only when CSGClaw owns + them and no other active participant references them. External channels should + usually delete only the local mapping or mark it inactive, not delete the real + remote user. +- Deleting a notification participant should also remove local notification + profile, webhook token, and remote_pull subscription metadata. + +### Agent APIs + +Keep `/api/v1/agents` as the runtime-agent API for lower-level runtime +management: edit model/profile, start, stop, restart, recreate, delete, view +logs, and support internal provisioning flows. + +CSGClaw's own product UI should not use `POST /api/v1/agents` as the primary +"Create Agent" action when the result is expected to appear in the CSGClaw IM. +It should use the same participant provisioning API as third-party channels, +with `channel=csgclaw`. + +Extend list and detail responses with participant bindings. + +```text +GET /api/v1/agents?include_participants=true +GET /api/v1/agents/{id}?include_participants=true +``` + +Example response excerpt: + +```json +{ + "id": "u-qa", + "name": "qa", + "role": "worker", + "runtime_kind": "codex", + "status": "running", + "participants": [ + { + "id": "qa", + "channel": "csgclaw", + "type": "agent", + "channel_user_ref": "u-qa" + }, + { + "id": "test", + "channel": "feishu", + "type": "agent", + "channel_user_ref": "ou_xxx" + } + ] +} +``` + +This satisfies the requirement that the agent list includes agents represented +in any supported IM, including Feishu. + +### Runtime Bridge API Replacement + +Deleting `/api/bots/*` requires replacing each old bridge surface with an +explicit participant or agent scoped route. + +Participant event streams are channel identity concerns: + +```text +GET /api/v1/channels/{channel}/participants/{id}/events +``` + +This replaces both: + +```text +GET /api/v1/channels/feishu/bots/{id}/events +GET /api/bots/{id}/events +``` + +Participant-authored message sending is also a channel identity concern: + +```text +POST /api/v1/channels/{channel}/participants/{id}/messages +``` + +The sender comes from the path participant. The body contains `room_id`, +`content`, optional `mentions`, and optional thread relation fields. This +replaces: + +```text +POST /api/bots/{id}/messages/send +``` + +Notification delivery is also a channel identity concern: + +```text +POST /api/v1/channels/{channel}/participants/{id}/notifications +``` + +This replaces: + +```text +POST /api/v1/channels/csgclaw/bots/{id}/notifications +``` + +LLM bridge calls are runtime agent concerns, not channel identity concerns: + +```text +GET /api/v1/agents/{agent_id}/llm/models +POST /api/v1/agents/{agent_id}/llm/chat/completions +POST /api/v1/agents/{agent_id}/llm/responses +``` + +These replace: + +```text +GET /api/bots/{id}/llm/models +GET /api/bots/{id}/llm/v1/models +POST /api/bots/{id}/llm/chat/completions +POST /api/bots/{id}/llm/v1/chat/completions +POST /api/bots/{id}/llm/responses +POST /api/bots/{id}/llm/v1/responses +``` + +If a runtime only knows its channel participant ID, it must resolve the +participant first and use `agent_id` for LLM calls. This keeps message identity +and model/runtime identity separate. + +### UI API Replacement + +The current UI issue is that an "Agent" workflow calls the deleted channel bot +API. The replacement is not UI-side chaining of multiple APIs. CSGClaw's own UI +must use the same model as Feishu and future third-party IMs: create a +participant in the target channel and bind or create an underlying agent. + +When CSGClaw's UI action means "create an Agent that can chat in CSGClaw IM", +call: + +```text +POST /api/v1/channels/csgclaw/participants +``` + +with `type=agent` and `agent_binding.mode=create` to create both the runtime +agent and the CSGClaw participant in one operation. This is the direct +replacement for the previous bot API that created both agent and user. + +When the UI adds an existing agent to another channel, use the same endpoint for +that channel with `agent_binding.mode=reuse`. + +The UI must not call `POST /api/v1/agents` and then separately create a user or +participant for this flow. That split can leave a created agent without a +channel identity, or a channel identity without a valid runtime binding. + +Recommended UI-to-API mapping: + +```text +CSGClaw Agent UI + Create Agent for CSGClaw IM -> POST /api/v1/channels/csgclaw/participants + type=agent, agent_binding.mode=create + Edit runtime/model/profile -> PATCH/PUT /api/v1/agents/{id}... + Start/stop/logs -> /api/v1/agents/{id}/... + +Channel or room page + Create new agent identity -> POST /api/v1/channels/{channel}/participants + type=agent, agent_binding.mode=create + Add existing agent identity -> POST /api/v1/channels/{channel}/participants + type=agent, agent_binding.mode=reuse + Add real person -> POST /api/v1/channels/{channel}/participants + type=human, agent_binding.mode=none +``` + +### Message and Mention APIs + +For participant-scoped send APIs, the sender comes from the path participant and +the body does not include `sender_id`. Mentions are arrays so one message can +mention multiple participants. + +```json +{ + "room_id": "oc_xxx", + "mentions": [ + { + "id": "alice" + } + ], + "content": "please take a look" +} +``` + +The channel adapter resolves: + +```text +path id -> Participant(channel=feishu, id=test) + -> channel_user_ref/channel_app_ref for sender credentials + +mentions[].id -> Participant(channel=feishu, id=alice) + -> channel_user_ref=open_id +``` + +For CSGClaw IM, the adapter renders a local mention using the local user ID. For +Feishu, the adapter renders `name`. For future IMs, +the adapter uses that channel's mention syntax. + +If a channel-level message endpoint remains, `sender_id` must explicitly mean a +participant ID in that channel. It must not mean bot ID. A single `mention_id` +does not belong in the new API and should only exist as a temporary +implementation detail while callers are updated in the same change. + +Room member APIs should also move from user-shaped fields to participant-shaped +fields. For example, `user_ids` should become `participant_ids` or +`participants: ParticipantRef[]`. Matrix payload adapters may still emit or +accept Matrix-native `user_id` values at the protocol boundary, but the +CSGClaw domain model should resolve them to participants before room membership +or mention logic runs. + +## UI Plan + +The UI should not force users to choose between model terms such as +participant, agent, and channel user as the first decision. + +Use intent-based entry points: + +```text +Add Bot to Current Channel + + Create a new bot + - create Participant(type=agent) + - create and bind a new Agent + - configure runtime, model, and template + + Add an existing bot + - list agents that are not yet represented in this channel + - create Participant(type=agent) + - bind it to the selected Agent + - confirm channel-specific identity settings +``` + +For human participants, use channel-native wording: + +```text +Add Person + - choose or enter channel user identity + - create Participant(type=human) + - make the person mentionable and optionally add them to a room +``` + +The UI can still use product-friendly labels such as Bot and Person. The backend +should keep the participant and agent split explicit. + +## CLI Changes + +The canonical CLI resource name should follow the backend model and use +`participant` for collaboration identities, with `pt` as a shorter subcommand +alias. Use `participant` in docs, scripts, and long-lived references; use `pt` +for interactive daily commands. `bot` can remain as a lightweight user-facing +alias for `type=agent` flows, but JSON output, API payloads, and errors should +use participant semantics instead of exposing a Bot storage model. + +Recommended command shape: + +```text +csgclaw participant list --channel csgclaw --type agent +csgclaw participant create --channel csgclaw --type agent --id qa --name QA --bind create +csgclaw participant create --channel feishu --type agent --id test --bind reuse --agent-id u-qa --channel-user-ref ou_xxx --channel-user-kind open_id --channel-app-ref cli_xxx +csgclaw participant create --channel feishu --type human --id alice --name Alice --channel-user-ref ou_alice --channel-user-kind open_id --channel-app-ref cli_xxx +csgclaw participant delete --channel feishu test +csgclaw participant delete --channel feishu test --delete-agent if-unreferenced +csgclaw pt list --channel csgclaw --type agent +csgclaw pt create --channel csgclaw --type agent --id qa --name QA --bind create +``` + +CLI field renames should match the API: + +- `pt` is an exact short alias for `participant`; every subcommand, flag, output, + and error must behave the same. +- `bot list/create/delete` is no longer canonical. If kept, it should only be a + product alias for `participant --type agent` / `pt --type agent`. +- `agent create` only creates runtime-only Agents. It should not be the primary + entry point for creating a chat-capable CSGClaw IM Agent. +- `user list/create/delete` moves to `participant list/create/delete --type human`. +- Room member commands should rename `--user-id`, `--user-ids`, and + `--member-ids` to `--participant-id`, `--participant-ids`, or structured + participant refs. +- Message commands should replace `--sender-id` with a path participant or an + explicit `--participant-id`; `--mention-id` should become repeatable or be + renamed to `--mention-participant-id` and sent as `mentions` / `mention_ids` + arrays. +- Feishu config commands should replace `--bot-id` with either + `--participant-id` or `--channel-app-ref`, depending on whether the command + configures a participant binding or manages the Feishu app/config. +- Team/task commands should replace `--lead-bot-id`, `--member-bot-ids`, + `--bot-id`, and `--actor-id` with `--lead-participant-id`, + `--member-participant-ids`, `--participant-id`, and + `--actor-participant-id`. Use `--agent-id` only for runtime-specific + operations. +- Runtime-embedded commands such as `csgclaw-cli` must be updated too. Embedded + skills and templates must not keep depending on old `bot_id`, `sender_id`, + `mention_id`, or `user_ids` semantics. + +## One-Step Implementation Scope + +- Add participant request/response types. +- Add participant storage with canonical key `(channel, id)`. +- Add a Participant ID generator: derive readable slugs from explicit `id` or + stable keys, add short random suffixes on collision, never derive IDs from + editable `name`, and keep the default Agent ID for newly created agent-backed + participants as `u-{participant_id}`, except the bootstrap manager + participant `manager` whose Agent ID is `u-manager`. +- Replace the public `User` API with participant APIs. Keep only an internal + channel identity/profile store where the CSGClaw and external channel + adapters need one. +- Add participant service methods for list, get, create, patch, delete, and + agent binding. +- Register `/api/v1/channels/{channel}/participants`. +- Register participant event and message routes: + `/api/v1/channels/{channel}/participants/{id}/events` and + `/api/v1/channels/{channel}/participants/{id}/messages`. +- Register the participant notification route: + `/api/v1/channels/{channel}/participants/{id}/notifications`. +- Register agent LLM routes under `/api/v1/agents/{agent_id}/llm/*`. +- Implement create modes: `create`, `reuse`, and `none`. +- Add response expansion for `include_agent` and `include_channel_user`. +- Add tests for creating a Feishu participant `test` bound to agent `u-qa`. +- Resolve sender and mention IDs through participant service. +- Update CSGClaw IM mention rendering to accept human and agent participants. +- Update Feishu send path so mentions resolve to participant `channel_user_ref` + instead of requiring a configured bot app for every mention. +- Add tests for agent-to-human mentions in CSGClaw IM and Feishu. +- Add tests proving a Feishu human participant can be mentioned with + `channel_app_ref + open_id` without having its own bot app/config. +- Change message send requests from a single `mention_id` to `mentions` / + `mention_ids` arrays. +- Rename room membership request fields from `user_ids` to `participant_ids` or + structured participant refs. +- Add `participants` expansion to `GET /api/v1/agents`. +- Include participants from Feishu and future channel stores. +- Add tests proving that agent `u-qa` appears with both `csgclaw:qa` and + `feishu:test` participant bindings. +- Add tests proving that deleting the `feishu:test` participant does not delete + agent `u-qa` while `csgclaw:qa` still references it. +- Replace the current create-agent/create-bot ambiguity with intent-based + channel actions. +- Make CSGClaw UI create chat-capable agents through + `POST /api/v1/channels/csgclaw/participants`, not direct agent creation plus + separate user provisioning. +- Add "Create a new bot" and "Add an existing bot" flows. +- Add "Add Person" flow for human participants. +- Replace the current notification bot webhook/pull routes with a + participant-scoped notification endpoint. +- Keep existing Agent pages focused on runtime configuration and lifecycle. +- Update CLI and `csgclaw-cli`: canonical commands use participant, register the + `pt` short alias, and move old bot/user/member/message parameters to + participant semantics. +- Migrate identity references in legacy `bots.json`, IM state, Feishu config, + and Team state. Warn in the UI when the runtime image/template contract is + outdated; the current recreate flow only preserves user skills. +- Do not implement chat history sync, sync storage, or sync APIs in this change; + only keep the identity model compatible with future sync. +- Delete channel bot CRUD, Feishu bot event, `/api/bots/*`, and public `/users` + routes in the same change set. +- Replace runtime bridge callers with participant/agent-scoped routes before + removing the old handlers. + +## Why This Solves the Problem Best + +This model matches the real domain boundaries. People and bots are channel +identities. Agents are reusable runtime capabilities. Mentions belong to the +channel identity layer, not the runtime layer. + +It also removes the ID-equality assumption. A Feishu participant can be named +`test` and bind to underlying agent `u-qa` explicitly. The relationship is a +stored foreign key rather than a naming convention. + +The result is a single target API rather than two competing surfaces. UI code no +longer calls bot APIs when the user is creating an Agent, and channel workflows +create participants explicitly. The UI can stay simple by presenting user intent +instead of internal model terminology. diff --git a/docs/participant-architecture.zh.md b/docs/participant-architecture.zh.md new file mode 100644 index 00000000..065845b3 --- /dev/null +++ b/docs/participant-architecture.zh.md @@ -0,0 +1,718 @@ +# Participant 身份架构与 API 修改计划 + +## 快速 Review 要点 + +- 核心变化是把 `Participant`、`Agent`、`ChannelUser` 和产品文案里的 `Bot` 拆开:Participant 是 room/message/member/mention 使用的协作身份;Agent 是 runtime 执行实体;ChannelUser 是 channel 内部 identity/profile;Bot 不再是后端 API 和存储模型。 +- UI 创建一个能出现在 CSGClaw 自带 IM 里的 Agent 时,应调用 `POST /api/v1/channels/csgclaw/participants`,使用 `type=agent` 和 `agent_binding.mode=create`,由服务端一次性创建 Agent、ChannelUser 和 Participant。 +- 新建 agent-backed participant 的 Agent ID 生成关系保持旧约定:`agent_id = u-{participant_id}`。Participant ID 应来自显式 `id` 或稳定 key,不能从后续可修改的 `name` 派生。 +- 跨 channel 复用不再依赖 ID 相等。同一个 Agent 可以有多个 participant,例如 `csgclaw:qa -> agent:u-qa` 和 `feishu:test -> agent:u-qa`。 +- Mention 走 participant 身份层。Feishu 真人 mention 使用 `channel_user_ref` 加 `channel_app_ref` / `channel_user_kind` 显式解析,不要求真人拥有 bot app/config。 +- Notification 是 `type=notification` 的 participant,表示 webhook、系统事件或 pull relay 这类通知来源;默认不绑定 Agent,也不暴露 LLM bridge。 +- Message 新 API 使用 `mentions` / `mention_ids` 数组;room membership 从 `user_ids` 迁移到 `participant_ids` 或结构化 `ParticipantRef`。 +- 删除 participant 默认只删除 channel 身份绑定,不删除底层 Agent;只有显式 `delete_agent=if_unreferenced` 这类语义才允许清理未被引用的 Agent。 +- 这次是前后端、runtime bridge、CLI 和内置模板同步更新的 breaking API 调整:API 兼容性不是迁移重点,旧 bot 路由、公开 `/users` 路由不保留兼容别名。 +- 旧 `bots.json` 等磁盘数据仍应可迁移;只要发现 runtime image/template contract 过旧,就在 UI 上提醒 recreate;当前 recreate 只承诺保留用户安装的 skills。 +- Matrix 对齐只覆盖本次涉及的 identity、membership、message、mention 和 thread 形状,不实现完整 Matrix homeserver 或 Client-Server API。 + +## 迁移优先事项 + +这次前后端一起发布,API breaking 不作为迁移风险;迁移重点是本地磁盘状态和旧 runtime image。 + +- **`bots.json`**:旧 bot 记录仍要能读取,并迁移成 participant 记录。普通 bot 迁移为 `type=agent` participant,保留原 `agent_id` / `channel_user_ref`;notification bot 迁移为 `type=notification` participant。 +- **IM state**:`im/state.json` 和 `im/sessions/*.jsonl` 里的旧 `users`、room `members`、message `sender_id`、`mentions`、thread context 等身份引用,需要从旧 user/bot ID 映射到 participant ID。 +- **Feishu config**:`channels/feishu.toml` 里按旧 `bot_id` 保存的 app/config,要迁移到 participant/channel app 语义,避免飞书发送和 mention 解析丢配置。 +- **Team state**:`teams/*` 下的 `lead_bot_id`、`member_bot_ids`、`bot_id`、`actor_id`、`created_by`、`assigned_to`、`requested_by`、`approver_id` 等字段,要迁移为 participant ID。 +- **Agents state**:`agents/state.json` 的 Agent ID 不需要改写;新 participant 记录继续通过 `agent_id` 指向原 Agent。 +- **旧镜像提醒**:只要发现 runtime image/template contract 过旧,就在 UI 上提醒用户 recreate。 +- **recreate 保留 skills**:当前 recreate 只承诺保留用户安装的 skills;不在这次计划里扩展为保留 workspace/project 状态。 + +## 背景 + +CSGClaw 目前的 `bot`、`agent` 和 channel `user` 概念在最早的本地多 Agent 协作里可以工作,但面对多渠道、多真人、多机器人协作时边界不够清楚。 + +最直接的问题是 mention 身份。Agent 需要在 CSGClaw 自带 IM、飞书以及未来其他 IM 里 @ 真人。当前 bot 模型默认消息发送者和 mention 对象都是 bot 类身份。在飞书里,这会导致 mention 通过已配置 bot app 身份解析,Agent 无法自然地 @ 一个真人 open_id。 + +还有一个命名和归属问题。UI 里很多操作叫 Agent 管理,但部分客户端实际调用 channel bot API。与此同时,底层 runtime agent 本来就可以跨 channel 复用。例如,Feishu bot 可以建模为“飞书 channel 身份 + 一个原本从 CSGClaw channel 创建的底层 Agent”。现在这种复用主要依赖 `u-manager` 这类相同 ID 约定。如果 CSGClaw 里的 agent 是 `u-qa`,但飞书侧 participant 叫 `test`,这个关系就无法清晰表达。 + +目标设计是拆开这些概念: + +- `Participant` 是协作身份,被 room、message、member、mention 引用。 +- `Agent` 是 runtime 执行实体,拥有 model、profile、生命周期、日志和 sandbox 状态。 +- `Bot` 从 API 和存储模型中移除。只有当产品文案明确需要面向用户说“Bot”时,UI 才可以保留这个词。 + +## 目标 + +- Agent 可以在 CSGClaw IM、飞书和未来 IM 中 @ 真人。 +- Room 和 Message 可以统一包含 human、agent-backed participant、notification participant。 +- 一个底层 Agent 可以被多个 channel participant 复用,不依赖 ID 相等。 +- `GET /api/v1/agents` 可以展示所有 runtime agent,以及这些 agent 在任意支持 IM 中对应的 participant,包括飞书。 +- UI 可以同时支持创建新的 agent-backed participant,以及把已有 agent 添加到另一个 channel,而不要求用户先理解内部模型。 +- 前后端、runtime bridge 和内置模板同步更新,现有 bot 路由不保留旧别名,直接删除。 +- 新模型在这次涉及 room、member、message、mention、thread 的范围内尽量贴近 Matrix 语义。 + +## 非目标 + +- 本次不合并同一个真人在不同 channel 的身份。如果后续产品需要跨 channel 审计或权限,可以再增加 `Identity` 层。 +- 不要求所有 participant 都有 agent。human 和 notification participant 默认不需要 runtime 执行能力。 +- 本次不实现完整 Matrix homeserver 或完整 Client-Server API。只在这次会碰到的身份、成员、消息、mention 和 thread 形状上对齐 Matrix。 + +## 目标模型 + +### Agent + +Agent 是 CSGClaw 全局 runtime 实体,不属于任何单一 channel。 + +```text +Agent + id + name + role + runtime_id + runtime_kind + image + runtime_options + status + agent_profile + created_at +``` + +规则: + +- Agent ID 全局唯一。 +- 新建 agent-backed participant 时,如果请求未显式指定 Agent ID,服务端按 `u-{participant_id}` 生成 Agent ID。这个关系保持旧 worker/bot ID 的习惯,例如 participant `qa` 对应 agent `u-qa`。 +- Bootstrap manager 是保留例外:默认 CSGClaw participant ID 为 `manager`,Agent ID 仍为 `u-manager`。 +- 如果调用方显式指定 Agent ID,仍必须满足全局唯一,并且不要求和 participant ID 相同;跨 channel 复用时通常显式传已有 `agent_id`。 +- Agent 生命周期操作继续放在 `/api/v1/agents`。 +- Agent profile、model、runtime、日志、start、stop、restart、recreate 仍由 agent service 管理。 + +### Participant + +Participant 是某个 channel 内可见的协作身份。 + +```text +Participant + id # 在所在 channel 内稳定 + channel # csgclaw | feishu | matrix | ... + type # human | agent | notification + name + avatar + channel_user_ref # csgclaw user id, feishu open_id, matrix user_id, ... + channel_user_kind # local_user_id | open_id | matrix_user_id | ... + channel_app_ref # 可选,用于 Feishu app_id 这类 bot app/config 身份 + agent_id # 可选 FK -> Agent.id,仅 type=agent 时有意义 + lifecycle_status # provisioning | active | disabled | failed + presence # 可选,channel presence 或 room member view + mentionable + metadata + created_at + updated_at +``` + +规则: + +- Participant 的规范 key 是 `(channel, id)`。 +- 只要 participant 需要在 channel 里发送、接收或被 mention,`channel_user_ref` 就是必填。 +- 活跃 participant 应满足该 channel 的 channel-user identity 唯一。对简单 channel 是 `(channel, channel_user_ref)`;对 Feishu 这类 app-scoped identity,需要把 `channel_app_ref` 和 `channel_user_kind` 纳入唯一键。 +- `lifecycle_status` 描述 participant 记录自身是否可用;`presence` 描述 channel 或 room 里的在线/离线视图;runtime 是否 running 应来自绑定的 Agent,不应写入 participant 的持久状态。 +- `mentionable=false` 表示该 participant 可以存在于列表或历史中,但不应被新消息 mention。 +- `type=human` 不要求也不应该要求 `agent_id`。 +- `type=notification` 不要求也不应该要求 `agent_id`。 +- `type=agent` 可以绑定已有 agent、创建新 agent,或者先注册 participant 后续再绑定 runtime。 +- 一个 Agent 可以跨 channel 拥有多个 participant: + +```text +csgclaw:qa -> agent:u-qa +feishu:test -> agent:u-qa +matrix:qa-bot -> agent:u-qa +``` + +### Participant ID 生成规则 + +Participant ID 是用户和 CLI 经常看到的 channel 身份 ID,应优先可读且稳定,而不是默认使用裸 UUID。UUID 可以作为内部随机源或兜底值,但直接暴露给用户会降低可读性,也不利于 room membership、mention 和 CLI 操作。 + +Participant ID 不能从 `name` 生成。`name` 是显示名,后续可能支持修改;ID 一旦进入 room member、message、mention、agent binding 和 CLI,就必须稳定。业界更常见的做法是“稳定 slug + 短随机冲突后缀”,例如 Kubernetes object name 或很多 SaaS 的 workspace slug。只有完全不面向用户操作的内部对象,才更适合直接暴露 `usr_...`、`agt_...` 这类带类型前缀的 opaque ID。 + +推荐生成算法: + +1. 如果请求显式传入 `id`,先 normalize 并校验唯一性。 +2. 如果没有显式 `id`,只能从稳定来源生成 slug,例如创建请求里的独立 `slug` / `handle` 字段、内置模板 key、角色 key、外部 channel 的不可变 handle,或迁移时的旧 bot/user ID。不要使用可修改的显示名 `name`。 +3. Slug 规则:小写;去掉首尾空白;把连续非 `[a-z0-9]` 字符替换成 `-`;折叠连续 `-`;去掉首尾 `-`;建议长度限制为 3 到 48 个字符。 +4. 如果 slug 为空,按类型生成可读前缀加短随机后缀,例如 `agent-8f3k2m`、`human-8f3k2m`、`notification-8f3k2m`。 +5. 如果 slug 已存在,在 slug 后追加短随机后缀,例如 `qa-8f3k2m`。短随机后缀可以来自 UUID/ULID/nanoid 的 base32/base36 截断值。 +6. 服务端返回最终 participant ID;同一个 `request_id` 或 `client_transaction_id` 重试时必须返回同一个 ID。 + +Agent-backed participant 的默认 Agent ID 生成规则保持: + +```text +agent_id = "u-" + participant_id +``` + +因此新建 CSGClaw IM Agent 时,participant `qa` 默认生成 agent `u-qa`,同时 CSGClaw channel user ref 也可以继续使用 `u-qa`,保持旧 runtime、workspace 和 mention 习惯。 + +### Channel User / Channel Identity + +引入 Participant 之后,现有对外 `User` 模型不应该继续作为顶层产品 API 保留。对外创建、查询、更新和删除“人”的入口应统一替换成 participant API。 + +但底层仍然需要一个类似 user 的记录,只是它的语义变成 channel-scoped identity/profile,由 channel adapter 拥有,不再是主要协作身份。 + +```text +ChannelUser + channel # csgclaw | feishu | matrix | ... + ref # CSGClaw local user id, Feishu open_id/union_id, Matrix user_id + kind # local_user_id | open_id | matrix_user_id | ... + app_ref # 可选,Feishu app_id/tenant scoped identity 时使用 + display_name + handle + avatar_url + presence + raw_profile + updated_at +``` + +规则: + +- `Participant.channel_user_ref` 指向 channel user,或该 channel 原生的等价身份。 +- 对内置 CSGClaw channel,这个内部记录替代当前 `User` 存储形状,用来承载 profile、avatar、handle 和 presence。 +- 对飞书,这个记录由 adapter 管理。`kind=open_id` 时 `ref` 是 app-scoped open_id,必须和 `app_ref` 一起理解;如果后续使用 `union_id` 或 `user_id`,也必须在 `kind` 中显式标出。 +- 对 Matrix,这个记录可以自然映射到 Matrix user ID,以及 `displayname`、`avatar_url`、membership state 等 member profile 字段。 +- 公共客户端应通过 participant 创建、列出、更新和删除真人,不再调用独立 `/users` API。 + +### Feishu 身份 Scope + +Feishu 的 user identity 不能只看一个裸 `open_id` 字符串。不同 app、tenant 或 identity type 下,同一个真人可能有不同 ID,同一个 ID 字符串也不能跨 scope 复用。 + +规则: + +- `channel_app_ref` 表示这个 participant 对应的 Feishu app/config,例如 `cli_xxx`。 +- `channel_user_kind=open_id` 时,`channel_user_ref` 是该 app scope 下的 open_id;唯一键应至少包含 `(channel, channel_app_ref, channel_user_kind, channel_user_ref)`。 +- `type=agent` participant 需要 `channel_app_ref` 来确定发送消息时使用哪个 Feishu app 凭证。 +- `type=human` participant 用 `channel_user_ref` 作为 mention 目标,不要求这个真人有 bot app/config。 +- 如果 adapter 从 Feishu event 里只拿到 `user_id`、`union_id` 或其他身份,应先按 `channel_user_kind` 记录原始身份,再在需要发送或 mention 时做显式解析,不能隐式当作 bot ID。 + +### Notification Participant + +Notification 也是一种 channel participant,但它默认不是 runtime agent。 + +```text +Participant(type=notification) + channel_user_ref # notification sender identity 或本地 webhook identity + channel_app_ref # 可选,外部 channel 发送所需 app/config + metadata.notification # webhook、remote_pull、subscription、delivery config +``` + +规则: + +- Notification participant 可以作为 room/message sender,用于展示系统通知、第三方 webhook 或 pull relay 的来源。 +- Notification participant 默认没有 `agent_id`,也不暴露 LLM bridge。 +- Notification webhook/pull 配置挂在 participant metadata 或专门的 notification profile 上,不再挂在 bot API 形状上。 +- 原 `POST /api/v1/channels/csgclaw/bots/{id}/notifications` 应替换为 participant-scoped notification endpoint: + +```text +POST /api/v1/channels/{channel}/participants/{id}/notifications +``` + +### Room、Message、Mention + +Room 和 Message 应引用 participant,而不是引用 agent。 + +```text +Room + channel + members: ParticipantRef[] + +Message + event_id + room_id + sender: ParticipantRef + mentions: ParticipantRef[] + content + relates_to + +ParticipantRef + channel + id +``` + +新 API 应避免 bot 形状命名。`sender_id`、`member_ids`、`user_ids` 这类字段只有在含义明确是 participant 或 Matrix user identity 时才保留,不能继续表示 legacy bot identity。新消息 API 应优先使用 `mentions: ParticipantRef[]` 或 `mention_ids: []`,而不是单个 `mention_id`。存在歧义时,优先使用 `participant_id`、`participant_ids` 或结构化 `ParticipantRef`。 + +### 未来聊天记录同步兼容性 + +聊天记录同步不是这次实施计划的一部分,它只是 participant 模型必须兼容的后续方向。 + +如果用户在飞书里聊天,CSGClaw 后续同步这个 room,导入消息应能走和本地消息相同的 participant 解析路径。因此 participant 模型不能假设所有消息都是本地产生,也不能假设所有 sender 都是 agent-backed participant。 + +未来 sync 工作可以增加 `MessageEvent` 或相邻 sync 记录,字段包括: + +- `external_room_ref`,例如 Feishu `chat_id` 或 Matrix room ID; +- `external_event_id`,例如 Feishu `message_id` 或 Matrix event ID; +- source IM 提供的 `origin_server_ts`; +- 本地摄入时间 `received_at`; +- 用于可恢复同步的 `sync_batch` 或 cursor metadata; +- 脱敏或限长后的 `raw_event`,用于排查问题。 + +这次计划不实现 sync storage、sync API 或 backfill job。这里只保证 sender、mention、room 和 event identity 不阻碍后续同步。 + +### 可选的未来 Identity 层 + +如果未来需要知道同一个真人同时出现在 CSGClaw local user、Feishu open_id 和 Matrix user_id 中,可以增加身份聚合层: + +```text +Identity 1 -> N Participant(type=human) +``` + +这个层不是解决 Agent @ 真人或跨 channel 复用 Agent 的前置条件。 + +## Matrix 对齐 + +后续 CSGClaw IM 会往 Matrix 协议方向实现,因此这次 participant 调整应该在涉及范围内尽量贴近 Matrix。目标不是现在实现完整 Matrix Client-Server API,而是避免产生第二套不可兼容的 IM 模型。 + +对齐点如下: + +- Matrix user ID 映射到 `Participant.channel_user_ref`,`channel_user_kind=matrix_user_id`,例如 `@qa:example.org`。 +- Matrix room ID 映射到 channel room reference,例如 `!roomid:example.org`;room alias 可以放在 channel metadata。 +- Room membership 应能表示成 `m.room.member` state:`membership`、`displayname`、`avatar_url` 属于 room membership 或 participant view,不属于 runtime agent。 +- 文本消息应能表示成 `m.room.message` event,content 至少包含 `msgtype=m.text`、`body`,可选 `format` / `formatted_body`。 +- Mention 应能表示成 Matrix `m.mentions`,尤其是用户 mention 的 `m.mentions.user_ids` 和 room mention 的 `m.mentions.room`。 +- 已有 thread metadata 应继续保持 Matrix 形状:`m.relates_to.rel_type=m.thread` 加 root event ID。 +- 后续 CSGClaw sync API 应保持与 Matrix `/sync` 类似的高层形状:客户端拿到 joined room timelines,以及类似 `next_batch` / `since` 的可恢复 batch token。 + +这样 CSGClaw 自有 IM 后续迁到 Matrix 会更顺,Feishu 和其他 channel adapter 仍然可以保留各自原生传输细节。 + +## API 计划 + +### Participant API + +在 channel 命名空间下新增 participant API。这些 API 替换旧的 channel bot CRUD 路由。 + +```text +GET /api/v1/channels/{channel}/participants +POST /api/v1/channels/{channel}/participants +GET /api/v1/channels/{channel}/participants/{id} +PATCH /api/v1/channels/{channel}/participants/{id} +DELETE /api/v1/channels/{channel}/participants/{id} +``` + +以下路由直接删除: + +```text +GET /api/v1/channels/{channel}/bots +POST /api/v1/channels/{channel}/bots +GET /api/v1/channels/{channel}/bots/{id} +PATCH /api/v1/channels/{channel}/bots/{id} +DELETE /api/v1/channels/{channel}/bots/{id} +GET /api/v1/channels/feishu/bots/{id}/events +POST /api/v1/channels/csgclaw/bots/{id}/notifications +GET /api/bots/{id}/events +POST /api/bots/{id}/messages/send +GET /api/bots/{id}/llm/models +GET /api/bots/{id}/llm/v1/models +POST /api/bots/{id}/llm/chat/completions +POST /api/bots/{id}/llm/v1/chat/completions +POST /api/bots/{id}/llm/responses +POST /api/bots/{id}/llm/v1/responses +``` + +当前公开 user 路由也应从产品 API 面删除: + +```text +GET /api/v1/users +POST /api/v1/users +DELETE /api/v1/users/{id} +GET /api/v1/channels/csgclaw/users +POST /api/v1/channels/csgclaw/users +DELETE /api/v1/channels/csgclaw/users/{id} +``` + +使用 participant API 替代: + +```text +GET /api/v1/channels/{channel}/participants?type=human +POST /api/v1/channels/{channel}/participants +``` + +列表查询参数: + +- `type=human|agent|notification` +- `agent_id=` +- `include_agent=true` +- `include_channel_user=true` + +创建一个同时新建 Agent 的 agent-backed participant: + +```json +{ + "id": "qa", + "type": "agent", + "name": "qa", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "id": "u-qa", + "name": "qa", + "role": "worker", + "runtime_kind": "codex", + "agent_profile": { + "provider": "api", + "model_id": "gpt-5.4" + } + } + } +} +``` + +这个 endpoint 是旧 UI “create bot” 行为的替代:旧行为会同时创建 agent 和 channel user。新方案必须由服务端提供一个单次 provisioning 操作,不能让 UI 先调用 `POST /api/v1/agents` 再单独创建 participant。 + +对于内置 CSGClaw channel,`agent_binding.mode=create` 必须一次性创建: + +```text +1. Agent +2. Channel user / Matrix-shaped member identity +3. Participant(type=agent, agent_id=) +``` + +只有三个资源都有效时才算提交成功。如果 agent 已创建但 user 或 participant 创建失败,服务端必须回滚已创建 agent,或者把该操作标记为失败且可通过 idempotency key 安全重试。 + +对于外部 channel,不存在真正的分布式事务。UI 仍然只调用一次 API,但服务端必须通过幂等和补偿保证一致性: + +- 接受可选 `request_id` 或 `client_transaction_id`; +- 同一个 key 的重复请求返回同一个最终资源; +- 记录已完成的部分步骤; +- 在 participant 暴露为 active 前,重试或补偿失败的 channel-user provisioning。 + +创建一个复用已有 Agent 的 channel participant: + +```json +{ + "id": "test", + "type": "agent", + "name": "QA", + "channel_user": { + "ref": "ou_xxx", + "kind": "open_id" + }, + "channel_app_ref": "cli_xxx", + "agent_binding": { + "mode": "reuse", + "agent_id": "u-qa" + } +} +``` + +创建一个真人 participant: + +```json +{ + "id": "alice", + "type": "human", + "name": "Alice", + "channel_user": { + "ref": "ou_alice", + "kind": "open_id" + } +} +``` + +支持的 `agent_binding.mode`: + +- `create`:创建新的 Agent 并绑定到这个 participant。 +- `reuse`:绑定到已有 Agent。 +- `none`:只创建 participant,不绑定 runtime。适用于 human、notification 和草稿状态的 agent participant。 + +校验规则: + +- `type=human` 拒绝 `agent_binding.mode=create`。 +- `type=notification` 拒绝 `agent_binding.mode=create`,除非未来 notification 明确需要 runtime。 +- `type=agent` 且 `mode=reuse` 时必须提供 `agent_id`。 +- `type=agent` 且 `mode=create` 时必须提供足够创建合法 worker 或 manager 的 Agent 字段。 +- Participant ID 和 Agent ID 不要求相同。 + +删除规则: + +- `DELETE /api/v1/channels/{channel}/participants/{id}` 默认只删除 participant 以及它的 channel 绑定,不删除底层 Agent。 +- 如果 `type=agent` participant 绑定了 Agent,删除 participant 只解除该 channel identity 与 Agent 的关联;同一个 Agent 的其他 participant 不受影响。 +- 如果调用方希望同时清理不再被引用的 Agent,应使用显式参数,例如 `delete_agent=if_unreferenced`。当仍有其他 participant 引用该 Agent 时,服务端必须拒绝删除 Agent。 +- Channel user / channel identity 只有在由 CSGClaw 管理且没有其他 active participant 引用时才可以被清理;外部 channel 通常只删除本地映射或标记 inactive,不删除远端真实用户。 +- Notification participant 删除时应同时移除本地 notification profile、webhook token 和 remote_pull subscription metadata。 + +### Agent API + +保留 `/api/v1/agents` 作为较底层的 runtime agent API,用于编辑 model/profile、start、stop、restart、recreate、delete、查看日志,以及支持内部 provisioning 流程。 + +CSGClaw 自己的产品 UI 如果创建结果预期会出现在 CSGClaw IM 中,不应把 `POST /api/v1/agents` 作为主“创建 Agent”入口。它应和飞书等第三方 IM 使用同一套 participant provisioning API,只是 `channel=csgclaw`。 + +扩展 list/detail 响应,展示 participant 绑定关系。 + +```text +GET /api/v1/agents?include_participants=true +GET /api/v1/agents/{id}?include_participants=true +``` + +响应片段示例: + +```json +{ + "id": "u-qa", + "name": "qa", + "role": "worker", + "runtime_kind": "codex", + "status": "running", + "participants": [ + { + "id": "qa", + "channel": "csgclaw", + "type": "agent", + "channel_user_ref": "u-qa" + }, + { + "id": "test", + "channel": "feishu", + "type": "agent", + "channel_user_ref": "ou_xxx" + } + ] +} +``` + +这满足“agent list 要包含任意 IM 中的 agent,包括当前支持的第三方 IM 飞书 agent”的需求。 + +### Runtime Bridge API 替换 + +删除 `/api/bots/*` 后,需要把每个旧 bridge surface 替换成明确的 participant scoped 或 agent scoped 路由。 + +Participant event stream 属于 channel 身份: + +```text +GET /api/v1/channels/{channel}/participants/{id}/events +``` + +替换: + +```text +GET /api/v1/channels/feishu/bots/{id}/events +GET /api/bots/{id}/events +``` + +Participant 作为发送者发消息也属于 channel 身份: + +```text +POST /api/v1/channels/{channel}/participants/{id}/messages +``` + +发送者来自 path participant。body 包含 `room_id`、`content`、可选 `mentions` 和可选 thread relation 字段。替换: + +```text +POST /api/bots/{id}/messages/send +``` + +Notification 投递也属于 channel 身份: + +```text +POST /api/v1/channels/{channel}/participants/{id}/notifications +``` + +替换: + +```text +POST /api/v1/channels/csgclaw/bots/{id}/notifications +``` + +LLM bridge 属于 runtime agent,不属于 channel 身份: + +```text +GET /api/v1/agents/{agent_id}/llm/models +POST /api/v1/agents/{agent_id}/llm/chat/completions +POST /api/v1/agents/{agent_id}/llm/responses +``` + +替换: + +```text +GET /api/bots/{id}/llm/models +GET /api/bots/{id}/llm/v1/models +POST /api/bots/{id}/llm/chat/completions +POST /api/bots/{id}/llm/v1/chat/completions +POST /api/bots/{id}/llm/responses +POST /api/bots/{id}/llm/v1/responses +``` + +如果 runtime 只知道自己的 channel participant ID,需要先解析 participant,再用 `agent_id` 调 LLM API。这样消息身份和 model/runtime 身份保持分离。 + +### UI API 替换 + +当前 UI 问题是“创建 Agent”的流程调用了将被删除的 channel bot API。替代方案不是让 UI 串联多个 API。CSGClaw 自己的 UI 也要和飞书及未来第三方 IM 使用同一个模型:在目标 channel 创建 participant,并绑定或创建底层 agent。 + +当 CSGClaw UI 行为是“创建一个可以在 CSGClaw IM 里聊天的 Agent”时,调用: + +```text +POST /api/v1/channels/csgclaw/participants +``` + +使用 `type=agent` 和 `agent_binding.mode=create` 一次性创建 runtime agent 和 CSGClaw participant。这就是之前 bot API “同时创建 agent 和 user”的直接替代。 + +当 UI 要把已有 agent 添加到另一个 channel 时,仍使用目标 channel 的同一个 endpoint,并传 `agent_binding.mode=reuse`。 + +这个流程里 UI 不能先调用 `POST /api/v1/agents`,再单独创建 user 或 participant。拆成多次调用会产生半失败:agent 创建成功但没有 channel identity,或者 channel identity 存在但没有有效 runtime binding。 + +推荐 UI 到 API 映射: + +```text +CSGClaw Agent UI + 创建 CSGClaw IM Agent -> POST /api/v1/channels/csgclaw/participants + type=agent, agent_binding.mode=create + 编辑 runtime/model/profile -> PATCH/PUT /api/v1/agents/{id}... + start/stop/logs -> /api/v1/agents/{id}/... + +Channel 或 Room 页面 + 在当前 channel 创建新 Agent identity -> POST /api/v1/channels/{channel}/participants + type=agent, agent_binding.mode=create + 从已有 Agent 添加到 channel identity -> POST /api/v1/channels/{channel}/participants + type=agent, agent_binding.mode=reuse + 添加真人 -> POST /api/v1/channels/{channel}/participants + type=human, agent_binding.mode=none +``` + +### Message 和 Mention API + +对 participant-scoped 发送 API,发送者来自 path participant,body 中不再传 `sender_id`。Mention 使用数组,支持一次消息 mention 多个 participant。 + +```json +{ + "room_id": "oc_xxx", + "mentions": [ + { + "id": "alice" + } + ], + "content": "please take a look" +} +``` + +Channel adapter 解析链路: + +```text +path id -> Participant(channel=feishu, id=test) + -> sender 所需的 channel_user_ref/channel_app_ref + +mentions[].id -> Participant(channel=feishu, id=alice) + -> channel_user_ref=open_id +``` + +在 CSGClaw IM 中,adapter 渲染本地 mention。在飞书中,adapter 渲染 `name`。未来 IM 使用各自 channel 的 mention 语法。 + +如果仍保留 channel-level message endpoint,`sender_id` 必须明确解释为该 channel 下的 participant ID,不能再解释为 bot ID。单个 `mention_id` 不进入新 API,只能作为旧请求结构在调用方同步替换前的临时实现细节。 + +Room member API 也应从 user 形状迁移到 participant 形状。例如,`user_ids` 应改为 `participant_ids` 或 `participants: ParticipantRef[]`。Matrix 协议边界的 adapter 仍可发送或接收 Matrix 原生 `user_id`,但进入 CSGClaw 领域模型前,应先解析成 participant,再进入 room membership 或 mention 逻辑。 + +## UI 计划 + +UI 不应该把 participant、agent、channel user 这些内部模型词作为用户第一层选择。 + +使用基于意图的入口: + +```text +添加 Bot 到当前 Channel + + 创建全新的 Bot + - 创建 Participant(type=agent) + - 创建并绑定新的 Agent + - 配置 runtime、model、template + + 从已有 Bot 添加 + - 列出当前 channel 中还没有出现的 agent + - 创建 Participant(type=agent) + - 绑定到选中的 Agent + - 确认 channel 相关身份设置 +``` + +真人 participant 使用 channel 语义: + +```text +添加真人 + - 选择或输入 channel user identity + - 创建 Participant(type=human) + - 让真人可被 mention,并可选加入 room +``` + +UI 可以继续使用 Bot、Person 这类产品友好的名称。后端保持 participant 和 agent 的分层清晰。 + +## CLI 变化 + +CLI 的规范资源名应跟随后端模型,使用 `participant` 作为协作身份入口,并提供更短的 `pt` 子命令别名。`participant` 用于文档、脚本和长期稳定引用;`pt` 用于交互式日常操作。`bot` 可以作为面向使用者的轻量别名保留给 `type=agent` 场景,但输出 JSON、API payload 和错误信息应使用 participant 语义,避免继续暴露 Bot 存储模型。 + +推荐命令形状: + +```text +csgclaw participant list --channel csgclaw --type agent +csgclaw participant create --channel csgclaw --type agent --id qa --name QA --bind create +csgclaw participant create --channel feishu --type agent --id test --bind reuse --agent-id u-qa --channel-user-ref ou_xxx --channel-user-kind open_id --channel-app-ref cli_xxx +csgclaw participant create --channel feishu --type human --id alice --name Alice --channel-user-ref ou_alice --channel-user-kind open_id --channel-app-ref cli_xxx +csgclaw participant delete --channel feishu test +csgclaw participant delete --channel feishu test --delete-agent if-unreferenced +csgclaw pt list --channel csgclaw --type agent +csgclaw pt create --channel csgclaw --type agent --id qa --name QA --bind create +``` + +CLI 字段改名应和 API 一致: + +- `pt` 是 `participant` 的等价短别名,所有 `participant` 子命令、flag、输出和错误语义都必须一致。 +- `bot list/create/delete` 不再是规范命令;如果保留,应只是 `participant --type agent` / `pt --type agent` 的产品别名。 +- `agent create` 只负责创建 runtime-only Agent,不应作为“创建可聊天 CSGClaw IM Agent”的主入口。 +- `user list/create/delete` 迁移为 `participant list/create/delete --type human`。 +- room member 命令中的 `--user-id`、`--user-ids`、`--member-ids` 应改为 `--participant-id`、`--participant-ids` 或结构化 participant ref。 +- message 命令中的 `--sender-id` 应改为 path 或显式 `--participant-id`;`--mention-id` 应支持重复传入或改为 `--mention-participant-id`,并发送为 `mentions` / `mention_ids` 数组。 +- Feishu 配置命令中的 `--bot-id` 应改为 `--participant-id` 或 `--channel-app-ref`,取决于命令是在配置 participant 绑定,还是在管理 Feishu app/config。 +- team/task 命令里的 `--lead-bot-id`、`--member-bot-ids`、`--bot-id`、`--actor-id` 应改为 `--lead-participant-id`、`--member-participant-ids`、`--participant-id`、`--actor-participant-id`;只有明确操作 runtime 时才使用 `--agent-id`。 +- `csgclaw-cli` 这类 runtime 内置命令也要同步更新;内置技能和模板不能继续依赖旧 `bot_id`、`sender_id`、`mention_id` 和 `user_ids` 语义。 + +## 一步到位实施范围 + +- 新增 participant request/response types。 +- 新增 participant storage,规范 key 为 `(channel, id)`。 +- 新增 Participant ID 生成器:从显式 `id` 或稳定 key 生成可读 slug,冲突时追加短随机后缀;不要从可修改的 `name` 派生 ID;新建 agent-backed participant 的默认 Agent ID 保持 `u-{participant_id}`,但 bootstrap manager 例外,participant 为 `manager`、Agent 为 `u-manager`。 +- 用 participant API 替换公开 `User` API。只有 CSGClaw 和外部 channel adapter 需要时,才保留内部 channel identity/profile store。 +- 新增 participant service,支持 list、get、create、patch、delete 和 agent binding。 +- 注册 `/api/v1/channels/{channel}/participants`。 +- 注册 participant event/message 路由: + `/api/v1/channels/{channel}/participants/{id}/events` 和 + `/api/v1/channels/{channel}/participants/{id}/messages`。 +- 注册 participant notification 路由: + `/api/v1/channels/{channel}/participants/{id}/notifications`。 +- 在 `/api/v1/agents/{agent_id}/llm/*` 下注册 agent LLM 路由。 +- 实现 `create`、`reuse`、`none` 三种创建模式。 +- 支持 `include_agent` 和 `include_channel_user` 响应展开。 +- 增加测试:创建 Feishu participant `test` 并绑定到 agent `u-qa`。 +- sender 和 mention ID 统一通过 participant service 解析。 +- 更新 CSGClaw IM mention 渲染,支持 human 和 agent participant。 +- 更新 Feishu 发送链路,让 mention 解析到 participant 的 `channel_user_ref`,不再要求每个 mention 对象都有配置好的 bot app。 +- 增加测试:Agent 在 CSGClaw IM 和飞书中 @ 真人。 +- 增加测试:Feishu human participant 使用 `channel_app_ref + open_id` 作为 mention 目标,不需要自己的 bot app/config。 +- 将 message send 请求从单个 `mention_id` 调整为 `mentions` / `mention_ids` 数组。 +- 将 room membership 请求字段从 `user_ids` 改为 `participant_ids` 或结构化 participant ref。 +- 为 `GET /api/v1/agents` 增加 `participants` 展开。 +- 包含飞书和未来 channel store 中的 participant。 +- 增加测试:agent `u-qa` 同时展示 `csgclaw:qa` 和 `feishu:test` 两个绑定。 +- 增加测试:删除 `feishu:test` participant 不删除仍被 `csgclaw:qa` 使用的 agent `u-qa`。 +- 用基于意图的 channel action 替代当前 create-agent/create-bot 混淆。 +- CSGClaw UI 创建可聊天 Agent 时,使用 + `POST /api/v1/channels/csgclaw/participants`,不要直接创建 agent 后再单独创建 user。 +- 增加“创建全新的 Bot”和“从已有 Bot 添加”流程。 +- 增加“添加真人”流程。 +- 用 participant-scoped notification endpoint 替换当前 notification bot webhook/pull 路由。 +- Agent 页面聚焦 runtime 配置和生命周期。 +- 更新 CLI 和 `csgclaw-cli`:规范命令使用 participant,并注册 `pt` 短别名;旧 bot/user/member/message 参数同步改为 participant 语义。 +- 迁移旧 `bots.json`、IM state、Feishu config 和 Team state 里的身份引用;旧 runtime image/template contract 过期时在 UI 提醒 recreate,当前 recreate 只保留用户 skills。 +- 本次不实现聊天记录同步、sync storage 或 sync API;只保证身份模型兼容未来同步。 +- 在同一个变更中删除 channel bot CRUD、Feishu bot event、`/api/bots/*` 和公开 `/users` 路由。 +- 删除旧 handler 前,先把 runtime bridge 调用方替换成 participant/agent scoped 路由。 + +## 为什么这是最佳方案 + +这个模型符合真实领域边界。真人和 bot 是 channel 身份,Agent 是可复用的 runtime 能力。Mention 属于 channel 身份层,不属于 runtime 层。 + +它也去掉了 ID 相等假设。Feishu participant 可以叫 `test`,同时显式绑定到底层 agent `u-qa`。这个关系是持久化外键,不再是命名约定。 + +最终结果是一个目标 API,而不是两个并存的 API 面。UI 创建 Agent 时不再调用 bot API;channel 流程显式创建 participant。UI 仍然可以用用户意图组织流程,而不是暴露内部模型术语。 diff --git a/docs/sandbox/csghub.md b/docs/sandbox/csghub.md index 93528807..690b807f 100644 --- a/docs/sandbox/csghub.md +++ b/docs/sandbox/csghub.md @@ -43,7 +43,7 @@ It has two audiences: │ manager pod │ csgclaw-agent-sandbox │ picoclaw │──────────────┐ │ └──────┬───────┘ │ │ - POST /api/bots/:id/worker │ │ │ + POST /api/v1/channels/csgclaw/participants │ │ │ ▼ │ │ ┌──────────────┐ │ │ │ worker pod │ csgclaw-agent-sandbox @@ -142,7 +142,7 @@ the CSGHub API env before sending the CSGHub `CreateRequest`. | Picoclaw ↔ server | `CSGCLAW_ACCESS_TOKEN` | `server.AccessToken` | | Picoclaw ↔ server | `PICOCLAW_CHANNELS_CSGCLAW_BASE_URL` | `resolveManagerBaseURL(server)` | | Picoclaw ↔ server | `PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN` | `server.AccessToken` | -| Picoclaw ↔ server | `PICOCLAW_CHANNELS_CSGCLAW_BOT_ID` | per-agent | +| Picoclaw ↔ server | `PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID` | per-agent | | Picoclaw ↔ server | `CSGCLAW_LLM_BASE_URL` | `llmBridgeBaseURL(...)` | | Picoclaw ↔ server | `CSGCLAW_LLM_API_KEY` | `server.AccessToken` | | Picoclaw ↔ server | `CSGCLAW_LLM_MODEL_ID` | per-agent | @@ -179,9 +179,10 @@ values and optionally prefixes them with `CSGCLAW_PVC_SUBPATH_PREFIX`. - Every sandbox (server + manager + worker) must share an overlay reachable by pod-IP or Hub service DNS; the server's advertised URL must resolve from inside manager/worker pods. -- Manager/worker pods must reach the server on - `CSGCLAW_BASE_URL` (LLM bridge `/api/bots//llm`, worker spawn - `/api/bots//workers`, health `/healthz`). +- Manager/worker pods must reach the server on `CSGCLAW_BASE_URL`: + participant message bridge + `/api/v1/channels/csgclaw/participants//{events,messages}`, + LLM bridge `/api/v1/agents//llm`, and health `/healthz`. - Server pod must reach the CSGHub Sandbox API on `CSGHUB_API_BASE_URL` (TLS + bearer). - The server-side CSGHub client uses `CSGHUB_AIGATEWAY_URL` when set; diff --git a/internal/agent/manager_config.go b/internal/agent/manager_config.go index d0d30258..1e00f4ea 100644 --- a/internal/agent/manager_config.go +++ b/internal/agent/manager_config.go @@ -1,6 +1,7 @@ package agent import ( + "encoding/json" "fmt" "log/slog" "net" @@ -31,15 +32,19 @@ var defaultLocalIPDetector = localIPDetector{ } func ensureManagerPicoClawConfig(server config.ServerConfig, model config.ModelConfig) (string, error) { - return ensureAgentPicoClawConfig(ManagerName, "u-manager", server, model) + return ensureAgentPicoClawConfigForParticipant(ManagerName, ManagerParticipantID, ManagerUserID, server, model) } -func ensureAgentPicoClawConfig(agentName, botID string, server config.ServerConfig, model config.ModelConfig) (string, error) { +func ensureAgentPicoClawConfig(agentName, agentID string, server config.ServerConfig, model config.ModelConfig) (string, error) { + return ensureAgentPicoClawConfigForParticipant(agentName, agentID, agentID, server, model) +} + +func ensureAgentPicoClawConfigForParticipant(agentName, participantID, agentID string, server config.ServerConfig, model config.ModelConfig) (string, error) { agentHome, err := agentHomeDir(agentName) if err != nil { return "", err } - return picoclawsandbox.EnsureConfig(agentHome, botID, server, model, resolveManagerBaseURL) + return picoclawsandbox.EnsureConfig(agentHome, participantID, agentID, server, model, resolveManagerBaseURL) } func managerPicoClawRoot() (string, error) { @@ -59,11 +64,52 @@ func agentPicoClawRoot(agentName string) (string, error) { } func renderManagerPicoClawConfig(server config.ServerConfig, model config.ModelConfig) ([]byte, error) { - return renderAgentPicoClawConfig("u-manager", server, model) + return renderAgentPicoClawConfigForParticipant(ManagerParticipantID, ManagerUserID, server, model) } -func renderAgentPicoClawConfig(botID string, server config.ServerConfig, model config.ModelConfig) ([]byte, error) { - return picoclawsandbox.RenderConfig(botID, server, model, resolveManagerBaseURL) +func renderAgentPicoClawConfig(agentID string, server config.ServerConfig, model config.ModelConfig) ([]byte, error) { + return renderAgentPicoClawConfigForParticipant(agentID, agentID, server, model) +} + +func renderAgentPicoClawConfigForParticipant(participantID, agentID string, server config.ServerConfig, model config.ModelConfig) ([]byte, error) { + return picoclawsandbox.RenderConfig(participantID, agentID, server, model, resolveManagerBaseURL) +} + +func agentPicoClawConfigNeedsParticipantRecreate(agentName, participantID string) bool { + root, err := agentPicoClawRoot(agentName) + if err != nil { + return false + } + data, err := os.ReadFile(filepath.Join(root, picoclawsandbox.HostConfig)) + if err != nil { + return false + } + + var cfg struct { + Channels map[string]json.RawMessage `json:"channels"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return false + } + raw := cfg.Channels["csgclaw"] + if len(raw) == 0 { + return true + } + var channel map[string]any + if err := json.Unmarshal(raw, &channel); err != nil { + return false + } + if enabled, ok := channel["enabled"].(bool); !ok || !enabled { + return true + } + got, ok := channel["participant_id"].(string) + if !ok || strings.TrimSpace(got) != strings.TrimSpace(participantID) { + return true + } + if _, ok := channel["bot_id"]; ok { + return true + } + return false } func picoclawBridgeModelID(modelID string) string { diff --git a/internal/agent/manager_config_test.go b/internal/agent/manager_config_test.go index 69bdd677..f2c0f825 100644 --- a/internal/agent/manager_config_test.go +++ b/internal/agent/manager_config_test.go @@ -59,9 +59,10 @@ func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) { for _, want := range []string{ `"model_name": "gpt-5.4"`, `"model": "openai/gpt-5.4"`, - `"api_base": "http://10.0.0.8:18080/api/bots/u-ux/llm"`, + `"api_base": "http://10.0.0.8:18080/api/v1/agents/u-ux/llm"`, `"api_key": "shared-token"`, - `"bot_id": "u-ux"`, + `"participant_id": "u-ux"`, + `"enabled": true`, } { if !strings.Contains(text, want) { t.Fatalf("renderAgentPicoClawConfig() missing %q in:\n%s", want, text) @@ -70,7 +71,9 @@ func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) { if strings.Contains(text, "cloud.infini-ai.com") { t.Fatalf("renderAgentPicoClawConfig() leaked upstream base URL:\n%s", text) } - + if strings.Contains(text, `"bot_id"`) { + t.Fatalf("renderAgentPicoClawConfig() still emitted bot_id:\n%s", text) + } var rendered map[string]any if err := json.Unmarshal(data, &rendered); err != nil { t.Fatalf("renderAgentPicoClawConfig() produced invalid JSON: %v", err) @@ -99,6 +102,63 @@ func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) { } } +func TestRenderManagerPicoClawConfigUsesSeparateParticipantAndAgentIDs(t *testing.T) { + localIPv4Resolver = func() string { return "10.0.0.8" } + defer func() { localIPv4Resolver = localIPv4 }() + + data, err := renderManagerPicoClawConfig(config.ServerConfig{ + ListenAddr: "0.0.0.0:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "gpt-5.5", + }) + if err != nil { + t.Fatalf("renderManagerPicoClawConfig() error = %v", err) + } + + text := string(data) + for _, want := range []string{ + `"participant_id": "` + ManagerParticipantID + `"`, + `"api_base": "http://10.0.0.8:18080/api/v1/agents/` + ManagerUserID + `/llm"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("renderManagerPicoClawConfig() missing %q in:\n%s", want, text) + } + } + if strings.Contains(text, `"api_base": "http://10.0.0.8:18080/api/v1/agents/`+ManagerParticipantID+`/llm"`) { + t.Fatalf("renderManagerPicoClawConfig() used participant ID for LLM bridge:\n%s", text) + } + if strings.Contains(text, `"bot_id"`) { + t.Fatalf("renderManagerPicoClawConfig() still emitted bot_id:\n%s", text) + } +} + +func TestAgentPicoClawConfigNeedsParticipantRecreateRejectsLegacyBotID(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + configPath := filepath.Join(homeDir, config.AppDirName, managerAgentsDirName, ManagerName, picoclawsandbox.HostDir, picoclawsandbox.HostConfig) + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("MkdirAll(config dir) error = %v", err) + } + + staleConfig := `{"channels":{"csgclaw":{"enabled":true,"participant_id":"manager","bot_id":"manager"}}}` + if err := os.WriteFile(configPath, []byte(staleConfig), 0o600); err != nil { + t.Fatalf("WriteFile(stale config) error = %v", err) + } + if !agentPicoClawConfigNeedsParticipantRecreate(ManagerName, ManagerParticipantID) { + t.Fatal("agentPicoClawConfigNeedsParticipantRecreate() = false, want true for legacy bot_id field") + } + + currentConfig := `{"channels":{"csgclaw":{"enabled":true,"participant_id":"manager"}}}` + if err := os.WriteFile(configPath, []byte(currentConfig), 0o600); err != nil { + t.Fatalf("WriteFile(current config) error = %v", err) + } + if agentPicoClawConfigNeedsParticipantRecreate(ManagerName, ManagerParticipantID) { + t.Fatal("agentPicoClawConfigNeedsParticipantRecreate() = true, want false for current participant bridge fields") + } +} + func stringifyJSONList(values []any) []string { result := make([]string, 0, len(values)) for _, value := range values { diff --git a/internal/agent/runtime_state.go b/internal/agent/runtime_state.go index e3caeec2..09798a1a 100644 --- a/internal/agent/runtime_state.go +++ b/internal/agent/runtime_state.go @@ -390,9 +390,9 @@ func ProjectsRoot() (string, error) { return ensureAgentProjectsRoot() } -func llmBridgeBaseURL(managerBaseURL, botID string) string { +func llmBridgeBaseURL(managerBaseURL, agentID string) string { managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") - return managerBaseURL + "/api/bots/" + strings.TrimSpace(botID) + "/llm" + return managerBaseURL + "/api/v1/agents/" + strings.TrimSpace(agentID) + "/llm" } func bridgeLLMEnvVars(llmBaseURL, accessToken, modelID string) map[string]string { diff --git a/internal/agent/runtime_state_test.go b/internal/agent/runtime_state_test.go index d40a4f78..e5adf936 100644 --- a/internal/agent/runtime_state_test.go +++ b/internal/agent/runtime_state_test.go @@ -58,7 +58,7 @@ func TestRuntimeProfileForAgentUsesBridgeForCodex(t *testing.T) { }, }) - if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("runtimeProfileForAgent().BaseURL = %q, want %q", got, want) } if got, want := profile.APIKey, "shared-token"; got != want { @@ -96,7 +96,7 @@ func TestRuntimeProfileForKindUsesBridgeForCodexRuntime(t *testing.T) { }, }) - if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("runtimeProfileForKind().BaseURL = %q, want %q", got, want) } if got, want := profile.APIKey, "shared-token"; got != want { @@ -136,7 +136,7 @@ func TestRuntimeProfileForKindUsesHostReachableBridgeForCodexRuntime(t *testing. ModelID: "gpt-5.4", }) - if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-developer/llm"; got != want { + if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-developer/llm"; got != want { t.Fatalf("runtimeProfileForKind().BaseURL = %q, want host-reachable %q", got, want) } } diff --git a/internal/agent/service.go b/internal/agent/service.go index 434b951f..86825bcf 100644 --- a/internal/agent/service.go +++ b/internal/agent/service.go @@ -24,14 +24,15 @@ import ( ) const ( - ManagerName = "manager" - ManagerUserID = "u-manager" - managerHostPort = 18790 - managerGuestPort = 18790 - managerDebugMode = true - hostWorkspaceDir = "workspace" - hostProjectsDir = "projects" - gatewayLogPoll = 200 * time.Millisecond + ManagerName = "manager" + ManagerParticipantID = "manager" + ManagerUserID = "u-manager" + managerHostPort = 18790 + managerGuestPort = 18790 + managerDebugMode = true + hostWorkspaceDir = "workspace" + hostProjectsDir = "projects" + gatewayLogPoll = 200 * time.Millisecond ) const ( @@ -398,10 +399,14 @@ func (svc *Service) EnsureBootstrapManager(ctx context.Context, forceRecreate bo if err != nil { return err } - if _, err := ensureAgentPicoClawConfig(ManagerName, ManagerUserID, svc.server, defaultModel); err != nil { + recreateForParticipantBridgeConfig := !forceRecreate && agentPicoClawConfigNeedsParticipantRecreate(ManagerName, ManagerParticipantID) + if _, err := ensureAgentPicoClawConfigForParticipant(ManagerName, ManagerParticipantID, ManagerUserID, svc.server, defaultModel); err != nil { return err } - _, err = svc.EnsureManager(ctx, forceRecreate) + if recreateForParticipantBridgeConfig { + log.Printf("bootstrap manager PicoClaw config uses legacy bot bridge fields; recreating manager to load participant bridge config") + } + _, err = svc.EnsureManager(ctx, forceRecreate || recreateForParticipantBridgeConfig) return err } @@ -497,10 +502,11 @@ func (s *Service) ensureManager(ctx context.Context, forceRecreate bool, imageOv return err } if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{ - RuntimeID: runtimeIDForAgentID(ManagerUserID), - AgentID: ManagerUserID, - AgentName: ManagerName, - Profile: s.runtimeProfileForKind(runtimeKind, ManagerUserID, ManagerName, "", startProfile), + RuntimeID: runtimeIDForAgentID(ManagerUserID), + AgentID: ManagerUserID, + ParticipantID: ManagerParticipantID, + AgentName: ManagerName, + Profile: s.runtimeProfileForKind(runtimeKind, ManagerUserID, ManagerName, "", startProfile), }); err != nil { return fmt.Errorf("provision bootstrap manager runtime: %w", err) } @@ -1555,6 +1561,7 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{ RuntimeID: runtimeIDForAgentID(id), AgentID: id, + ParticipantID: participantIDForAgent(name, id), AgentName: name, Profile: runtimeProfile, WorkspaceOverlay: strings.TrimSpace(spec.FromTemplate), @@ -1757,12 +1764,34 @@ func (s *Service) provisionRuntimeForAgent(ctx context.Context, rt agentruntime. return s.provisionRuntime(ctx, rt, strings.TrimSpace(got.RuntimeKind), agentruntime.ProvisionRequest{ RuntimeID: normalizeRuntimeID(got.RuntimeID, got.ID), AgentID: strings.TrimSpace(got.ID), + ParticipantID: participantIDForAgent(got.Name, got.ID), AgentName: strings.TrimSpace(got.Name), Profile: s.runtimeProfileForAgent(got), WorkspaceOverlay: strings.TrimSpace(workspaceOverlay), }) } +func participantIDForAgent(agentName, agentID string) string { + agentID = strings.TrimSpace(agentID) + if managerGatewayMatch(agentName, agentID) { + return ManagerParticipantID + } + return participantIDFromAgentID(agentID) +} + +func ParticipantIDForAgent(agentName, agentID string) string { + return participantIDForAgent(agentName, agentID) +} + +func participantIDFromAgentID(agentID string) string { + agentID = strings.TrimSpace(agentID) + withoutPrefix := strings.TrimPrefix(agentID, "u-") + if withoutPrefix != "" && withoutPrefix != agentID { + return withoutPrefix + } + return agentID +} + func (s *Service) gatewayProvisionRequest(runtimeKind, agentName, agentID string) (*agentruntime.GatewayProvision, error) { if s == nil { return nil, fmt.Errorf("agent service is required") diff --git a/internal/agent/service_profiles.go b/internal/agent/service_profiles.go index 1d8510b3..6f9e2f15 100644 --- a/internal/agent/service_profiles.go +++ b/internal/agent/service_profiles.go @@ -399,10 +399,11 @@ func (s *Service) recreate(ctx context.Context, id string, imageFor func(context return Agent{}, fmt.Errorf("refresh gateway template skills: %w", err) } if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{ - RuntimeID: createSpec.RuntimeID, - AgentID: createSpec.AgentID, - AgentName: createSpec.AgentName, - Profile: runtimeProfile, + RuntimeID: createSpec.RuntimeID, + AgentID: createSpec.AgentID, + ParticipantID: participantIDForAgent(createSpec.AgentName, createSpec.AgentID), + AgentName: createSpec.AgentName, + Profile: runtimeProfile, }); err != nil { return Agent{}, fmt.Errorf("provision agent runtime: %w", err) } diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go index 994f985a..99d12326 100644 --- a/internal/agent/service_test.go +++ b/internal/agent/service_test.go @@ -572,7 +572,7 @@ func TestCreateWorkerUsesCodexRuntimeWhenRequested(t *testing.T) { if spec.AgentName != "alice" { t.Fatalf("Create() agent name = %q, want %q", spec.AgentName, "alice") } - if got, want := spec.Profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := spec.Profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("Create() profile base url = %q, want %q", got, want) } if got, want := spec.Profile.APIKey, "shared-token"; got != want { @@ -1107,7 +1107,7 @@ func TestCreateWorkerProvisionsRuntimeBeforeNew(t *testing.T) { if req.AgentName != "alice" { t.Fatalf("Provision() agent name = %q, want %q", req.AgentName, "alice") } - if got, want := req.Profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := req.Profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("Provision() profile base url = %q, want %q", got, want) } if got, want := req.Profile.APIKey, "shared-token"; got != want { @@ -1149,6 +1149,47 @@ func TestCreateWorkerProvisionsRuntimeBeforeNew(t *testing.T) { } } +func TestCreateWorkerProvisionsParticipantIDSeparateFromAgentID(t *testing.T) { + var gotParticipantID string + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, "manager-image:test", "", + WithRuntime(fakeAgentRuntime{ + kind: RuntimeKindPicoClawSandbox, + provision: func(_ context.Context, req agentruntime.ProvisionRequest) error { + gotParticipantID = req.ParticipantID + return nil + }, + new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { + return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "box-qa"}, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + if _, err := svc.CreateWorker(context.Background(), CreateAgentSpec{ + ID: "u-agent-hhtz4b", + Name: "qa", + RuntimeKind: RuntimeKindPicoClawSandbox, + Image: "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-worker:dev", + AgentProfile: AgentProfile{ + Name: "qa", + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "gpt-5.5", + ProfileComplete: true, + }, + }); err != nil { + t.Fatalf("CreateWorker() error = %v", err) + } + if got, want := gotParticipantID, "agent-hhtz4b"; got != want { + t.Fatalf("Provision() participant id = %q, want %q", got, want) + } +} + func TestCreateWorkerPassesWorkspaceOverlayToProvision(t *testing.T) { overlayRoot := t.TempDir() var gotOverlay string @@ -1198,7 +1239,7 @@ func TestRecreateTriggersLifecycleObserver(t *testing.T) { kind: RuntimeKindCodex, del: func(context.Context, agentruntime.Handle) error { return nil }, new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { - if got, want := spec.Profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := spec.Profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("Create() profile base url = %q, want %q", got, want) } if got, want := spec.Profile.APIKey, "shared-token"; got != want { @@ -1275,7 +1316,7 @@ func TestRecreateProvisionsRuntimeBeforeNew(t *testing.T) { if req.AgentName != "alice" { t.Fatalf("Provision() agent name = %q, want %q", req.AgentName, "alice") } - if got, want := req.Profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := req.Profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("Provision() profile base url = %q, want %q", got, want) } if got, want := req.Profile.APIKey, "shared-token"; got != want { @@ -3668,11 +3709,14 @@ func TestStartRefreshesCompleteWorkerGatewayConfig(t *testing.T) { t.Fatalf("ReadFile(worker config) error = %v", err) } text := string(data) - for _, want := range []string{`"bot_id": "u-alice"`, `"model_name": "gpt-5.5"`, `"api_base": "http://10.0.0.8:18080/api/bots/u-alice/llm"`} { + for _, want := range []string{`"participant_id": "alice"`, `"model_name": "gpt-5.5"`, `"api_base": "http://10.0.0.8:18080/api/v1/agents/u-alice/llm"`} { if !strings.Contains(text, want) { t.Fatalf("worker config missing %q in:\n%s", want, text) } } + if strings.Contains(text, `"bot_id"`) { + t.Fatalf("worker config still emitted bot_id:\n%s", text) + } } func TestStartConfiguredAgentsRecreatesMissingCompleteWorkerBoxes(t *testing.T) { @@ -5316,6 +5360,112 @@ func TestEnsureBootstrapStateReusesStoredManagerBoxIDWithoutForce(t *testing.T) } } +func TestEnsureBootstrapStateRecreatesManagerWithLegacyPicoClawBridgeConfig(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + SetTestHooks(nil, nil) + defer ResetTestHooks() + + primaryRT := &fakeRuntime{} + testEnsureRuntimeAtHomeHook = func(_ *Service, home string) (sandbox.Runtime, error) { + return primaryRT, nil + } + testGetBoxHook = func(_ *Service, _ context.Context, rt sandbox.Runtime, idOrName string) (sandbox.Instance, error) { + if rt == primaryRT && idOrName == "box-old" { + return &fakeInfoInstance{info: sandbox.Info{ + ID: "box-old", + Name: ManagerName, + State: sandbox.StateRunning, + CreatedAt: time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC), + }}, nil + } + return nil, fmt.Errorf("%w: missing", sandbox.ErrNotFound) + } + var removed []string + testForceRemoveBoxHook = func(_ *Service, _ context.Context, _ sandbox.Runtime, idOrName string) error { + removed = append(removed, idOrName) + return nil + } + var created bool + testCreateGatewayBoxHook = func(_ *Service, _ context.Context, _ sandbox.Runtime, image, name, botID string, _ AgentProfile) (sandbox.Instance, sandbox.Info, error) { + created = true + if image != "manager-image:test" || name != ManagerName || botID != ManagerUserID { + t.Fatalf("createGatewayBox() got image=%q name=%q botID=%q", image, name, botID) + } + return &fakeInfoInstance{info: sandbox.Info{ + ID: "box-new", + Name: ManagerName, + State: sandbox.StateRunning, + CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC), + }}, sandbox.Info{ + ID: "box-new", + Name: ManagerName, + State: sandbox.StateRunning, + CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC), + }, nil + } + + managerHome := filepath.Join(homeDir, config.AppDirName, managerAgentsDirName, ManagerName) + configPath := filepath.Join(managerHome, picoclawsandbox.HostDir, picoclawsandbox.HostConfig) + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("MkdirAll(config dir) error = %v", err) + } + legacyConfig := `{"channels":{"csgclaw":{"enabled":true,"bot_id":"u-manager"}}}` + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("WriteFile(legacy config) error = %v", err) + } + + statePath := filepath.Join(t.TempDir(), "agents.json") + data, err := json.Marshal(persistedState{ + Agents: []persistedAgent{ + { + ID: ManagerUserID, + Name: ManagerName, + RuntimeKind: RuntimeKindPicoClawSandbox, + Role: RoleManager, + BoxID: "box-old", + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + AgentProfile: AgentProfile{ + Name: ManagerName, + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "gpt-4.1", + ProfileComplete: true, + }, + ProfileComplete: true, + }, + }, + }) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if err := os.WriteFile(statePath, data, 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + if err := EnsureBootstrapState(context.Background(), statePath, config.ServerConfig{ListenAddr: ":18080", AccessToken: "token"}, testModelConfig(), "manager-image:test", false); err != nil { + t.Fatalf("EnsureBootstrapState() error = %v", err) + } + if !created { + t.Fatal("createGatewayBox() was not called; legacy manager bridge config should force recreate") + } + if got, want := strings.Join(removed, ","), "box-old"; got != want { + t.Fatalf("removed boxes = %q, want %q", got, want) + } + rendered, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(rendered config) error = %v", err) + } + if !strings.Contains(string(rendered), `"participant_id": "`+ManagerParticipantID+`"`) { + t.Fatalf("rendered config missing participant_id:\n%s", rendered) + } + if strings.Contains(string(rendered), `"bot_id"`) { + t.Fatalf("rendered config still contains bot_id:\n%s", rendered) + } +} + func TestBoxRuntimeHomeUsesPerAgentDirectory(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) @@ -5622,7 +5772,7 @@ func TestGatewayCreateSpecBuildsSandboxSpec(t *testing.T) { if got, want := spec.Env["CSGCLAW_BASE_URL"], "http://10.0.0.8:18080"; got != want { t.Fatalf("CSGCLAW_BASE_URL = %q, want %q", got, want) } - if got, want := spec.Env["CSGCLAW_LLM_BASE_URL"], "http://10.0.0.8:18080/api/bots/u-worker-1/llm"; got != want { + if got, want := spec.Env["CSGCLAW_LLM_BASE_URL"], "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm"; got != want { t.Fatalf("CSGCLAW_LLM_BASE_URL = %q, want %q", got, want) } if got, want := spec.Env["PICOCLAW_CHANNELS_FEISHU_APP_ID"], "cli_worker"; got != want { @@ -6065,33 +6215,38 @@ func TestPicoclawBoxEnvVars(t *testing.T) { "http://10.0.0.8:18080", "shared-token", "u-worker-1", - "http://10.0.0.8:18080/api/bots/u-worker-1/llm", + "u-worker-1", + "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", "minimax-m2.7", ) wants := map[string]string{ - "CSGCLAW_BASE_URL": "http://10.0.0.8:18080", - "CSGCLAW_ACCESS_TOKEN": "shared-token", - "PICOCLAW_CHANNELS_CSGCLAW_BASE_URL": "http://10.0.0.8:18080", - "PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN": "shared-token", - "PICOCLAW_CHANNELS_CSGCLAW_BOT_ID": "u-worker-1", - "CSGCLAW_LLM_BASE_URL": "http://10.0.0.8:18080/api/bots/u-worker-1/llm", - "CSGCLAW_LLM_API_KEY": "shared-token", - "CSGCLAW_LLM_MODEL_ID": "minimax-m2.7", - "OPENAI_BASE_URL": "http://10.0.0.8:18080/api/bots/u-worker-1/llm", - "OPENAI_API_KEY": "shared-token", - "OPENAI_MODEL": "minimax-m2.7", - "PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME": "minimax-m2.7", - "PICOCLAW_CUSTOM_MODEL_NAME": "minimax-m2.7", - "PICOCLAW_CUSTOM_MODEL_ID": "openai/minimax-m2.7", - "PICOCLAW_CUSTOM_MODEL_API_KEY": "shared-token", - "PICOCLAW_CUSTOM_MODEL_BASE_URL": "http://10.0.0.8:18080/api/bots/u-worker-1/llm", + "CSGCLAW_BASE_URL": "http://10.0.0.8:18080", + "CSGCLAW_ACCESS_TOKEN": "shared-token", + "PICOCLAW_CHANNELS_CSGCLAW_BASE_URL": "http://10.0.0.8:18080", + "PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN": "shared-token", + "PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID": "u-worker-1", + "PICOCLAW_CHANNELS_CSGCLAW_ENABLED": "true", + "CSGCLAW_LLM_BASE_URL": "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", + "CSGCLAW_LLM_API_KEY": "shared-token", + "CSGCLAW_LLM_MODEL_ID": "minimax-m2.7", + "OPENAI_BASE_URL": "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", + "OPENAI_API_KEY": "shared-token", + "OPENAI_MODEL": "minimax-m2.7", + "PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME": "minimax-m2.7", + "PICOCLAW_CUSTOM_MODEL_NAME": "minimax-m2.7", + "PICOCLAW_CUSTOM_MODEL_ID": "openai/minimax-m2.7", + "PICOCLAW_CUSTOM_MODEL_API_KEY": "shared-token", + "PICOCLAW_CUSTOM_MODEL_BASE_URL": "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", } for key, want := range wants { if got[key] != want { t.Fatalf("%s = %q, want %q", key, got[key], want) } } + if _, ok := got["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"]; ok { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_BOT_ID should not be emitted") + } } func TestPicoclawBoxEnvVarsPrefixesCustomModelIDForSlashNames(t *testing.T) { @@ -6099,7 +6254,8 @@ func TestPicoclawBoxEnvVarsPrefixesCustomModelIDForSlashNames(t *testing.T) { "http://10.0.0.8:18080", "shared-token", "u-worker-1", - "http://10.0.0.8:18080/api/bots/u-worker-1/llm", + "u-worker-1", + "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", "Qwen/Qwen3-0.6B-GGUF", ) @@ -6143,14 +6299,15 @@ func TestAddFeishuBoxEnvVarsRequiresExactBotIDMatch(t *testing.T) { } } -func picoclawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string) map[string]string { +func picoclawBoxEnvVars(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string) map[string]string { env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) picoclawModelID := picoclawBridgeModelID(modelID) env["CSGCLAW_BASE_URL"] = baseURL env["CSGCLAW_ACCESS_TOKEN"] = accessToken env["PICOCLAW_CHANNELS_CSGCLAW_BASE_URL"] = baseURL env["PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN"] = accessToken - env["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"] = botID + env["PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID"] = participantID + env["PICOCLAW_CHANNELS_CSGCLAW_ENABLED"] = "true" env["PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"] = modelID env["PICOCLAW_CUSTOM_MODEL_NAME"] = modelID env["PICOCLAW_CUSTOM_MODEL_ID"] = picoclawModelID @@ -6230,9 +6387,9 @@ func withTestSandboxRuntimeHost(host PicoClawRuntimeHost, provider feishu.BotCre }, nil }, SyncHandle: host.SyncHandle, - BuildRuntimeEnv: func(baseURL, accessToken, botID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { - env := picoclawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID) - addFeishuBoxEnvVars(env, botID, provider) + BuildRuntimeEnv: func(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { + env := picoclawBoxEnvVars(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID) + addFeishuBoxEnvVars(env, agentID, provider) return env }, AddProfileEnv: addProfileEnvVars, diff --git a/internal/api/bot_compat.go b/internal/api/bot_compat.go deleted file mode 100644 index aa222c93..00000000 --- a/internal/api/bot_compat.go +++ /dev/null @@ -1,429 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/go-chi/chi/v5" - - "csgclaw/internal/agent" - "csgclaw/internal/im" - agentruntime "csgclaw/internal/runtime" -) - -const ( - botReplayWindow = 30 * time.Minute - botHeartbeatInterval = 15 * time.Second -) - -func (h *Handler) registerBotCompatibilityRoutes(router chi.Router) { - router.Route("/api/bots/{id}", func(r chi.Router) { - r.Get("/events", h.handleBotCompatibilityEvents) - r.Post("/messages/send", h.handleBotCompatibilitySendMessage) - r.Get("/llm/models", h.handleBotCompatibilityLLMModels) - r.Get("/llm/v1/models", h.handleBotCompatibilityLLMModels) - r.Post("/llm/chat/completions", h.handleBotCompatibilityLLMChatCompletions) - r.Post("/llm/v1/chat/completions", h.handleBotCompatibilityLLMChatCompletions) - r.Get("/llm/responses", h.handleBotCompatibilityLLMResponsesWebsocket) - r.Get("/llm/v1/responses", h.handleBotCompatibilityLLMResponsesWebsocket) - r.Post("/llm/responses", h.handleBotCompatibilityLLMResponses) - r.Post("/llm/v1/responses", h.handleBotCompatibilityLLMResponses) - }) -} - -func (h *Handler) PublishBotEvent(evt im.Event) { - if h.botBridge == nil || h.im == nil { - return - } - if evt.Type != im.EventTypeMessageCreated || evt.Message == nil || evt.Sender == nil { - return - } - - room, ok := h.im.Room(evt.RoomID) - if !ok { - return - } - if reason, ok, err := newConversationCommandReason(evt.Message.Content); err != nil { - slog.Warn("parse new conversation command failed", "room_id", evt.RoomID, "message_id", evt.Message.ID, "error", err) - } else if ok { - missed := h.publishNewConversationBotEvent(context.Background(), room, *evt.Sender, *evt.Message, reason) - h.reconnectMissedBotAgents(evt.Sender.ID, missed) - return - } - missed := h.botBridge.PublishMessageEvent(room, *evt.Sender, *evt.Message) - h.reconnectMissedBotAgents(evt.Sender.ID, missed) -} - -func (h *Handler) handleBotCompatibilityEvents(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return - } - h.handleBotEvents(w, r, botID) -} - -func (h *Handler) handleBotCompatibilitySendMessage(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return - } - h.handleBotSendMessage(w, r, botID) -} - -func (h *Handler) handleBotCompatibilityLLMModels(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return - } - h.handleBotLLMModels(w, r, botID) -} - -func (h *Handler) handleBotCompatibilityLLMChatCompletions(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return - } - h.handleBotLLMChatCompletions(w, r, botID) -} - -func (h *Handler) handleBotCompatibilityLLMResponses(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return - } - h.handleBotLLMResponses(w, r, botID) -} - -func (h *Handler) handleBotCompatibilityLLMResponsesWebsocket(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return - } - h.handleBotLLMResponsesWebsocket(w, r, botID) -} - -func (h *Handler) requireBotCompatibilityBotID(w http.ResponseWriter, r *http.Request) (string, bool) { - botID := pathValue(r, "id") - if botID == "" { - http.NotFound(w, r) - return "", false - } - if h.botBridge == nil { - http.Error(w, "picoclaw integration is not configured", http.StatusServiceUnavailable) - return "", false - } - if h.im != nil { - botID = h.im.ResolveUserID(botID) - } - if !h.validateServerAccessToken(r.Header.Get("Authorization")) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return "", false - } - return botID, true -} - -func (h *Handler) handleBotEvents(w http.ResponseWriter, r *http.Request, botID string) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming is not supported", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - - events, cancel := h.botBridge.Subscribe(botID) - defer func() { - cancel() - h.requeueBufferedBotEvents(botID, events) - }() - controller := http.NewResponseController(w) - - if _, err := io.WriteString(w, ": connected\n\n"); err != nil { - return - } - if err := flushBotSSE(controller, flusher); err != nil { - return - } - h.replayRecentBotMessages(botID, r.Header.Get("Last-Event-ID")) - heartbeat := time.NewTicker(botHeartbeatInterval) - defer heartbeat.Stop() - - for { - select { - case <-r.Context().Done(): - return - case <-heartbeat.C: - if err := writeBotSSEComment(w, controller, flusher, "heartbeat"); err != nil { - return - } - case evt, ok := <-events: - if !ok { - return - } - if err := writeBotSSEEvent(w, controller, flusher, evt); err != nil { - h.botBridge.Requeue(botID, evt) - return - } - h.botBridge.Ack(botID, evt.MessageID) - } - } -} - -func writeBotSSEEvent(w http.ResponseWriter, controller *http.ResponseController, fallback http.Flusher, evt im.BotEvent) error { - data, err := evt.MarshalJSONLine() - if err != nil { - return err - } - if id := botSSEID(evt.MessageID); id != "" { - if _, err := fmt.Fprintf(w, "id: %s\n", id); err != nil { - return err - } - } - if _, err := fmt.Fprintf(w, "event: message\ndata: %s\n\n", data); err != nil { - return err - } - return flushBotSSE(controller, fallback) -} - -func writeBotSSEComment(w http.ResponseWriter, controller *http.ResponseController, fallback http.Flusher, comment string) error { - if _, err := fmt.Fprintf(w, ": %s\n\n", comment); err != nil { - return err - } - return flushBotSSE(controller, fallback) -} - -func flushBotSSE(controller *http.ResponseController, fallback http.Flusher) error { - if controller != nil { - if err := controller.Flush(); err == nil { - return nil - } else if !errors.Is(err, http.ErrNotSupported) { - return err - } - } - if fallback == nil { - return nil - } - fallback.Flush() - return nil -} - -func (h *Handler) requeueBufferedBotEvents(botID string, events <-chan im.BotEvent) { - if h == nil || h.botBridge == nil { - return - } - for evt := range events { - h.botBridge.Requeue(botID, evt) - } -} - -func (h *Handler) replayRecentBotMessages(botID, lastEventID string) { - if h == nil || h.im == nil || h.botBridge == nil { - return - } - rooms := h.im.ListRoomsWithOptions(im.ListMessagesOptions{IncludeThreadReplies: true}) - cutoff := time.Now().UTC().Add(-botReplayWindow) - replayAfter, hasReplayCursor := replayCursor(rooms, lastEventID) - for _, room := range rooms { - for idx, message := range room.Messages { - if !message.CreatedAt.IsZero() && message.CreatedAt.Before(cutoff) { - continue - } - if hasReplayCursor && isAtOrBeforeReplayCursor(message, lastEventID, replayAfter) { - continue - } - if h.isAgentSender(message.SenderID) { - continue - } - if hasLaterMessageFrom(room.Messages[idx+1:], botID) { - continue - } - sender, ok := h.im.User(message.SenderID) - if !ok { - continue - } - if reason, ok, err := newConversationCommandReason(message.Content); err != nil { - slog.Warn("parse new conversation command failed", "bot_id", botID, "message_id", message.ID, "error", err) - h.botBridge.EnqueueMessageEvent(room, sender, message, botID) - continue - } else if ok { - missed := h.publishNewConversationBotEvent(context.Background(), room, sender, message, reason) - h.reconnectMissedBotAgents(sender.ID, missed) - continue - } - // Route replay through the bridge so the stable message ID remains the - // dedupe key for events already delivered live or drained from pending. - h.botBridge.EnqueueMessageEvent(room, sender, message, botID) - } - } -} - -func replayCursor(rooms []im.Room, lastEventID string) (time.Time, bool) { - lastEventID = strings.TrimSpace(lastEventID) - if lastEventID == "" { - return time.Time{}, false - } - for _, room := range rooms { - for _, message := range room.Messages { - if message.ID == lastEventID { - return message.CreatedAt, true - } - } - } - return time.Time{}, false -} - -func isAtOrBeforeReplayCursor(message im.Message, lastEventID string, replayAfter time.Time) bool { - if message.ID == strings.TrimSpace(lastEventID) { - return true - } - if replayAfter.IsZero() || message.CreatedAt.IsZero() { - return false - } - return !message.CreatedAt.After(replayAfter) -} - -func botSSEID(messageID string) string { - messageID = strings.TrimSpace(messageID) - messageID = strings.ReplaceAll(messageID, "\r", "") - messageID = strings.ReplaceAll(messageID, "\n", "") - return messageID -} - -func (h *Handler) reconnectMissedBotAgents(senderID string, botIDs []string) { - if h == nil || h.svc == nil || h.isAgentSender(senderID) || len(botIDs) == 0 { - return - } - seen := make(map[string]struct{}, len(botIDs)) - for _, botID := range botIDs { - botID = strings.TrimSpace(botID) - if botID == "" { - continue - } - if _, ok := seen[botID]; ok { - continue - } - seen[botID] = struct{}{} - if _, ok := h.svc.Agent(botID); !ok { - continue - } - go h.recoverMissedBotDelivery(botID) - } -} - -func (h *Handler) recoverMissedBotDelivery(botID string) { - if h == nil || h.svc == nil { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - view, err := h.svc.RuntimeView(ctx, botID) - if err != nil { - slog.Warn("bot delivery recovery failed", "agent_id", botID, "error", err) - return - } - if err := h.applyBotDeliveryRecoveryPolicy(ctx, view); err != nil { - slog.Warn("bot delivery recovery failed", "agent_id", botID, "runtime_kind", view.RuntimeKind, "state", view.State, "error", err) - } -} - -func (h *Handler) applyBotDeliveryRecoveryPolicy(ctx context.Context, view agent.RuntimeView) error { - if h == nil || h.svc == nil { - return nil - } - switch view.State { - case agentruntime.StateCreated, agentruntime.StateStopped, agentruntime.StateExited, agentruntime.StateFailed: - _, err := h.svc.Start(ctx, view.AgentID) - return err - case agentruntime.StateRunning: - _, err := h.svc.Start(ctx, view.AgentID) - return err - case "", agentruntime.StateUnknown: - fallthrough - default: - _, err := h.svc.Recreate(ctx, view.AgentID) - return err - } -} - -func (h *Handler) isAgentSender(senderID string) bool { - if h == nil || h.svc == nil { - return false - } - _, ok := h.svc.Agent(senderID) - return ok -} - -func hasLaterMessageFrom(messages []im.Message, senderID string) bool { - senderID = strings.TrimSpace(senderID) - if senderID == "" { - return false - } - for _, message := range messages { - if message.SenderID == senderID { - return true - } - } - return false -} - -func (h *Handler) handleBotSendMessage(w http.ResponseWriter, r *http.Request, botID string) { - if h.im == nil { - http.Error(w, "im service is not configured", http.StatusServiceUnavailable) - return - } - var req im.BotSendMessageRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) - return - } - roomID := req.ResolvedRoomID() - text := req.ResolvedText() - threadRootID := req.ResolvedThreadRootID() - - message, err := h.im.DeliverMessage(im.DeliverMessageRequest{ - RoomID: roomID, - SenderID: botID, - Content: text, - MessageID: req.MessageID, - ThreadRootID: threadRootID, - }) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - h.publishMessageCreated(roomID, botID, message) - h.publishThreadUpdated(roomID, message) - writeJSON(w, http.StatusOK, map[string]string{"message_id": message.ID}) -} - -func parseBotCompatibilityPath(path string) (botID, action string, ok bool) { - const prefix = "/api/bots/" - if !strings.HasPrefix(path, prefix) { - return "", "", false - } - - rest := strings.TrimPrefix(path, prefix) - parts := strings.Split(strings.Trim(rest, "/"), "/") - if len(parts) < 2 || parts[0] == "" { - return "", "", false - } - - botID = parts[0] - action = strings.Join(parts[1:], "/") - switch action { - case "events", "messages/send", "llm/models", "llm/v1/models", "llm/chat/completions", "llm/v1/chat/completions", "llm/responses", "llm/v1/responses": - return botID, action, true - default: - return "", "", false - } -} diff --git a/internal/api/conversation_command.go b/internal/api/conversation_command.go index 4a1140fa..d4f772d6 100644 --- a/internal/api/conversation_command.go +++ b/internal/api/conversation_command.go @@ -22,22 +22,24 @@ func newConversationCommandReason(content string) (string, bool, error) { return strings.TrimSpace(cmd.Body), true, nil } -func (h *Handler) publishNewConversationBotEvent(ctx context.Context, room im.Room, sender im.User, message im.Message, reason string) []string { - if h == nil || h.svc == nil || h.botBridge == nil { +func (h *Handler) publishNewConversationParticipantEvent(ctx context.Context, room im.Room, sender im.User, message im.Message, reason string) []string { + if h == nil || h.svc == nil || h.participantBridge == nil { return nil } var missed []string threadRootID := conversationThreadRootID(message) - for _, botID := range newConversationTargets(room, message, h.isAgentSender) { + for _, target := range h.newConversationBridgeTargets(room, message) { + participantID := target.bridgeID + agentID := h.runtimeAgentIDForBridgeID(participantID) action, err := h.svc.NewConversationAction(ctx, agent.NewConversationRequest{ Channel: csgclawchannel.ChannelID, - BotID: botID, + BotID: agentID, RoomID: room.ID, ThreadRootID: threadRootID, Reason: reason, }) if err != nil { - slog.Warn("new conversation action failed", "channel", csgclawchannel.ChannelID, "bot_id", botID, "room_id", room.ID, "error", err) + slog.Warn("new conversation action failed", "channel", csgclawchannel.ChannelID, "participant_id", participantID, "room_id", room.ID, "error", err) continue } switch action.Mode { @@ -45,18 +47,44 @@ func (h *Handler) publishNewConversationBotEvent(ctx context.Context, room im.Ro if action.BotEventText == "" { continue } - if !h.botBridge.EnqueueMessageEventWithText(room, sender, message, botID, action.BotEventText) { - missed = append(missed, botID) + if !h.enqueueParticipantMessageEventForBridgeTarget(room, sender, message, target, action.BotEventText) { + missed = append(missed, participantID) } case agent.NewConversationActionInternal: - if !h.botBridge.EnqueueMessageEvent(room, sender, message, botID) { - missed = append(missed, botID) + if !h.enqueueParticipantMessageEventForBridgeTarget(room, sender, message, target, "") { + missed = append(missed, participantID) } } } return missed } +func (h *Handler) newConversationBridgeTargets(room im.Room, message im.Message) []participantBridgeTarget { + if h == nil { + return nil + } + targets := make([]participantBridgeTarget, 0) + for _, target := range h.participantBridgeTargetsForRoom(room) { + if strings.TrimSpace(target.bridgeID) == "" || target.matches(message.SenderID) || !h.isAgentSender(target.bridgeID) { + continue + } + if !room.IsDirect && !messageMentionsBridgeTarget(message, target) { + continue + } + targets = append(targets, target) + } + return targets +} + +func messageMentionsBridgeTarget(message im.Message, target participantBridgeTarget) bool { + for _, mention := range message.Mentions { + if target.matches(mention.ID) { + return true + } + } + return false +} + func newConversationTargets(room im.Room, message im.Message, isAgent func(string) bool) []string { if isAgent == nil { return nil diff --git a/internal/api/feishu.go b/internal/api/feishu.go index a61a4e9f..559a3d76 100644 --- a/internal/api/feishu.go +++ b/internal/api/feishu.go @@ -22,6 +22,14 @@ func (h *Handler) handleFeishuBotByID(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleFeishuEvents(w http.ResponseWriter, r *http.Request, botID string) { + h.streamFeishuEvents(w, r, botID, true) +} + +func (h *Handler) handleFeishuParticipantEvents(w http.ResponseWriter, r *http.Request, targetID string) { + h.streamFeishuEvents(w, r, targetID, false) +} + +func (h *Handler) streamFeishuEvents(w http.ResponseWriter, r *http.Request, targetID string, resolveBotOpenID bool) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -34,10 +42,14 @@ func (h *Handler) handleFeishuEvents(w http.ResponseWriter, r *http.Request, bot http.Error(w, "feishu events are not configured", http.StatusServiceUnavailable) return } - botOpenID, _, err := h.feishu.ResolveBotOpenID(r.Context(), botID) - if err != nil { - http.Error(w, fmt.Sprintf("resolve feishu bot open_id: %v", err), http.StatusBadRequest) - return + targetID = strings.TrimSpace(targetID) + if resolveBotOpenID { + botOpenID, _, err := h.feishu.ResolveBotOpenID(r.Context(), targetID) + if err != nil { + http.Error(w, fmt.Sprintf("resolve feishu bot open_id: %v", err), http.StatusBadRequest) + return + } + targetID = strings.TrimSpace(botOpenID) } flusher, ok := w.(http.Flusher) @@ -72,7 +84,7 @@ func (h *Handler) handleFeishuEvents(w http.ResponseWriter, r *http.Request, bot if !ok { return } - if !feishuEventMentions(evt, botOpenID) { + if !feishuEventMentions(evt, targetID) { continue } data, err := json.Marshal(evt) diff --git a/internal/api/feishu_config_test.go b/internal/api/feishu_config_test.go index 7b1e46ab..b981361c 100644 --- a/internal/api/feishu_config_test.go +++ b/internal/api/feishu_config_test.go @@ -22,7 +22,7 @@ func TestFeishuChannelConfigPutWritesStandaloneConfigAndReloads(t *testing.T) { feishuSvc := feishu.NewService() feishuSvc.SetConfigPath(configPath) - h := NewHandlerWithBotAndAuth(nil, nil, nil, nil, nil, feishuSvc, nil, "secret", false) + h := NewHandlerWithAuth(nil, nil, nil, nil, feishuSvc, nil, "secret", false) body := []byte(`{"bot_id":"u-dev","app_id":"cli_dev","app_secret":"dev-secret","admin_open_id":"ou_admin"}`) req := httptest.NewRequest(http.MethodPut, "/api/v1/channels/feishu/config", bytes.NewReader(body)) @@ -81,7 +81,7 @@ func TestFeishuChannelConfigGetMasksSecret(t *testing.T) { feishuSvc := feishu.NewService() feishuSvc.SetConfigPath(configPath) - h := NewHandlerWithBotAndAuth(nil, nil, nil, nil, nil, feishuSvc, nil, "secret", false) + h := NewHandlerWithAuth(nil, nil, nil, nil, feishuSvc, nil, "secret", false) req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/config?bot_id=u-dev", nil) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() @@ -105,7 +105,7 @@ func TestChannelsReloadDoesNotDuplicateAuthorization(t *testing.T) { feishuSvc := feishu.NewService() feishuSvc.SetConfigPath(configPath) - h := NewHandlerWithBotAndAuth(nil, nil, nil, nil, nil, feishuSvc, nil, "secret", false) + h := NewHandlerWithAuth(nil, nil, nil, nil, feishuSvc, nil, "secret", false) req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/config", nil) rec := httptest.NewRecorder() h.Routes().ServeHTTP(rec, req) @@ -115,7 +115,7 @@ func TestChannelsReloadDoesNotDuplicateAuthorization(t *testing.T) { } func TestLegacyChannelsReloadRouteIsNotRegistered(t *testing.T) { - h := NewHandlerWithBotAndAuth(nil, nil, nil, nil, nil, feishu.NewService(), nil, "secret", false) + h := NewHandlerWithAuth(nil, nil, nil, nil, feishu.NewService(), nil, "secret", false) req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/reload", nil) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() @@ -126,7 +126,7 @@ func TestLegacyChannelsReloadRouteIsNotRegistered(t *testing.T) { } func TestLegacyFeishuConfigBotIDPathIsNotRegistered(t *testing.T) { - h := NewHandlerWithBotAndAuth(nil, nil, nil, nil, nil, feishu.NewService(), nil, "secret", false) + h := NewHandlerWithAuth(nil, nil, nil, nil, feishu.NewService(), nil, "secret", false) req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/config/u-dev", nil) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() diff --git a/internal/api/handler.go b/internal/api/handler.go index 80178863..4f33618d 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -15,14 +15,14 @@ import ( "csgclaw/internal/agent" "csgclaw/internal/apitypes" - "csgclaw/internal/bot" csgclawchannel "csgclaw/internal/channel/csgclaw" - "csgclaw/internal/channel/csgclaw/notification_bot" + "csgclaw/internal/channel/csgclaw/notification" "csgclaw/internal/channel/feishu" "csgclaw/internal/config" "csgclaw/internal/hub" "csgclaw/internal/im" "csgclaw/internal/llm" + "csgclaw/internal/participant" "csgclaw/internal/sandbox" "csgclaw/internal/sandboxproviders" "csgclaw/internal/team" @@ -33,12 +33,12 @@ import ( type Handler struct { svc *agent.Service - botSvc *bot.Service + participant *participant.Service im *im.Service csgclaw *csgclawchannel.Service imBus *im.Bus imProvisioner *im.Provisioner - botBridge *im.BotBridge + participantBridge *im.ParticipantBridge feishu *feishu.Service llm *llm.Service hub *hub.Service @@ -51,7 +51,7 @@ type Handler struct { upgradeConfigPath string upgradeApply func(upgrade.ApplyHelperOptions) error localRuntimeImages func(context.Context, config.Config) ([]string, error) - notificationDeliver notification_bot.Fanouter + notificationDeliver notification.Fanouter activityDecider ActivityDecider } @@ -117,6 +117,7 @@ type agentResponse struct { AgentProfile agent.AgentProfileView `json:"agent_profile,omitempty"` ProfileComplete bool `json:"profile_complete"` DetectionResults []agent.ProfileDetectionResult `json:"detection_results,omitempty"` + Participants []apitypes.Participant `json:"participants,omitempty"` } func (h *Handler) handleBootstrapConfig(w http.ResponseWriter, r *http.Request) { @@ -304,31 +305,22 @@ type addRoomMembersRequest struct { Locale string `json:"locale"` } -func NewHandler(svc *agent.Service, imSvc *im.Service, imBus *im.Bus, botBridge *im.BotBridge, feishu *feishu.Service, llmSvc *llm.Service) *Handler { - return NewHandlerWithBotAndAccessToken(svc, nil, imSvc, imBus, botBridge, feishu, llmSvc, "") +func NewHandler(svc *agent.Service, imSvc *im.Service, imBus *im.Bus, participantBridge *im.ParticipantBridge, feishu *feishu.Service, llmSvc *llm.Service) *Handler { + return NewHandlerWithAccessToken(svc, imSvc, imBus, participantBridge, feishu, llmSvc, "") } -func NewHandlerWithBot(svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, imBus *im.Bus, botBridge *im.BotBridge, feishu *feishu.Service, llmSvc *llm.Service) *Handler { - return NewHandlerWithBotAndAccessToken(svc, botSvc, imSvc, imBus, botBridge, feishu, llmSvc, "") +func NewHandlerWithAccessToken(svc *agent.Service, imSvc *im.Service, imBus *im.Bus, participantBridge *im.ParticipantBridge, feishu *feishu.Service, llmSvc *llm.Service, serverAccessToken string) *Handler { + return NewHandlerWithAuth(svc, imSvc, imBus, participantBridge, feishu, llmSvc, serverAccessToken, false) } -func NewHandlerWithBotAndAccessToken(svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, imBus *im.Bus, botBridge *im.BotBridge, feishu *feishu.Service, llmSvc *llm.Service, serverAccessToken string) *Handler { - return NewHandlerWithBotAndAuth(svc, botSvc, imSvc, imBus, botBridge, feishu, llmSvc, serverAccessToken, false) -} - -func NewHandlerWithBotAndAuth(svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, imBus *im.Bus, botBridge *im.BotBridge, feishu *feishu.Service, llmSvc *llm.Service, serverAccessToken string, serverNoAuth bool) *Handler { - if botSvc != nil { - botSvc.SetDependencies(svc, imSvc, feishu) - botSvc.SetIMBus(imBus) - } +func NewHandlerWithAuth(svc *agent.Service, imSvc *im.Service, imBus *im.Bus, participantBridge *im.ParticipantBridge, feishu *feishu.Service, llmSvc *llm.Service, serverAccessToken string, serverNoAuth bool) *Handler { h := &Handler{ svc: svc, - botSvc: botSvc, im: imSvc, csgclaw: csgclawchannel.NewService(imSvc), imBus: imBus, imProvisioner: im.NewProvisioner(imSvc, imBus), - botBridge: botBridge, + participantBridge: participantBridge, feishu: feishu, llm: llmSvc, serverAccessToken: serverAccessToken, @@ -338,12 +330,18 @@ func NewHandlerWithBotAndAuth(svc *agent.Service, botSvc *bot.Service, imSvc *im return h } -func (h *Handler) SetNotificationDeliver(d notification_bot.Fanouter) { +func (h *Handler) SetNotificationDeliver(d notification.Fanouter) { if h != nil { h.notificationDeliver = d } } +func (h *Handler) SetParticipantService(svc *participant.Service) { + if h != nil { + h.participant = svc + } +} + func (h *Handler) SetActivityDecider(decider ActivityDecider) { if h != nil { h.activityDecider = decider @@ -482,134 +480,6 @@ func (h *Handler) handleUpgradeApply(w http.ResponseWriter, r *http.Request) { }) } -func (h *Handler) handleBots(w http.ResponseWriter, r *http.Request) { - if h.botSvc == nil { - http.Error(w, "bot service is not configured", http.StatusServiceUnavailable) - return - } - h.botSvc.SetIMBus(h.imBus) - channelName := botChannelName(r) - - switch r.Method { - case http.MethodGet: - bots, err := h.botSvc.List(channelName, r.URL.Query().Get("role"), r.URL.Query().Get("type")) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, http.StatusOK, bots) - case http.MethodPost: - var req apitypes.CreateBotRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) - return - } - req.Channel = channelName - req.Type = bot.NormalizeBotType(req.Type) - if req.Type == bot.BotTypeNotification { - created, err := h.botSvc.CreateNotificationBot(r.Context(), req) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, http.StatusCreated, created) - return - } - created, err := h.botSvc.Create(r.Context(), req) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, http.StatusCreated, created) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleBotByID(w http.ResponseWriter, r *http.Request) { - if h.botSvc == nil { - http.Error(w, "bot service is not configured", http.StatusServiceUnavailable) - return - } - - id := pathValue(r, "id") - if id == "" { - http.NotFound(w, r) - return - } - channelName := botChannelName(r) - - stored, found, err := h.botSvc.BotByChannelID(channelName, id) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if !found { - http.NotFound(w, r) - return - } - - switch r.Method { - case http.MethodGet, http.MethodPatch: - if !bot.IsNotificationBot(stored) { - http.Error(w, "method not allowed for this bot type", http.StatusMethodNotAllowed) - return - } - } - switch r.Method { - case http.MethodGet: - b, err := h.botSvc.GetNotificationBot(channelName, id) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - writeJSON(w, http.StatusOK, b) - case http.MethodPatch: - var patch apitypes.PatchNotificationBotRequest - if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { - http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) - return - } - updated, err := h.botSvc.PatchNotificationBot(r.Context(), channelName, id, bot.CreateRequest{ - Name: patch.Name, - Description: patch.Description, - Avatar: patch.Avatar, - RuntimeOptions: patch.RuntimeOptions, - }) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - h.publishUpdatedBotUser(updated) - writeJSON(w, http.StatusOK, updated) - case http.MethodDelete: - if err := h.botSvc.Delete(r.Context(), channelName, id); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusNoContent) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func botChannelName(r *http.Request) string { - if channel := pathValue(r, "channel"); channel != "" { - return channel - } - if r == nil { - return "" - } - switch { - case strings.HasPrefix(r.URL.Path, "/api/v1/channels/csgclaw/"): - return "csgclaw" - case strings.HasPrefix(r.URL.Path, "/api/v1/channels/feishu/"): - return "feishu" - default: - return "" - } -} - func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { if h.svc == nil { http.Error(w, "agent service is not configured", http.StatusServiceUnavailable) @@ -621,7 +491,7 @@ func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - writeJSON(w, http.StatusOK, presentAgents(h.svc.List())) + writeJSON(w, http.StatusOK, h.presentAgentsForRequest(r, h.svc.List())) case http.MethodPost: h.handleCreateAgentWorker(w, r) default: @@ -652,7 +522,7 @@ func (h *Handler) handleAgentByID(w http.ResponseWriter, r *http.Request) { http.Error(w, "agent not found", http.StatusNotFound) return } - writeJSON(w, http.StatusOK, presentAgent(a)) + writeJSON(w, http.StatusOK, h.presentAgentForRequest(r, a)) case http.MethodPatch: var req agent.UpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -707,32 +577,6 @@ func (h *Handler) publishUpdatedAgentUser(updated agent.Agent) { } } -func (h *Handler) publishUpdatedBotUser(updated bot.Bot) { - if h == nil || h.im == nil { - return - } - id := strings.TrimSpace(updated.UserID) - if id == "" { - id = strings.TrimSpace(updated.ID) - } - user, ok, err := h.im.UpdateAgentUser(im.UpdateAgentUserRequest{ - ID: id, - Name: updated.Name, - Role: updated.Role, - Avatar: updated.Avatar, - }) - if err != nil || !ok { - return - } - if h.imBus != nil { - userCopy := user - h.imBus.Publish(im.Event{ - Type: im.EventTypeUserUpdated, - User: &userCopy, - }) - } -} - func (h *Handler) handleAgentProfileByID(w http.ResponseWriter, r *http.Request) { id := pathValue(r, "id") if id == "" { @@ -1576,6 +1420,7 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(req.Name) handle := strings.TrimSpace(req.Handle) role := strings.TrimSpace(req.Role) + id = h.resolveCSGClawParticipantUserID(id) if id == "" { http.Error(w, "id is required", http.StatusBadRequest) @@ -1589,20 +1434,36 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { handle = name } - if h.botSvc != nil && h.svc != nil && shouldCreateWorkerForUser(id, role) { - h.botSvc.SetDependencies(h.svc, h.im, h.feishu) - h.botSvc.SetIMBus(h.imBus) - created, err := h.botSvc.Create(r.Context(), apitypes.CreateBotRequest{ - ID: id, + if h.participant != nil && h.svc != nil && shouldCreateWorkerForUser(id, role) { + participantID := workerParticipantIDFromUserID(id) + created, err := h.participant.Create(r.Context(), participant.CreateRequest{ + ID: participantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, Name: name, - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), + ChannelUser: participant.ChannelUserSpec{ + Ref: id, + Kind: participant.ChannelUserKindLocalUserID, + }, + AgentBinding: participant.AgentBindingSpec{ + Mode: participant.BindingModeCreate, + AgentID: id, + Agent: &agent.CreateAgentSpec{ + ID: id, + Name: name, + Role: agent.RoleWorker, + }, + }, }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if user, ok := h.im.User(created.UserID); ok { + if user, ok := h.im.User(created.ChannelUserRef); ok { + h.publishUserEvent(im.EventTypeUserCreated, user) + if room, ok := h.directRoomWithMembers("u-admin", user.ID); ok { + h.publishRoomEvent(im.EventTypeRoomCreated, room) + } writeJSON(w, http.StatusCreated, user) return } @@ -1641,6 +1502,42 @@ func shouldCreateWorkerForUser(id, role string) bool { } } +func workerParticipantIDFromUserID(id string) string { + id = strings.TrimSpace(id) + withoutPrefix := strings.TrimPrefix(id, "u-") + if withoutPrefix != "" && withoutPrefix != id { + return withoutPrefix + } + return id +} + +func (h *Handler) directRoomWithMembers(left, right string) (im.Room, bool) { + if h == nil || h.im == nil { + return im.Room{}, false + } + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + for _, room := range h.im.ListRooms() { + if !room.IsDirect { + continue + } + if roomHasMember(room.Members, left) && roomHasMember(room.Members, right) { + return room, true + } + } + return im.Room{}, false +} + +func roomHasMember(members []string, id string) bool { + id = strings.TrimSpace(id) + for _, member := range members { + if strings.TrimSpace(member) == id { + return true + } + } + return false +} + func (h *Handler) handleCreateMessage(w http.ResponseWriter, r *http.Request) { channel, ok := h.requireLocalChannel(w) if !ok { @@ -1657,6 +1554,7 @@ func (h *Handler) handleCreateMessage(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + serviceReq = h.resolveCSGClawParticipantMessageRequest(serviceReq) message, err := channel.SendMessage(serviceReq) if err != nil { @@ -1679,6 +1577,8 @@ func (h *Handler) handleCreateRoom(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) return } + req.CreatorID = h.resolveCSGClawParticipantUserID(req.CreatorID) + req.MemberIDs = h.resolveCSGClawParticipantUserIDs(req.MemberIDs) room, err := channel.CreateRoom(req) if err != nil { @@ -1738,6 +1638,8 @@ func (h *Handler) handleAddRoomMembers(w http.ResponseWriter, r *http.Request, p http.Error(w, err.Error(), http.StatusBadRequest) return } + serviceReq.InviterID = h.resolveCSGClawParticipantUserID(serviceReq.InviterID) + serviceReq.UserIDs = h.resolveCSGClawParticipantUserIDs(serviceReq.UserIDs) room, err := channel.AddRoomMembers(serviceReq) if err != nil { @@ -1885,6 +1787,93 @@ func presentAgents(items []agent.Agent) []agentResponse { return out } +func (h *Handler) presentAgentsForRequest(r *http.Request, items []agent.Agent) []agentResponse { + out := presentAgents(items) + if !includeParticipants(r) || h == nil || h.participant == nil { + return out + } + byAgent := participantsByAgentID(h.participant.List(participant.ListOptions{})) + for i := range out { + out[i].Participants = byAgent[out[i].ID] + } + return out +} + +func (h *Handler) presentAgentForRequest(r *http.Request, item agent.Agent) agentResponse { + resp := presentAgent(item) + if !includeParticipants(r) || h == nil || h.participant == nil { + return resp + } + resp.Participants = h.participant.List(participant.ListOptions{AgentID: item.ID}) + return resp +} + +func includeParticipants(r *http.Request) bool { + if r == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include_participants")), "true") +} + +func participantsByAgentID(items []apitypes.Participant) map[string][]apitypes.Participant { + out := make(map[string][]apitypes.Participant) + for _, item := range items { + if strings.TrimSpace(item.AgentID) == "" { + continue + } + out[item.AgentID] = append(out[item.AgentID], item) + } + return out +} + +func (h *Handler) resolveCSGClawParticipantMessageRequest(req im.CreateMessageRequest) im.CreateMessageRequest { + req.SenderID = h.resolveCSGClawParticipantUserID(req.SenderID) + req.MentionID = h.resolveCSGClawParticipantUserID(req.MentionID) + return req +} + +func (h *Handler) resolveCSGClawParticipantUserIDs(ids []string) []string { + if len(ids) == 0 { + return nil + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + if resolved := h.resolveCSGClawParticipantUserID(id); resolved != "" { + out = append(out, resolved) + } + } + return out +} + +func (h *Handler) resolveCSGClawParticipantUserID(id string) string { + id = strings.TrimSpace(id) + if id == "" || h == nil || h.participant == nil { + if id == agent.ManagerUserID { + return agent.ManagerParticipantID + } + return id + } + item, ok := h.participant.Get(participant.ChannelCSGClaw, id) + if ok { + if ref := strings.TrimSpace(item.ChannelUserRef); ref != "" { + return ref + } + return id + } + for _, candidate := range h.participant.List(participant.ListOptions{Channel: participant.ChannelCSGClaw, AgentID: id}) { + if ref := strings.TrimSpace(candidate.ChannelUserRef); ref != "" { + return ref + } + if participantID := strings.TrimSpace(candidate.ID); participantID != "" { + return participantID + } + } + if id == agent.ManagerUserID { + return agent.ManagerParticipantID + } + return id +} + func presentAgent(item agent.Agent) agentResponse { av := agent.RedactedProfileViewForAgent(item) if strings.TrimSpace(av.Name) == strings.TrimSpace(item.Name) { diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index f87fce1a..18ad81b0 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -17,12 +17,12 @@ import ( "csgclaw/internal/agent" "csgclaw/internal/apitypes" "csgclaw/internal/app/runtimewiring" - "csgclaw/internal/bot" "csgclaw/internal/channel/feishu" "csgclaw/internal/config" "csgclaw/internal/hub" "csgclaw/internal/im" "csgclaw/internal/llm" + "csgclaw/internal/participant" agentruntime "csgclaw/internal/runtime" "csgclaw/internal/runtime/openclawsandbox" "csgclaw/internal/runtime/picoclawsandbox" @@ -160,35 +160,6 @@ func (f *fakeCodexBridgeController) StopAgent(agentID string) { f.stopCalls = append(f.stopCalls, agentID) } -func TestParseBotCompatibilityPath(t *testing.T) { - tests := []struct { - path string - wantBotID string - wantAction string - wantOK bool - }{ - {path: "/api/bots/u-manager/events", wantBotID: "u-manager", wantAction: "events", wantOK: true}, - {path: "/api/bots/u-manager/messages/send", wantBotID: "u-manager", wantAction: "messages/send", wantOK: true}, - {path: "/api/bots/u-manager/llm/models", wantBotID: "u-manager", wantAction: "llm/models", wantOK: true}, - {path: "/api/bots/u-manager/llm/v1/models", wantBotID: "u-manager", wantAction: "llm/v1/models", wantOK: true}, - {path: "/api/bots/u-manager/llm/chat/completions", wantBotID: "u-manager", wantAction: "llm/chat/completions", wantOK: true}, - {path: "/api/bots/u-manager/llm/v1/chat/completions", wantBotID: "u-manager", wantAction: "llm/v1/chat/completions", wantOK: true}, - {path: "/api/bots/u-manager/llm/responses", wantBotID: "u-manager", wantAction: "llm/responses", wantOK: true}, - {path: "/api/bots/u-manager/llm/v1/responses", wantBotID: "u-manager", wantAction: "llm/v1/responses", wantOK: true}, - {path: "/api/bots/u-manager", wantOK: false}, - {path: "/api/bots//events", wantOK: false}, - } - - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - gotBotID, gotAction, gotOK := parseBotCompatibilityPath(tt.path) - if gotBotID != tt.wantBotID || gotAction != tt.wantAction || gotOK != tt.wantOK { - t.Fatalf("parseBotCompatibilityPath(%q) = (%q, %q, %v), want (%q, %q, %v)", tt.path, gotBotID, gotAction, gotOK, tt.wantBotID, tt.wantAction, tt.wantOK) - } - }) - } -} - func TestDeriveAgentHandle(t *testing.T) { tests := []struct { name string @@ -295,73 +266,6 @@ func TestBootstrapConfigViewUsesServerUpgradeVisibility(t *testing.T) { } } -func TestHandleAgentImageCandidatesReturnsLocalSandboxImages(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.toml") - if err := (config.Config{ - Server: config.ServerConfig{ - ListenAddr: "127.0.0.1:18080", - AccessToken: "token", - }, - Sandbox: config.SandboxConfig{ - Provider: config.DockerProvider, - DockerCLIPath: "/custom/docker", - }, - }).Save(configPath); err != nil { - t.Fatalf("Save(config) error = %v", err) - } - - srv := &Handler{ - configPath: configPath, - localRuntimeImages: func(_ context.Context, cfg config.Config) ([]string, error) { - if got, want := cfg.Sandbox.Provider, config.DockerProvider; got != want { - t.Fatalf("cfg.Sandbox.Provider = %q, want %q", got, want) - } - if got, want := cfg.Sandbox.DockerCLIPath, "/custom/docker"; got != want { - t.Fatalf("cfg.Sandbox.DockerCLIPath = %q, want %q", got, want) - } - return []string{"registry.example/picoclaw:2026.5.27", "registry.example/picoclaw:2026.5.22"}, nil - }, - } - - rec := httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/agents/image-candidates", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var got []string - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - want := []string{"registry.example/picoclaw:2026.5.27", "registry.example/picoclaw:2026.5.22"} - if !slices.Equal(got, want) { - t.Fatalf("agent image candidates = %#v, want %#v", got, want) - } -} - -func TestListLocalRuntimeImagesUsesSandboxProviderCapability(t *testing.T) { - var gotHome string - provider := &sandboxtest.Provider{ - Images: []string{"registry.example/picoclaw:2026.5.27"}, - ListImagesFunc: func(_ context.Context, homeDir string) ([]string, error) { - gotHome = homeDir - return []string{"registry.example/picoclaw:2026.5.27"}, nil - }, - } - - got, err := listLocalRuntimeImagesWithProvider(context.Background(), provider) - if err != nil { - t.Fatalf("listLocalRuntimeImagesWithProvider() error = %v", err) - } - want := []string{"registry.example/picoclaw:2026.5.27"} - if !slices.Equal(got, want) { - t.Fatalf("listLocalRuntimeImagesWithProvider() = %#v, want %#v", got, want) - } - wantHomeSuffix := filepath.Join(config.AppDirName, "agents", agent.ManagerName, config.RuntimeHomeDirName) - if !strings.HasSuffix(gotHome, wantHomeSuffix) { - t.Fatalf("sandbox home = %q, want suffix %q", gotHome, wantHomeSuffix) - } -} - func TestHandleFeishuRoomsMembers(t *testing.T) { feishuSvc := feishu.NewServiceWithCreateChatAndAddMembers( map[string]feishu.AppConfig{ @@ -403,707 +307,87 @@ func TestHandleFeishuRoomsMembers(t *testing.T) { } var room im.Room if err := json.NewDecoder(rec.Body).Decode(&room); err != nil { - t.Fatalf("decode room: %v", err) - } - - addReq := strings.NewReader(`{"user_ids":["fsu-alice"]}`) - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/rooms/"+room.ID+"/members", addReq)) - if rec.Code != http.StatusOK { - t.Fatalf("add status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/rooms/"+room.ID+"/members", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("members status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var members []im.User - if err := json.NewDecoder(rec.Body).Decode(&members); err != nil { - t.Fatalf("decode members: %v", err) - } - if len(members) != 2 { - t.Fatalf("members = %+v, want two users", members) - } - if members[0].ID != "u-manager" || members[1].ID != "fsu-alice" { - t.Fatalf("members = %+v, want bot ids", members) - } -} - -func TestHandleRoomsMembersListsCsgclawMembers(t *testing.T) { - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-admin", - Users: []im.User{ - {ID: "u-admin", Name: "Admin", Handle: "admin", Role: "admin"}, - {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"}, - }, - Rooms: []im.Room{ - {ID: "room-1", Title: "Ops", Members: []string{"u-admin", "u-alice"}}, - }, - }) - srv := &Handler{im: imSvc} - - rec := httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-1/members", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("members status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - var members []im.User - if err := json.NewDecoder(rec.Body).Decode(&members); err != nil { - t.Fatalf("decode members: %v", err) - } - if len(members) != 2 || members[0].ID != "u-admin" || members[1].ID != "u-alice" { - t.Fatalf("members = %+v, want room members", members) - } -} - -func TestHandleRoomsMembersAddsCsgclawMember(t *testing.T) { - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-admin", - Users: []im.User{ - {ID: "u-admin", Name: "Admin", Handle: "admin", Role: "admin"}, - {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"}, - }, - Rooms: []im.Room{ - {ID: "room-1", Title: "Ops", Members: []string{"u-admin"}}, - }, - }) - srv := &Handler{im: imSvc} - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/room-1/members", strings.NewReader(`{"inviter_id":"u-admin","user_ids":["u-alice"]}`)) - srv.Routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("add status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - var room im.Room - if err := json.NewDecoder(rec.Body).Decode(&room); err != nil { - t.Fatalf("decode room: %v", err) - } - if len(room.Members) != 2 || room.Members[1] != "u-alice" { - t.Fatalf("members = %+v, want u-admin and u-alice", room.Members) - } -} - -func TestHandleBotsListUsesChannelPath(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, []bot.Bot{ - { - ID: "bot-csgclaw", - Name: "CSGClaw Bot", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - AgentID: "agent-csgclaw", - UserID: "user-csgclaw", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-feishu", - Name: "Feishu Bot", - Role: string(bot.RoleManager), - Channel: string(bot.ChannelFeishu), - AgentID: "agent-feishu", - UserID: "user-feishu", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - })} - - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var got []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - if len(got) != 1 || got[0].ID != "bot-csgclaw" { - t.Fatalf("bots = %+v, want only csgclaw bot", got) - } -} - -func TestHandleBotsListFiltersByChannel(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, []bot.Bot{ - { - ID: "bot-csgclaw", - Name: "CSGClaw Bot", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-feishu", - Name: "Feishu Bot", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelFeishu), - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - })} - - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var got []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - if len(got) != 1 || got[0].ID != "bot-csgclaw" { - t.Fatalf("bots = %+v, want only bot-csgclaw", got) - } -} - -func TestHandleBotsListFiltersByRole(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, []bot.Bot{ - { - ID: "bot-manager", - Name: "Manager Bot", - Role: string(bot.RoleManager), - Channel: string(bot.ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-worker", - Name: "Worker Bot", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - })} - - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots?role=worker", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var got []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - if len(got) != 1 || got[0].ID != "bot-worker" { - t.Fatalf("bots = %+v, want only bot-worker", got) - } -} - -func TestHandleBotsListRejectsInvalidChannel(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, nil)} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/unknown/bots", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) - } -} - -func TestHandleBotsListRejectsInvalidRole(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, nil)} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots?role=agent", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) - } -} - -func TestHandleBotsListRequiresService(t *testing.T) { - srv := &Handler{} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusServiceUnavailable { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusServiceUnavailable, rec.Body.String()) - } -} - -func TestHandleBotsCreateCSGClawWorker(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc, _ := mustNewSeededServiceWithPath(t, nil) - imSvc := im.NewService() - bus := im.NewBus() - events, cancel := bus.Subscribe() - defer cancel() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - imBus: bus, - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","description":"test lead","image":"agent-image:1","avatar":"avatar/cartoon-3.png","role":"worker","runtime_kind":"picoclaw_sandbox","agent_profile":{"provider":"csghub_lite","model_id":"glm-4.5","reasoning_effort":"high"}}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - var created bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { - t.Fatalf("decode response: %v", err) - } - if created.ID != "u-alice" || created.AgentID != "u-alice" || created.UserID != "u-alice" { - t.Fatalf("created bot = %+v, want u-alice IDs", created) - } - if created.Description != "test lead" { - t.Fatalf("created bot description = %q, want test lead", created.Description) - } - if created.Avatar != "avatar/cartoon-3.png" { - t.Fatalf("created bot avatar = %q, want avatar/cartoon-3.png", created.Avatar) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list bots status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var bots []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&bots); err != nil { - t.Fatalf("decode bots response: %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-alice" { - t.Fatalf("bots = %+v, want u-alice", bots) - } - if bots[0].Description != "test lead" { - t.Fatalf("bots[0].Description = %q, want test lead", bots[0].Description) - } - if bots[0].Avatar != "avatar/cartoon-3.png" { - t.Fatalf("bots[0].Avatar = %q, want avatar/cartoon-3.png", bots[0].Avatar) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list agents status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var agents []map[string]any - if err := json.NewDecoder(rec.Body).Decode(&agents); err != nil { - t.Fatalf("decode agents response: %v", err) - } - if len(agents) != 1 || agents[0]["id"] != "u-alice" { - t.Fatalf("agents = %+v, want u-alice", agents) - } - if agents[0]["image"] != "agent-image:1" { - t.Fatalf("agents[0].image = %#v, want agent-image:1", agents[0]["image"]) - } - if agents[0]["avatar"] != "avatar/cartoon-3.png" { - t.Fatalf("agents[0].avatar = %#v, want avatar/cartoon-3.png", agents[0]["avatar"]) - } - profile, ok := agents[0]["agent_profile"].(map[string]any) - if !ok || profile["provider"] != agent.ProviderCSGHubLite || profile["model_id"] != "glm-4.5" { - t.Fatalf("agent_profile = %#v, want csghub_lite/glm-4.5", agents[0]["agent_profile"]) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list users status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var users []im.User - if err := json.NewDecoder(rec.Body).Decode(&users); err != nil { - t.Fatalf("decode users response: %v", err) - } - if !containsUser(users, "u-alice") { - t.Fatalf("users = %+v, want u-alice", users) - } - for _, user := range users { - if user.ID == "u-alice" && user.Avatar != "avatar/cartoon-3.png" { - t.Fatalf("user avatar = %q, want avatar/cartoon-3.png", user.Avatar) - } - } - rooms := imSvc.ListRooms() - if len(rooms) != 1 || !containsMember(rooms[0].Members, "u-admin") || !containsMember(rooms[0].Members, "u-alice") { - t.Fatalf("rooms = %+v, want bootstrap room with admin and u-alice", rooms) - } - first := mustReceiveIMEvent(t, events) - if first.Type != im.EventTypeUserCreated || first.User == nil || first.User.ID != "u-alice" { - t.Fatalf("first event = %+v, want user_created for u-alice", first) - } - second := mustReceiveIMEvent(t, events) - if second.Type != im.EventTypeRoomCreated || second.Room == nil { - t.Fatalf("second event = %+v, want room_created with room payload", second) - } - third := mustReceiveIMEventWithin(t, events, 2*time.Second) - if third.Type != im.EventTypeMessageCreated || third.Message == nil { - t.Fatalf("third event = %+v, want bootstrap message", third) - } -} - -func TestHandleBotsCreateCodexWorkerEnsuresCodexBridge(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc, err := agent.NewService( - config.ModelConfig{ - Provider: config.ProviderLLMAPI, - BaseURL: "http://127.0.0.1:4000", - APIKey: "sk-test", - ModelID: "model-1", - }, - config.ServerConfig{}, "manager-image:test", "", - agent.WithRuntime(fakeCompatRuntime{ - kind: agent.RuntimeKindCodex, - new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { - return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "codex-" + spec.AgentName}, nil - }, - }), - ) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - - imSvc := im.NewService() - bus := im.NewBus() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - bridge := &fakeCodexBridgeController{} - agentSvc.SetLifecycleObserver(bridge) - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - imBus: bus, - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","role":"worker","runtime_kind":"codex"}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - if len(bridge.ensureCalls) != 1 { - t.Fatalf("EnsureAgent() calls = %d, want 1", len(bridge.ensureCalls)) - } - if bridge.ensureCalls[0].ID != "u-alice" || bridge.ensureCalls[0].RuntimeKind != agent.RuntimeKindCodex { - t.Fatalf("EnsureAgent() got %+v, want codex worker u-alice", bridge.ensureCalls[0]) - } -} - -func TestHandleBotsCreateFeishuWorker(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc, _ := mustNewSeededServiceWithPath(t, nil) - feishuSvc := feishu.NewService() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, nil, feishuSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - feishu: feishuSvc, - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/bots", strings.NewReader(`{"name":"alice","image":"agent-image:1","role":"worker","runtime_kind":"picoclaw_sandbox"}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - var created bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { - t.Fatalf("decode response: %v", err) - } - if created.ID != "u-alice" || created.AgentID != "u-alice" || created.UserID != "u-alice" || created.Channel != "feishu" { - t.Fatalf("created bot = %+v, want feishu u-alice IDs", created) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list bots status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var bots []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&bots); err != nil { - t.Fatalf("decode bots response: %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-alice" { - t.Fatalf("bots = %+v, want u-alice", bots) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/users", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list feishu users status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var users []im.User - if err := json.NewDecoder(rec.Body).Decode(&users); err != nil { - t.Fatalf("decode users response: %v", err) - } - if !containsUser(users, "u-alice") { - t.Fatalf("feishu users = %+v, want u-alice", users) - } -} - -func TestHandleBotsCreateRejectsDuplicateWorkerNameInSameChannel(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc, _ := mustNewSeededServiceWithPath(t, nil) - imSvc := im.NewService() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - } - - first := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","image":"agent-image:1","role":"worker","runtime_kind":"picoclaw_sandbox"}`)) - firstRec := httptest.NewRecorder() - srv.Routes().ServeHTTP(firstRec, first) - if firstRec.Code != http.StatusCreated { - t.Fatalf("first status = %d, want %d; body=%s", firstRec.Code, http.StatusCreated, firstRec.Body.String()) - } - - second := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","image":"agent-image:1","role":"worker","runtime_kind":"picoclaw_sandbox"}`)) - secondRec := httptest.NewRecorder() - srv.Routes().ServeHTTP(secondRec, second) - if secondRec.Code != http.StatusBadRequest { - t.Fatalf("second status = %d, want %d; body=%s", secondRec.Code, http.StatusBadRequest, secondRec.Body.String()) - } - if !strings.Contains(secondRec.Body.String(), `bot name "alice" already exists in channel "csgclaw"`) { - t.Fatalf("second body = %q, want duplicate name error", secondRec.Body.String()) - } -} - -func TestHandleBotsCreateCSGClawManagerBindsBootstrappedAgent(t *testing.T) { - agentSvc := mustNewSeededService(t, []agent.Agent{ - { - ID: agent.ManagerUserID, - Name: agent.ManagerName, - Role: agent.RoleManager, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - imSvc := im.NewService() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"manager","role":"manager"}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - var created bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { - t.Fatalf("decode response: %v", err) - } - if created.ID != agent.ManagerUserID || created.AgentID != agent.ManagerUserID || created.UserID != agent.ManagerUserID || created.Role != string(bot.RoleManager) { - t.Fatalf("created bot = %+v, want manager u-manager IDs", created) - } -} - -func TestHandleBotsCreateManagerBootstrapsMissingAgent(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc := mustNewSeededService(t, nil) - imSvc := im.NewService() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, + t.Fatalf("decode room: %v", err) } - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"manager","role":"manager"}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) + addReq := strings.NewReader(`{"user_ids":["fsu-alice"]}`) + rec = httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/rooms/"+room.ID+"/members", addReq)) + if rec.Code != http.StatusOK { + t.Fatalf("add status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + rec = httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/rooms/"+room.ID+"/members", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("members status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - var created bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { - t.Fatalf("decode response: %v", err) + var members []im.User + if err := json.NewDecoder(rec.Body).Decode(&members); err != nil { + t.Fatalf("decode members: %v", err) } - if created.ID != agent.ManagerUserID || created.AgentID != agent.ManagerUserID || created.UserID != agent.ManagerUserID { - t.Fatalf("created bot = %+v, want u-manager IDs", created) + if len(members) != 2 { + t.Fatalf("members = %+v, want two users", members) } -} - -func TestHandleBotsListRejectsUnsupportedMethod(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, nil)} - req := httptest.NewRequest(http.MethodPut, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusMethodNotAllowed { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusMethodNotAllowed, rec.Body.String()) + if members[0].ID != "u-manager" || members[1].ID != "fsu-alice" { + t.Fatalf("members = %+v, want bot ids", members) } } -func TestHandleBotByIDDeleteUsesChannel(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, []bot.Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), +func TestHandleRoomsMembersListsCsgclawMembers(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "Admin", Handle: "admin", Role: "admin"}, + {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"}, }, - { - ID: "u-alice", - Name: "Alice", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelFeishu), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), + Rooms: []im.Room{ + {ID: "room-1", Title: "Ops", Members: []string{"u-admin", "u-alice"}}, }, - })} - req := httptest.NewRequest(http.MethodDelete, "/api/v1/channels/feishu/bots/u-alice", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) + }) + srv := &Handler{im: imSvc} - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) - } - bots, err := srv.botSvc.List(string(bot.ChannelCSGClaw), "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-alice" { - t.Fatalf("csgclaw bots = %+v, want retained u-alice", bots) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-1/members", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("members status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - bots, err = srv.botSvc.List(string(bot.ChannelFeishu), "", "") - if err != nil { - t.Fatalf("List(feishu) error = %v", err) + + var members []im.User + if err := json.NewDecoder(rec.Body).Decode(&members); err != nil { + t.Fatalf("decode members: %v", err) } - if len(bots) != 0 { - t.Fatalf("feishu bots = %+v, want deleted", bots) + if len(members) != 2 || members[0].ID != "u-admin" || members[1].ID != "u-alice" { + t.Fatalf("members = %+v, want room members", members) } } -func TestHandleBotByIDDeleteRemovesCSGClawUser(t *testing.T) { - store, err := bot.NewMemoryStore([]bot.Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } +func TestHandleRoomsMembersAddsCsgclawMember(t *testing.T) { imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ CurrentUserID: "u-admin", Users: []im.User{ - {ID: "u-admin", Name: "admin", Handle: "admin", IsOnline: true}, - {ID: "u-alice", Name: "Alice", Handle: "alice", IsOnline: true}, + {ID: "u-admin", Name: "Admin", Handle: "admin", Role: "admin"}, + {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"}, }, Rooms: []im.Room{ - { - ID: "room-1", - Title: "Alice", - Members: []string{"u-admin", "u-alice"}, - Messages: []im.Message{{ID: "msg-1", SenderID: "u-alice", Content: "hello"}}, - }, + {ID: "room-1", Title: "Ops", Members: []string{"u-admin"}}, }, }) - botSvc, err := bot.NewServiceWithDependencies(store, nil, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{botSvc: botSvc} - req := httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/bots/u-alice", nil) - rec := httptest.NewRecorder() + srv := &Handler{im: imSvc} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/room-1/members", strings.NewReader(`{"inviter_id":"u-admin","user_ids":["u-alice"]}`)) srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) - } - if _, ok := imSvc.User("u-alice"); ok { - t.Fatal("User(u-alice) ok = true, want false after bot delete") - } - if _, ok := imSvc.Room("room-1"); ok { - t.Fatal("Room(room-1) ok = true, want false after removing DM user") + if rec.Code != http.StatusOK { + t.Fatalf("add status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - bots, err := botSvc.List(string(bot.ChannelCSGClaw), "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) + + var room im.Room + if err := json.NewDecoder(rec.Body).Decode(&room); err != nil { + t.Fatalf("decode room: %v", err) } - if len(bots) != 0 { - t.Fatalf("csgclaw bots = %+v, want deleted", bots) + if len(room.Members) != 2 || room.Members[1] != "u-alice" { + t.Fatalf("members = %+v, want u-admin and u-alice", room.Members) } } @@ -1339,22 +623,9 @@ func TestHandleAgentsPatchUpdatesMetadataAndProfile(t *testing.T) { CreatedAt: time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC), }, }) - imSvc := im.NewService() - if _, _, err := imSvc.EnsureAgentUser(im.EnsureAgentUserRequest{ - ID: "u-alice", - Name: "alice", - Handle: "alice", - Role: agent.RoleWorker, - Avatar: "avatar/3D-1.png", - }); err != nil { - t.Fatalf("EnsureAgentUser() error = %v", err) - } - bus := im.NewBus() - events, cancel := bus.Subscribe() - defer cancel() - srv := &Handler{svc: svc, im: imSvc, imBus: bus} - body := `{"description":"new role","avatar":"avatar/cartoon-4.png","agent_profile":{"name":"alice","provider":"csghub_lite","model_id":"new-model","env":{"A":"B"}}}` + srv := &Handler{svc: svc} + body := `{"description":"new role","agent_profile":{"name":"alice","provider":"csghub_lite","model_id":"new-model","env":{"A":"B"}}}` req := httptest.NewRequest(http.MethodPatch, "/api/v1/agents/u-alice", strings.NewReader(body)) rec := httptest.NewRecorder() @@ -1370,24 +641,10 @@ func TestHandleAgentsPatchUpdatesMetadataAndProfile(t *testing.T) { if got["description"] != "new role" { t.Fatalf("agent = %#v, want updated description", got) } - if got["avatar"] != "avatar/cartoon-4.png" { - t.Fatalf("agent avatar = %#v, want updated avatar", got["avatar"]) - } profile, ok := got["agent_profile"].(map[string]any) if !ok || profile["env_restart_required"] != true || profile["model_id"] != "new-model" { t.Fatalf("agent_profile = %#v, want env_restart_required true", got["agent_profile"]) } - user, ok := imSvc.User("u-alice") - if !ok { - t.Fatal("User(u-alice) ok = false, want true") - } - if user.Avatar != "avatar/cartoon-4.png" { - t.Fatalf("user avatar = %q, want avatar/cartoon-4.png", user.Avatar) - } - evt := mustReceiveIMEvent(t, events) - if evt.Type != im.EventTypeUserUpdated || evt.User == nil || evt.User.Avatar != "avatar/cartoon-4.png" { - t.Fatalf("event = %+v, want user.updated with updated avatar", evt) - } } func TestHandleAgentsGetByIDReloadsStateBeforeLookup(t *testing.T) { @@ -2003,7 +1260,6 @@ func TestAgentCreateRequestFromAPIIncludesFromTemplate(t *testing.T) { Name: "alice", RuntimeKind: agent.RuntimeKindCodex, FromTemplate: "builtin.frontend-alice", - Avatar: "avatar/3D-1.png", Profile: "codex-fast", }) @@ -2016,9 +1272,6 @@ func TestAgentCreateRequestFromAPIIncludesFromTemplate(t *testing.T) { if got.Spec.FromTemplate != "builtin.frontend-alice" { t.Fatalf("Spec.FromTemplate = %q, want %q", got.Spec.FromTemplate, "builtin.frontend-alice") } - if got.Spec.Avatar != "avatar/3D-1.png" { - t.Fatalf("Spec.Avatar = %q, want %q", got.Spec.Avatar, "avatar/3D-1.png") - } if got.Spec.Profile != "codex-fast" { t.Fatalf("Spec.Profile = %q, want %q", got.Spec.Profile, "codex-fast") } @@ -2053,62 +1306,6 @@ func TestHandleHubTemplatesListsAggregatedTemplates(t *testing.T) { } } -func TestHandleHubTemplateDeleteRemovesLocalTemplate(t *testing.T) { - hubSvc := mustNewLocalTemplateHubService(t, "review-bot", hub.Template{ - ID: "review-bot", - Name: "review-bot", - Role: hub.TemplateRoleWorker, - RuntimeKind: agent.RuntimeKindCodex, - }) - srv := &Handler{} - srv.SetHubService(hubSvc) - req := httptest.NewRequest(http.MethodDelete, "/api/v1/hub/templates/local.review-bot", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) - } - listReq := httptest.NewRequest(http.MethodGet, "/api/v1/hub/templates", nil) - listRec := httptest.NewRecorder() - srv.Routes().ServeHTTP(listRec, listReq) - if listRec.Code != http.StatusOK { - t.Fatalf("list status = %d, want %d; body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) - } - var listed []apitypes.HubTemplate - if err := json.NewDecoder(listRec.Body).Decode(&listed); err != nil { - t.Fatalf("decode list response: %v", err) - } - for _, item := range listed { - if item.ID == "local.review-bot" { - t.Fatalf("listed templates = %#v, want deleted local.review-bot", listed) - } - } -} - -func TestHandleHubTemplateDeleteRejectsBuiltinTemplate(t *testing.T) { - builtinSvc, err := hub.NewService(config.HubConfig{ - DefaultRegistry: "builtin", - Registries: []config.HubRegistryConfig{ - {Name: "builtin", Kind: hub.RegistryKindBuiltin, Enabled: true}, - }, - }, hub.DefaultStoreFactory) - if err != nil { - t.Fatalf("hub.NewService() error = %v", err) - } - srv := &Handler{} - srv.SetHubService(builtinSvc) - req := httptest.NewRequest(http.MethodDelete, "/api/v1/hub/templates/builtin.picoclaw-worker", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String()) - } -} - func TestHandleHubTemplateByIDReturnsTemplate(t *testing.T) { hubSvc := mustNewLocalTemplateHubService(t, "review-bot", hub.Template{ ID: "review-bot", @@ -2581,7 +1778,7 @@ func TestHandleRoomsInviteAliasAddsConversationMembers(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { @@ -2592,7 +1789,7 @@ func TestHandleRoomsInviteAliasAddsConversationMembers(t *testing.T) { }, }), } - req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/invite", strings.NewReader(`{"room_id":"room-1","inviter_id":"u-admin","user_ids":["u-manager"],"locale":"en"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/invite", strings.NewReader(`{"room_id":"room-1","inviter_id":"u-admin","user_ids":["manager"],"locale":"en"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -2608,14 +1805,14 @@ func TestHandleRoomsInviteAliasAddsConversationMembers(t *testing.T) { if got.ID != "room-1" { t.Fatalf("conversation id = %q, want %q", got.ID, "room-1") } - if !containsMember(got.Members, "u-manager") { - t.Fatalf("members = %+v, want u-manager to be invited", got.Members) + if !containsMember(got.Members, "manager") { + t.Fatalf("members = %+v, want manager to be invited", got.Members) } } func TestHandleRoomsInviteRequiresRoomID(t *testing.T) { srv := &Handler{im: im.NewService()} - req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/invite", strings.NewReader(`{"inviter_id":"u-admin","user_ids":["u-manager"]}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/invite", strings.NewReader(`{"inviter_id":"u-admin","user_ids":["manager"]}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2723,7 +1920,7 @@ func TestHandleUsersReturnsUserList(t *testing.T) { }), } - req := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/users", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2750,7 +1947,7 @@ func TestHandleUsersCreateProvisionsIMUser(t *testing.T) { imBus: bus, } - req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"id":"u-alice","name":"Alice","handle":"alice","role":"worker"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"id":"u-alice","name":"Alice","handle":"alice","role":"worker"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2784,7 +1981,7 @@ func TestHandleUsersCreateProvisionsIMUser(t *testing.T) { } } -func TestHandleUsersCreateWithBotServiceCreatesWorkerAgent(t *testing.T) { +func TestHandleUsersCreateWithParticipantServiceCreatesWorkerAgent(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) @@ -2794,22 +1991,19 @@ func TestHandleUsersCreateWithBotServiceCreatesWorkerAgent(t *testing.T) { events, cancel := bus.Subscribe() defer cancel() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } + participantSvc := participant.NewService( + participant.NewMemoryStore(nil), + participant.WithAgentService(agentSvc), + participant.WithIMService(imSvc), + ) srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - imBus: bus, + svc: agentSvc, + participant: participantSvc, + im: imSvc, + imBus: bus, } - req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"id":"u-qa","name":"qa","handle":"qa","role":"qa"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"id":"u-qa","name":"qa","handle":"qa","role":"qa"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2832,12 +2026,9 @@ func TestHandleUsersCreateWithBotServiceCreatesWorkerAgent(t *testing.T) { t.Fatalf("agent = %+v, want qa worker", created) } - bots, err := botSvc.List(string(bot.ChannelCSGClaw), string(bot.RoleWorker), "") - if err != nil { - t.Fatalf("List(worker) error = %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-qa" || bots[0].AgentID != "u-qa" || bots[0].UserID != "u-qa" { - t.Fatalf("bots = %+v, want one qa worker bot", bots) + participants := participantSvc.List(participant.ListOptions{Channel: participant.ChannelCSGClaw, Type: participant.TypeAgent}) + if len(participants) != 1 || participants[0].ID != "qa" || participants[0].AgentID != "u-qa" || participants[0].ChannelUserRef != "u-qa" { + t.Fatalf("participants = %+v, want one qa worker participant", participants) } first := mustReceiveIMEvent(t, events) @@ -2850,10 +2041,81 @@ func TestHandleUsersCreateWithBotServiceCreatesWorkerAgent(t *testing.T) { } } +func TestHandleUsersCreateManagerAgentIDReturnsParticipantUser(t *testing.T) { + imSvc := im.NewService() + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"id":"u-manager","name":"manager","handle":"manager","role":"manager"}`)) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var got im.User + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.ID != agent.ManagerParticipantID || got.Handle != agent.ManagerName { + t.Fatalf("user = %+v, want existing manager participant user", got) + } + if _, ok := imSvc.User(agent.ManagerUserID); ok { + t.Fatalf("legacy runtime user %q was created", agent.ManagerUserID) + } +} + +func TestHandleCreateRoomResolvesManagerAgentIDToParticipantUser(t *testing.T) { + imSvc := im.NewService() + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/rooms", strings.NewReader(`{"title":"manager dm","creator_id":"u-admin","member_ids":["u-manager"]}`)) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var got im.Room + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if !containsMember(got.Members, agent.ManagerParticipantID) || containsMember(got.Members, agent.ManagerUserID) { + t.Fatalf("room members = %+v, want manager participant user only", got.Members) + } +} + func TestHandleUsersCreateDefaultsHandleFromName(t *testing.T) { srv := &Handler{im: im.NewService()} - req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"id":"u-alice","name":"Alice"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"id":"u-alice","name":"Alice"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2873,7 +2135,7 @@ func TestHandleUsersCreateDefaultsHandleFromName(t *testing.T) { func TestHandleUsersCreateRejectsMissingID(t *testing.T) { srv := &Handler{im: im.NewService()} - req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"name":"Alice","handle":"alice"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"name":"Alice","handle":"alice"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2966,13 +2228,13 @@ func TestHandleMessagesPostCreatesMessage(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", Title: "Room One", - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, }, }, }), @@ -2993,8 +2255,8 @@ func TestHandleMessagesPostCreatesMessage(t *testing.T) { if got.SenderID != "u-admin" || got.Content != "hello @manager" { t.Fatalf("message = %+v, want sender/content populated", got) } - if len(got.Mentions) != 1 || got.Mentions[0].ID != "u-manager" || got.Mentions[0].Name != "manager" { - t.Fatalf("mentions = %+v, want u-manager", got.Mentions) + if len(got.Mentions) != 1 || got.Mentions[0].ID != "manager" || got.Mentions[0].Name != "manager" { + t.Fatalf("mentions = %+v, want manager", got.Mentions) } } @@ -3079,17 +2341,17 @@ func TestHandleThreadRoutesAndMessageFiltering(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", Title: "Room One", - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ {ID: "msg-1", SenderID: "u-admin", Content: "before", CreatedAt: time.Date(2026, 5, 20, 9, 0, 0, 0, time.UTC)}, {ID: "msg-root", SenderID: "u-admin", Content: "root", CreatedAt: time.Date(2026, 5, 20, 9, 1, 0, 0, time.UTC)}, - {ID: "msg-2", SenderID: "u-manager", Content: "after", CreatedAt: time.Date(2026, 5, 20, 9, 2, 0, 0, time.UTC)}, + {ID: "msg-2", SenderID: "manager", Content: "after", CreatedAt: time.Date(2026, 5, 20, 9, 2, 0, 0, time.UTC)}, }, }, }, @@ -3110,7 +2372,7 @@ func TestHandleThreadRoutesAndMessageFiltering(t *testing.T) { t.Fatalf("started thread = %+v, want root with context", started) } - req = httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(`{"room_id":"room-1","sender_id":"u-manager","content":"thread reply","relates_to":{"rel_type":"m.thread","event_id":"msg-root"}}`)) + req = httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(`{"room_id":"room-1","sender_id":"manager","content":"thread reply","relates_to":{"rel_type":"m.thread","event_id":"msg-root"}}`)) rec = httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusCreated { @@ -3220,13 +2482,13 @@ func TestHandleThreadEventsPublishCreatedAndUpdated(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", Title: "Room One", - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{{ID: "msg-root", SenderID: "u-admin", Content: "root", CreatedAt: time.Date(2026, 5, 20, 9, 0, 0, 0, time.UTC)}}, }, }, @@ -3245,7 +2507,7 @@ func TestHandleThreadEventsPublishCreatedAndUpdated(t *testing.T) { t.Fatalf("created event = %+v, want thread.created for msg-root", created) } - req = httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(`{"room_id":"room-1","sender_id":"u-manager","content":"thread reply","relates_to":{"rel_type":"m.thread","event_id":"msg-root"}}`)) + req = httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(`{"room_id":"room-1","sender_id":"manager","content":"thread reply","relates_to":{"rel_type":"m.thread","event_id":"msg-root"}}`)) rec = httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusCreated { @@ -3432,7 +2694,7 @@ func TestHandleFeishuEventsStreamsMessageBusEvents(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil).WithContext(ctx) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil).WithContext(ctx) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() @@ -3504,7 +2766,7 @@ func TestHandleFeishuEventsSendsHeartbeat(t *testing.T) { srv := &Handler{feishu: feishuSvc, serverAccessToken: "secret"} ctx, cancel := context.WithCancel(context.Background()) - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil).WithContext(ctx) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil).WithContext(ctx) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() @@ -3529,7 +2791,7 @@ func TestHandleFeishuEventsRequiresAuthorization(t *testing.T) { serverAccessToken: "secret", } - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3541,7 +2803,7 @@ func TestHandleFeishuEventsRequiresAuthorization(t *testing.T) { func TestHandleFeishuEventsRequiresAuthorizationWhenServerAccessTokenEmpty(t *testing.T) { srv := &Handler{} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3553,7 +2815,7 @@ func TestHandleFeishuEventsRequiresAuthorizationWhenServerAccessTokenEmpty(t *te func TestHandleFeishuEventsSkipsAuthorizationWhenNoAuth(t *testing.T) { srv := &Handler{serverNoAuth: true} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3621,12 +2883,12 @@ func TestHandleRoomsPostCreatesRoom(t *testing.T) { Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, {ID: "u-alice", Name: "Alice", Handle: "alice"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, }), } - req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms", strings.NewReader(`{"title":"Launch","description":"coordination","creator_id":"u-admin","member_ids":["u-alice","u-manager"],"locale":"en"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms", strings.NewReader(`{"title":"Launch","description":"coordination","creator_id":"u-admin","member_ids":["u-alice","manager"],"locale":"en"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3641,7 +2903,7 @@ func TestHandleRoomsPostCreatesRoom(t *testing.T) { if got.Title != "Launch" { t.Fatalf("conversation.Title = %q, want Launch", got.Title) } - if !containsMember(got.Members, "u-admin") || !containsMember(got.Members, "u-alice") || !containsMember(got.Members, "u-manager") { + if !containsMember(got.Members, "u-admin") || !containsMember(got.Members, "u-alice") || !containsMember(got.Members, "manager") { t.Fatalf("members = %+v, want admin, alice, and manager", got.Members) } } @@ -3693,7 +2955,7 @@ func TestHandleUsersDeleteRemovesUser(t *testing.T) { }), } - req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/u-alice", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/users/u-alice", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3711,7 +2973,7 @@ func TestHandleUsersDeleteRemovesUser(t *testing.T) { func TestHandleUsersDeleteCurrentUserReturnsConflict(t *testing.T) { srv := &Handler{im: im.NewService()} - req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/u-admin", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/users/u-admin", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3786,14 +3048,14 @@ func TestHandleFeishuRoomsDeleteRemovesRoom(t *testing.T) { } } -func TestHandleBotCompatibilityRoutesRequireAuthorization(t *testing.T) { +func TestHandleParticipantMessageRouteRequiresAuthorization(t *testing.T) { srv := &Handler{ im: im.NewService(), - botBridge: im.NewBotBridge("secret"), + participantBridge: im.NewParticipantBridge("secret"), serverAccessToken: "secret", } - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3802,12 +3064,12 @@ func TestHandleBotCompatibilityRoutesRequireAuthorization(t *testing.T) { } } -func TestHandleBotCompatibilityRoutesRequireAuthorizationWhenServerAccessTokenEmpty(t *testing.T) { +func TestHandleParticipantMessageRouteRequiresAuthorizationWhenServerAccessTokenEmpty(t *testing.T) { srv := &Handler{ - botBridge: im.NewBotBridge("secret"), + participantBridge: im.NewParticipantBridge("secret"), } - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3816,13 +3078,13 @@ func TestHandleBotCompatibilityRoutesRequireAuthorizationWhenServerAccessTokenEm } } -func TestHandleBotCompatibilityRoutesSkipAuthorizationWhenNoAuth(t *testing.T) { +func TestHandleParticipantMessageRouteSkipsAuthorizationWhenNoAuth(t *testing.T) { srv := &Handler{ - botBridge: im.NewBotBridge("secret"), - serverNoAuth: true, + participantBridge: im.NewParticipantBridge("secret"), + serverNoAuth: true, } - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3833,11 +3095,11 @@ func TestHandleBotCompatibilityRoutesSkipAuthorizationWhenNoAuth(t *testing.T) { func TestHandleBotSendMessageRequiresIMService(t *testing.T) { srv := &Handler{ - botBridge: im.NewBotBridge(""), - serverNoAuth: true, + participantBridge: im.NewParticipantBridge(""), + serverNoAuth: true, } - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3852,15 +3114,15 @@ func TestHandleBotSendMessageDoesNotInferRecentThreadScope(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ - {ID: "msg-root", SenderID: "u-manager", Content: "How can I help?", CreatedAt: now}, + {ID: "msg-root", SenderID: "manager", Content: "How can I help?", CreatedAt: now}, }, }, }, @@ -3888,8 +3150,8 @@ func TestHandleBotSendMessageDoesNotInferRecentThreadScope(t *testing.T) { if !ok { t.Fatal("User(u-admin) = false, want user") } - bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + bridge := im.NewParticipantBridge("") + events, cancel := bridge.Subscribe("manager") defer cancel() bridge.PublishMessageEvent(room, sender, inbound) select { @@ -3897,13 +3159,13 @@ func TestHandleBotSendMessageDoesNotInferRecentThreadScope(t *testing.T) { if evt.ThreadRootID != "msg-root" { t.Fatalf("bot event ThreadRootID = %q, want msg-root", evt.ThreadRootID) } - bridge.Ack("u-manager", evt.MessageID) + bridge.Ack("manager", evt.MessageID) case <-time.After(time.Second): t.Fatal("PublishMessageEvent() timed out waiting for threaded event") } - srv := &Handler{im: imSvc, botBridge: bridge, serverNoAuth: true} - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"thread answer"}`)) + srv := &Handler{im: imSvc, participantBridge: bridge, serverNoAuth: true} + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/manager/messages", strings.NewReader(`{"room_id":"room-1","text":"thread answer"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -3941,15 +3203,15 @@ func TestHandleBotSendMessageAcceptsPicoClawThreadContext(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ - {ID: "msg-root", SenderID: "u-manager", Content: "How can I help?", CreatedAt: now}, + {ID: "msg-root", SenderID: "manager", Content: "How can I help?", CreatedAt: now}, }, }, }, @@ -3958,8 +3220,8 @@ func TestHandleBotSendMessageAcceptsPicoClawThreadContext(t *testing.T) { t.Fatalf("StartThread() error = %v", err) } - srv := &Handler{im: imSvc, botBridge: im.NewBotBridge(""), serverNoAuth: true} - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{ + srv := &Handler{im: imSvc, participantBridge: im.NewParticipantBridge(""), serverNoAuth: true} + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/manager/messages", strings.NewReader(`{ "chat_id": "room-1", "content": "direct PicoClaw thread answer", "context": { @@ -4003,19 +3265,19 @@ func TestHandleBotSendMessageAcceptsPicoClawThreadContext(t *testing.T) { } } -func TestPublishBotEventQueuesUntilBotSubscribes(t *testing.T) { +func TestPublishParticipantEventQueuesUntilParticipantSubscribes(t *testing.T) { now := time.Now().UTC() imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-pending", @@ -4027,8 +3289,8 @@ func TestPublishBotEventQueuesUntilBotSubscribes(t *testing.T) { }, }, }) - bridge := im.NewBotBridge("") - srv := &Handler{im: imSvc, botBridge: bridge} + bridge := im.NewParticipantBridge("") + srv := &Handler{im: imSvc, participantBridge: bridge} sender, ok := imSvc.User("u-admin") if !ok { @@ -4039,14 +3301,14 @@ func TestPublishBotEventQueuesUntilBotSubscribes(t *testing.T) { t.Fatalf("room = %+v, want one message", room) } - srv.PublishBotEvent(im.Event{ + srv.PublishParticipantEvent(im.Event{ Type: im.EventTypeMessageCreated, RoomID: "room-1", Sender: &sender, Message: &room.Messages[0], }) - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() select { @@ -4059,7 +3321,7 @@ func TestPublishBotEventQueuesUntilBotSubscribes(t *testing.T) { } } -func TestPublishBotEventReensuresRunningWorkerLifecycle(t *testing.T) { +func TestPublishParticipantEventReensuresRunningWorkerLifecycle(t *testing.T) { t.Setenv("HOME", t.TempDir()) started := make(chan string, 1) recreated := make(chan string, 1) @@ -4126,8 +3388,8 @@ func TestPublishBotEventReensuresRunningWorkerLifecycle(t *testing.T) { t.Fatalf("room = %+v, want one message", room) } - srv := &Handler{svc: svc, im: imSvc, botBridge: im.NewBotBridge("")} - srv.PublishBotEvent(im.Event{ + srv := &Handler{svc: svc, im: imSvc, participantBridge: im.NewParticipantBridge("")} + srv.PublishParticipantEvent(im.Event{ Type: im.EventTypeMessageCreated, RoomID: "room-1", Sender: &sender, @@ -4146,7 +3408,7 @@ func TestPublishBotEventReensuresRunningWorkerLifecycle(t *testing.T) { } } -func TestPublishBotEventStartsStoppedWorker(t *testing.T) { +func TestPublishParticipantEventStartsStoppedWorker(t *testing.T) { t.Setenv("HOME", t.TempDir()) started := make(chan string, 1) restoreDefault := agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { @@ -4208,8 +3470,8 @@ func TestPublishBotEventStartsStoppedWorker(t *testing.T) { t.Fatalf("room = %+v, want one message", room) } - srv := &Handler{svc: svc, im: imSvc, botBridge: im.NewBotBridge("")} - srv.PublishBotEvent(im.Event{ + srv := &Handler{svc: svc, im: imSvc, participantBridge: im.NewParticipantBridge("")} + srv.PublishParticipantEvent(im.Event{ Type: im.EventTypeMessageCreated, RoomID: "room-1", Sender: &sender, @@ -4232,13 +3494,13 @@ func TestHandleBotEventsRequeuesWhenSSEWriteFails(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-retry", @@ -4250,7 +3512,7 @@ func TestHandleBotEventsRequeuesWhenSSEWriteFails(t *testing.T) { }, }, }) - bridge := im.NewBotBridge("") + bridge := im.NewParticipantBridge("") room, ok := imSvc.Room("room-1") if !ok { t.Fatal("Room(room-1) = false, want room") @@ -4261,11 +3523,11 @@ func TestHandleBotEventsRequeuesWhenSSEWriteFails(t *testing.T) { } bridge.PublishMessageEvent(room, sender, room.Messages[0]) - srv := &Handler{im: imSvc, botBridge: bridge} - req := httptest.NewRequest(http.MethodGet, "/api/bots/u-manager/events", nil) - srv.handleBotEvents(&failingBotEventWriter{header: make(http.Header)}, req, "u-manager") + srv := &Handler{im: imSvc, participantBridge: bridge} + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/participants/manager/events", nil) + srv.handleParticipantEventsStream(&failingBotEventWriter{header: make(http.Header)}, req, "manager") - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() select { case evt := <-events: @@ -4303,13 +3565,13 @@ func TestReplayRecentBotMessagesReplaysUnansweredHumanMessage(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-missed", @@ -4321,12 +3583,12 @@ func TestReplayRecentBotMessagesReplaysUnansweredHumanMessage(t *testing.T) { }, }, }) - bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + bridge := im.NewParticipantBridge("") + events, cancel := bridge.Subscribe("manager") defer cancel() - srv := &Handler{im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "") + srv := &Handler{im: imSvc, participantBridge: bridge} + srv.replayRecentParticipantMessages("manager", "") select { case evt := <-events: @@ -4334,7 +3596,109 @@ func TestReplayRecentBotMessagesReplaysUnansweredHumanMessage(t *testing.T) { t.Fatalf("replayed event = %+v, want msg-missed please reply", evt) } case <-time.After(time.Second): - t.Fatal("replayRecentBotMessages() timed out waiting for event") + t.Fatal("replayRecentParticipantMessages() timed out waiting for event") + } +} + +func TestReplayRecentBotMessagesSkipsRoomWithoutBridgeTarget(t *testing.T) { + now := time.Now().UTC() + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-agent-hhtz4b", Name: "qa", Handle: "qa"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager"}, + }, + Rooms: []im.Room{ + { + ID: "room-qa", + IsDirect: true, + Members: []string{"u-admin", "u-agent-hhtz4b"}, + Messages: []im.Message{ + { + ID: "msg-qa", + SenderID: "u-admin", + Content: "qa only", + CreatedAt: now, + }, + }, + }, + }, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewParticipantBridge("") + events, cancel := bridge.Subscribe(agent.ManagerParticipantID) + defer cancel() + + srv := &Handler{im: imSvc, participant: participantSvc, participantBridge: bridge} + srv.replayRecentParticipantMessages(agent.ManagerParticipantID, "") + + select { + case evt := <-events: + t.Fatalf("replayed event = %+v, want no replay for room without manager membership", evt) + case <-time.After(50 * time.Millisecond): + } +} + +func TestReplayRecentBotMessagesReplaysParticipantRoomUsingChannelUserRef(t *testing.T) { + now := time.Now().UTC() + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-agent-hhtz4b", Name: "qa", Handle: "qa"}, + }, + Rooms: []im.Room{ + { + ID: "room-qa", + IsDirect: true, + Members: []string{"u-admin", "u-agent-hhtz4b"}, + Messages: []im.Message{ + { + ID: "msg-qa", + SenderID: "u-admin", + Content: "qa only", + CreatedAt: now, + }, + }, + }, + }, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "agent-hhtz4b", + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: "qa", + ChannelUserRef: "u-agent-hhtz4b", + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: "u-agent-hhtz4b", + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewParticipantBridge("") + events, cancel := bridge.Subscribe("agent-hhtz4b") + defer cancel() + + srv := &Handler{im: imSvc, participant: participantSvc, participantBridge: bridge} + srv.replayRecentParticipantMessages("agent-hhtz4b", "") + + select { + case evt := <-events: + if evt.MessageID != "msg-qa" || evt.Context.Account != "agent-hhtz4b" { + t.Fatalf("replayed event = %+v, want participant-keyed QA replay", evt) + } + case <-time.After(time.Second): + t.Fatal("replayRecentParticipantMessages() timed out waiting for participant-keyed QA event") } } @@ -4383,13 +3747,13 @@ func TestReplayRecentBotMessagesUsesNewConversationFlow(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-new-convo", @@ -4401,12 +3765,12 @@ func TestReplayRecentBotMessagesUsesNewConversationFlow(t *testing.T) { }, }, }) - bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + bridge := im.NewParticipantBridge("") + events, cancel := bridge.Subscribe("manager") defer cancel() - srv := &Handler{svc: svc, im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "") + srv := &Handler{svc: svc, im: imSvc, participantBridge: bridge} + srv.replayRecentParticipantMessages("manager", "") select { case evt := <-events: @@ -4414,7 +3778,7 @@ func TestReplayRecentBotMessagesUsesNewConversationFlow(t *testing.T) { t.Fatalf("replayed event = %+v, want msg-new-convo ack: cleared", evt) } case <-time.After(time.Second): - t.Fatal("replayRecentBotMessages() timed out waiting for event") + t.Fatal("replayRecentParticipantMessages() timed out waiting for event") } } @@ -4424,13 +3788,13 @@ func TestReplayRecentBotMessagesSkipsAnsweredMessage(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-answered", @@ -4440,7 +3804,7 @@ func TestReplayRecentBotMessagesSkipsAnsweredMessage(t *testing.T) { }, { ID: "msg-reply", - SenderID: "u-manager", + SenderID: "manager", Content: "done", CreatedAt: now.Add(time.Second), }, @@ -4448,12 +3812,12 @@ func TestReplayRecentBotMessagesSkipsAnsweredMessage(t *testing.T) { }, }, }) - bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + bridge := im.NewParticipantBridge("") + events, cancel := bridge.Subscribe("manager") defer cancel() - srv := &Handler{im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "") + srv := &Handler{im: imSvc, participantBridge: bridge} + srv.replayRecentParticipantMessages("manager", "") select { case evt := <-events: @@ -4468,13 +3832,13 @@ func TestReplayRecentBotMessagesDoesNotDuplicateDeliveredMessage(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-delivered", @@ -4486,8 +3850,8 @@ func TestReplayRecentBotMessagesDoesNotDuplicateDeliveredMessage(t *testing.T) { }, }, }) - bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + bridge := im.NewParticipantBridge("") + events, cancel := bridge.Subscribe("manager") defer cancel() room, ok := imSvc.Room("room-1") @@ -4505,13 +3869,13 @@ func TestReplayRecentBotMessagesDoesNotDuplicateDeliveredMessage(t *testing.T) { if evt.MessageID != "msg-delivered" { t.Fatalf("live event = %+v, want msg-delivered", evt) } - bridge.Ack("u-manager", evt.MessageID) + bridge.Ack("manager", evt.MessageID) case <-time.After(time.Second): t.Fatal("PublishMessageEvent() timed out waiting for event") } - srv := &Handler{im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "") + srv := &Handler{im: imSvc, participantBridge: bridge} + srv.replayRecentParticipantMessages("manager", "") select { case evt := <-events: @@ -4526,13 +3890,13 @@ func TestReplayRecentBotMessagesHonorsLastEventID(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-seen", @@ -4550,12 +3914,12 @@ func TestReplayRecentBotMessagesHonorsLastEventID(t *testing.T) { }, }, }) - bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + bridge := im.NewParticipantBridge("") + events, cancel := bridge.Subscribe("manager") defer cancel() - srv := &Handler{im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "msg-seen") + srv := &Handler{im: imSvc, participantBridge: bridge} + srv.replayRecentParticipantMessages("manager", "msg-seen") select { case evt := <-events: @@ -4563,7 +3927,7 @@ func TestReplayRecentBotMessagesHonorsLastEventID(t *testing.T) { t.Fatalf("replayed event = %+v, want msg-new new after reconnect", evt) } case <-time.After(time.Second): - t.Fatal("replayRecentBotMessages() timed out waiting for event") + t.Fatal("replayRecentParticipantMessages() timed out waiting for event") } select { @@ -4617,12 +3981,12 @@ func TestHandleBotLLMModelsReturnsBridgeCatalog(t *testing.T) { srv := &Handler{ svc: svc, - botBridge: im.NewBotBridge("secret"), + participantBridge: im.NewParticipantBridge("secret"), llm: bridge, serverAccessToken: "secret", } - req := httptest.NewRequest(http.MethodGet, "/api/bots/u-manager/llm/v1/models", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager/llm/v1/models", nil) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -4679,12 +4043,12 @@ func TestHandleBotLLMModelsLegacyRouteReturnsBridgeCatalog(t *testing.T) { srv := &Handler{ svc: svc, - botBridge: im.NewBotBridge("secret"), + participantBridge: im.NewParticipantBridge("secret"), llm: bridge, serverAccessToken: "secret", } - req := httptest.NewRequest(http.MethodGet, "/api/bots/u-manager/llm/models", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager/llm/models", nil) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -4732,20 +4096,6 @@ func mustNewSeededService(t *testing.T, agents []agent.Agent) *agent.Service { return svc } -func mustNewBotService(t *testing.T, bots []bot.Bot) *bot.Service { - t.Helper() - - store, err := bot.NewMemoryStore(bots) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - svc, err := bot.NewService(store) - if err != nil { - t.Fatalf("bot.NewService() error = %v", err) - } - return svc -} - func mustNewSeededServiceWithPath(t *testing.T, agents []agent.Agent) (*agent.Service, string) { t.Helper() t.Setenv("HOME", t.TempDir()) diff --git a/internal/api/llm.go b/internal/api/llm.go index 0268aee4..4b44d655 100644 --- a/internal/api/llm.go +++ b/internal/api/llm.go @@ -8,6 +8,51 @@ import ( "csgclaw/internal/llm" ) +func (h *Handler) handleAgentLLMModelsByID(w http.ResponseWriter, r *http.Request) { + agentID, ok := h.requireAgentLLMID(w, r) + if !ok { + return + } + h.handleBotLLMModels(w, r, agentID) +} + +func (h *Handler) handleAgentLLMChatCompletionsByID(w http.ResponseWriter, r *http.Request) { + agentID, ok := h.requireAgentLLMID(w, r) + if !ok { + return + } + h.handleBotLLMChatCompletions(w, r, agentID) +} + +func (h *Handler) handleAgentLLMResponsesByID(w http.ResponseWriter, r *http.Request) { + agentID, ok := h.requireAgentLLMID(w, r) + if !ok { + return + } + h.handleBotLLMResponses(w, r, agentID) +} + +func (h *Handler) handleAgentLLMResponsesWebsocketByID(w http.ResponseWriter, r *http.Request) { + agentID, ok := h.requireAgentLLMID(w, r) + if !ok { + return + } + h.handleBotLLMResponsesWebsocket(w, r, agentID) +} + +func (h *Handler) requireAgentLLMID(w http.ResponseWriter, r *http.Request) (string, bool) { + agentID := strings.TrimSpace(pathValue(r, "id")) + if agentID == "" { + http.NotFound(w, r) + return "", false + } + if !h.validateServerAccessToken(r.Header.Get("Authorization")) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return "", false + } + return agentID, true +} + func (h *Handler) handleBotLLMModels(w http.ResponseWriter, r *http.Request, botID string) { if h.llm == nil { http.Error(w, "llm bridge is not configured", http.StatusServiceUnavailable) diff --git a/internal/api/notification_bots.go b/internal/api/notification_bots.go deleted file mode 100644 index df3f566e..00000000 --- a/internal/api/notification_bots.go +++ /dev/null @@ -1,37 +0,0 @@ -package api - -import ( - "net/http" - - "csgclaw/internal/channel/csgclaw/notification_bot" -) - -func (h *Handler) pushNotificationBot(w http.ResponseWriter, r *http.Request) { - id := pathValue(r, "id") - if id == "" { - http.NotFound(w, r) - return - } - channel := botChannelName(r) - if channel == "" { - channel = "csgclaw" - } - deps := h.notificationPushDeps(channel) - notification_bot.ServeNotificationPush(w, r, id, deps) -} - -func (h *Handler) notificationPushDeps(channel string) notification_bot.PushHTTPDeps { - var reload func() error - var lookup func(string) (map[string]any, string, bool) - if h.botSvc != nil { - reload = h.botSvc.Reload - lookup = func(id string) (map[string]any, string, bool) { - return h.botSvc.LookupNotificationBotForDelivery(channel, id) - } - } - return notification_bot.PushHTTPDeps{ - Reload: reload, - LookupNotificationBot: lookup, - Deliver: h.notificationDeliver, - } -} diff --git a/internal/api/notification_bots_handler_test.go b/internal/api/notification_bots_handler_test.go deleted file mode 100644 index 6774f585..00000000 --- a/internal/api/notification_bots_handler_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - - "csgclaw/internal/bot" - "csgclaw/internal/im" -) - -func TestHandleBotByIDRejectsGetPatchForNormalBot(t *testing.T) { - imSvc := im.NewService() - botStore, err := bot.NewMemoryStore([]bot.Bot{{ - ID: "u-worker", - Name: "worker", - Type: bot.BotTypeNormal, - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - AgentID: "u-worker", - UserID: "u-worker", - }}) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewService(botStore) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - botSvc.SetDependencies(nil, imSvc) - - srv := &Handler{botSvc: botSvc, im: imSvc} - router := srv.Routes() - - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots/u-worker", nil)) - if rec.Code != http.StatusMethodNotAllowed { - t.Fatalf("GET normal bot status = %d, want 405", rec.Code) - } -} diff --git a/internal/api/notification_bots_test.go b/internal/api/notification_bots_test.go deleted file mode 100644 index 3fa928f1..00000000 --- a/internal/api/notification_bots_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "csgclaw/internal/apitypes" - "csgclaw/internal/bot" - "csgclaw/internal/im" -) - -func TestNotificationBotsCRUDAndListBotsFilter(t *testing.T) { - imSvc := im.NewService() - bus := im.NewBus() - botStore, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewService(botStore) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - botSvc.SetDependencies(nil, imSvc) - - srv := &Handler{botSvc: botSvc, im: imSvc, imBus: bus} - router := srv.Routes() - - createBody, _ := json.Marshal(apitypes.CreateBotRequest{ - Name: "notify-1", - Type: "notification", - Role: "worker", - RuntimeOptions: map[string]any{ - "delivery_mode": "webhook", - "webhook_token": "secret-token", - }, - }) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", bytes.NewReader(createBody))) - if rec.Code != http.StatusCreated { - t.Fatalf("POST notification-bots status = %d, body = %s", rec.Code, rec.Body.String()) - } - var created apitypes.Bot - if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { - t.Fatalf("decode created: %v", err) - } - if created.Type != bot.BotTypeNotification { - t.Fatalf("created.Type = %q, want %q", created.Type, bot.BotTypeNotification) - } - if created.ID != "n-notify-1" { - t.Fatalf("created.ID = %q, want n-notify-1", created.ID) - } - if created.AgentID != "" { - t.Fatalf("created.AgentID = %q, want empty", created.AgentID) - } - - rec = httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("GET bots status = %d", rec.Code) - } - var listed []apitypes.Bot - if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil { - t.Fatalf("decode bots: %v", err) - } - var found bool - for _, b := range listed { - if b.ID == created.ID && b.Type == bot.BotTypeNotification { - found = true - break - } - } - if !found { - t.Fatalf("GET /bots = %+v, want notification bot %q", listed, created.ID) - } - - events, cancel := bus.Subscribe() - defer cancel() - patchBody, _ := json.Marshal(apitypes.PatchNotificationBotRequest{ - Avatar: "avatar/cartoon-4.png", - }) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodPatch, "/api/v1/channels/csgclaw/bots/"+created.ID, bytes.NewReader(patchBody))) - if rec.Code != http.StatusOK { - t.Fatalf("PATCH notification-bots status = %d, body = %s", rec.Code, rec.Body.String()) - } - user, ok := imSvc.User(created.UserID) - if !ok { - t.Fatalf("User(%q) ok = false, want true", created.UserID) - } - if user.Avatar != "avatar/cartoon-4.png" { - t.Fatalf("user avatar = %q, want avatar/cartoon-4.png", user.Avatar) - } - evt := mustReceiveIMEvent(t, events) - if evt.Type != im.EventTypeUserUpdated || evt.User == nil || evt.User.Avatar != "avatar/cartoon-4.png" { - t.Fatalf("event = %+v, want user.updated with updated avatar", evt) - } - - push := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots/"+created.ID+"/notifications", bytes.NewReader([]byte(`{"hello":"world"}`))) - req.Header.Set("Authorization", "Bearer secret-token") - req.Header.Set("Content-Type", "application/json") - srv.SetNotificationDeliver(&noopFanouter{}) - router.ServeHTTP(push, req) - if push.Code != http.StatusAccepted { - t.Fatalf("POST notifications status = %d, body = %s", push.Code, push.Body.String()) - } - - rec = httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/bots/"+created.ID, nil)) - if rec.Code != http.StatusNoContent { - t.Fatalf("DELETE notification-bots status = %d, body = %s", rec.Code, rec.Body.String()) - } - _ = context.Background() -} - -type noopFanouter struct{} - -func (noopFanouter) DeliverFanout(string, string) error { return nil } diff --git a/internal/api/notification_participants.go b/internal/api/notification_participants.go new file mode 100644 index 00000000..0820f755 --- /dev/null +++ b/internal/api/notification_participants.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http" + "strings" + + "csgclaw/internal/channel/csgclaw/notification" + participantpkg "csgclaw/internal/participant" +) + +func (h *Handler) pushNotificationParticipant(w http.ResponseWriter, r *http.Request) { + id := pathValue(r, "id") + if id == "" { + http.NotFound(w, r) + return + } + channel := participantChannelName(pathValue(r, "channel")) + if channel == "" { + http.NotFound(w, r) + return + } + deps := h.notificationParticipantPushDeps(channel) + notification.ServeNotificationPush(w, r, id, deps) +} + +func (h *Handler) notificationParticipantPushDeps(channel string) notification.PushHTTPDeps { + return notification.PushHTTPDeps{ + Reload: func() error { return nil }, + LookupNotificationParticipant: func(id string) (map[string]any, string, bool) { + if h.participant == nil { + return nil, "", false + } + item, ok := h.participant.Get(channel, id) + if ok && strings.EqualFold(item.Type, participantpkg.TypeNotification) { + return item.Metadata, item.ChannelUserRef, true + } + return nil, "", false + }, + Deliver: h.notificationDeliver, + } +} diff --git a/internal/api/participant.go b/internal/api/participant.go new file mode 100644 index 00000000..34cad63d --- /dev/null +++ b/internal/api/participant.go @@ -0,0 +1,230 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" + "csgclaw/internal/participant" +) + +func (h *Handler) handleParticipants(w http.ResponseWriter, r *http.Request) { + if h.participant == nil { + http.Error(w, "participant service is not configured", http.StatusServiceUnavailable) + return + } + channelName := pathValue(r, "channel") + if channelName == "" { + http.NotFound(w, r) + return + } + + switch r.Method { + case http.MethodGet: + items := h.participant.List(participant.ListOptions{ + Channel: channelName, + Type: r.URL.Query().Get("type"), + AgentID: r.URL.Query().Get("agent_id"), + }) + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req participant.CreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + req.Channel = channelName + created, err := h.participant.Create(r.Context(), req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusCreated, created) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleParticipantByIDPath(w http.ResponseWriter, r *http.Request) { + if h.participant == nil { + http.Error(w, "participant service is not configured", http.StatusServiceUnavailable) + return + } + channelName := pathValue(r, "channel") + id := pathValue(r, "id") + if channelName == "" || id == "" { + http.NotFound(w, r) + return + } + + switch r.Method { + case http.MethodGet: + item, ok := h.participant.Get(channelName, id) + if !ok { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, item) + case http.MethodPatch: + var req participant.UpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + updated, ok, err := h.participant.Update(r.Context(), channelName, id, req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !ok { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, updated) + case http.MethodDelete: + _, ok, err := h.participant.Delete(r.Context(), channelName, id, participant.DeleteOptions{ + DeleteAgent: r.URL.Query().Get("delete_agent"), + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !ok { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleParticipantEvents(w http.ResponseWriter, r *http.Request) { + channelName := participantChannelName(pathValue(r, "channel")) + id := pathValue(r, "id") + if channelName == "" || id == "" { + http.NotFound(w, r) + return + } + + switch channelName { + case "csgclaw": + participantID, ok := h.requireParticipantBridgeID(w, r, h.resolveParticipantBridgeID(channelName, id)) + if !ok { + return + } + h.handleParticipantEventsStream(w, r, participantID) + case "feishu": + h.handleFeishuParticipantEvents(w, r, h.resolveFeishuParticipantTargetID(id)) + default: + http.NotFound(w, r) + } +} + +func (h *Handler) handleParticipantMessage(w http.ResponseWriter, r *http.Request) { + channelName := participantChannelName(pathValue(r, "channel")) + id := pathValue(r, "id") + if channelName == "" || id == "" { + http.NotFound(w, r) + return + } + if channelName != "csgclaw" { + http.NotFound(w, r) + return + } + participantID := h.resolveParticipantChannelUserID(channelName, id) + participantID, ok := h.requireParticipantBridgeID(w, r, participantID) + if !ok { + return + } + h.handleParticipantSendMessage(w, r, participantID) +} + +func (h *Handler) resolveParticipantChannelUserID(channelName, id string) string { + id = strings.TrimSpace(id) + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(channelName, id); ok { + return participantChannelUserOrID(item) + } + if strings.EqualFold(channelName, participant.ChannelCSGClaw) { + for _, item := range h.participant.List(participant.ListOptions{Channel: channelName}) { + if !isCSGClawAgentParticipant(item) || !participantMatchesIdentity(item, id) { + continue + } + return participantChannelUserOrID(item) + } + } + } + if id == agent.ManagerUserID { + return agent.ManagerParticipantID + } + return id +} + +func (h *Handler) resolveParticipantBridgeID(channelName, id string) string { + id = strings.TrimSpace(id) + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(channelName, id); ok && isCSGClawAgentParticipant(item) { + return strings.TrimSpace(item.ID) + } + if strings.EqualFold(channelName, participant.ChannelCSGClaw) { + for _, item := range h.participant.List(participant.ListOptions{Channel: channelName}) { + if !isCSGClawAgentParticipant(item) || !participantMatchesIdentity(item, id) { + continue + } + return strings.TrimSpace(item.ID) + } + } + } + if id == agent.ManagerUserID { + return agent.ManagerParticipantID + } + return id +} + +func (h *Handler) resolveFeishuParticipantTargetID(id string) string { + id = strings.TrimSpace(id) + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(participant.ChannelFeishu, id); ok { + return participantChannelUserOrID(item) + } + for _, item := range h.participant.List(participant.ListOptions{Channel: participant.ChannelFeishu}) { + if !participantMatchesIdentity(item, id) { + continue + } + return participantChannelUserOrID(item) + } + } + return id +} + +func participantChannelUserOrID(item apitypes.Participant) string { + if ref := strings.TrimSpace(item.ChannelUserRef); ref != "" { + return ref + } + return strings.TrimSpace(item.ID) +} + +func (h *Handler) requireParticipantBridgeID(w http.ResponseWriter, r *http.Request, id string) (string, bool) { + id = strings.TrimSpace(id) + if id == "" { + http.NotFound(w, r) + return "", false + } + if h.participantBridge == nil { + http.Error(w, "picoclaw integration is not configured", http.StatusServiceUnavailable) + return "", false + } + if !h.validateServerAccessToken(r.Header.Get("Authorization")) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return "", false + } + return id, true +} + +func participantChannelName(channel string) string { + return strings.TrimSpace(channel) +} diff --git a/internal/api/participant_bridge.go b/internal/api/participant_bridge.go new file mode 100644 index 00000000..ea4b26e6 --- /dev/null +++ b/internal/api/participant_bridge.go @@ -0,0 +1,555 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" + "csgclaw/internal/im" + "csgclaw/internal/participant" + agentruntime "csgclaw/internal/runtime" +) + +const ( + participantReplayWindow = 30 * time.Minute + participantHeartbeatInterval = 15 * time.Second +) + +func (h *Handler) PublishParticipantEvent(evt im.Event) { + if h.participantBridge == nil || h.im == nil { + return + } + if evt.Type != im.EventTypeMessageCreated || evt.Message == nil || evt.Sender == nil { + return + } + + room, ok := h.im.Room(evt.RoomID) + if !ok { + return + } + if reason, ok, err := newConversationCommandReason(evt.Message.Content); err != nil { + slog.Warn("parse new conversation command failed", "room_id", evt.RoomID, "message_id", evt.Message.ID, "error", err) + } else if ok { + missed := h.publishNewConversationParticipantEvent(context.Background(), room, *evt.Sender, *evt.Message, reason) + h.reconnectMissedParticipantAgents(evt.Sender.ID, missed) + return + } + missed := h.publishMessageParticipantEvent(room, *evt.Sender, *evt.Message) + h.reconnectMissedParticipantAgents(evt.Sender.ID, missed) +} + +type participantBridgeTarget struct { + bridgeID string + aliases []string +} + +func newParticipantBridgeTarget(bridgeID string, aliases ...string) participantBridgeTarget { + bridgeID = strings.TrimSpace(bridgeID) + if bridgeID == "" { + return participantBridgeTarget{} + } + seen := map[string]struct{}{bridgeID: {}} + out := participantBridgeTarget{ + bridgeID: bridgeID, + aliases: []string{bridgeID}, + } + for _, alias := range aliases { + alias = strings.TrimSpace(alias) + if alias == "" { + continue + } + if _, ok := seen[alias]; ok { + continue + } + seen[alias] = struct{}{} + out.aliases = append(out.aliases, alias) + } + return out +} + +func (t participantBridgeTarget) matches(id string) bool { + id = strings.TrimSpace(id) + if id == "" { + return false + } + for _, alias := range t.aliases { + if strings.TrimSpace(alias) == id { + return true + } + } + return false +} + +func (h *Handler) publishMessageParticipantEvent(room im.Room, sender im.User, message im.Message) []string { + var missed []string + for _, target := range h.participantBridgeTargetsForRoom(room) { + if !h.enqueueParticipantMessageEventForBridgeTarget(room, sender, message, target, "") { + missed = append(missed, target.bridgeID) + } + } + return missed +} + +func (h *Handler) enqueueParticipantMessageEventForBridgeID(room im.Room, sender im.User, message im.Message, bridgeID string, text string) bool { + return h.enqueueParticipantMessageEventForBridgeTarget(room, sender, message, h.participantBridgeTargetForBridgeID(bridgeID), text) +} + +func (h *Handler) enqueueParticipantMessageEventForBridgeTarget(room im.Room, sender im.User, message im.Message, target participantBridgeTarget, text string) bool { + if h == nil || h.participantBridge == nil || strings.TrimSpace(target.bridgeID) == "" { + return true + } + if target.matches(message.SenderID) { + return true + } + deliveryRoom := roomForParticipantBridgeTarget(room, target) + deliveryMessage := messageForParticipantBridgeTarget(message, target) + if strings.TrimSpace(text) != "" { + return h.participantBridge.EnqueueMessageEventWithText(deliveryRoom, sender, deliveryMessage, target.bridgeID, text) + } + return h.participantBridge.EnqueueMessageEvent(deliveryRoom, sender, deliveryMessage, target.bridgeID) +} + +func (h *Handler) participantBridgeTargetsForRoom(room im.Room) []participantBridgeTarget { + targets := make([]participantBridgeTarget, 0, len(room.Members)) + seen := make(map[string]struct{}, len(room.Members)) + for _, memberID := range room.Members { + target := h.participantBridgeTargetForRoomMember(memberID) + if strings.TrimSpace(target.bridgeID) == "" { + continue + } + if _, ok := seen[target.bridgeID]; ok { + continue + } + seen[target.bridgeID] = struct{}{} + targets = append(targets, target) + } + return targets +} + +func (h *Handler) participantBridgeTargetForRoomMember(memberID string) participantBridgeTarget { + memberID = strings.TrimSpace(memberID) + if memberID == "" { + return participantBridgeTarget{} + } + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(participant.ChannelCSGClaw, memberID); ok && isCSGClawAgentParticipant(item) { + return participantBridgeTargetForParticipant(item, memberID) + } + for _, item := range h.participant.List(participant.ListOptions{Channel: participant.ChannelCSGClaw}) { + if !isCSGClawAgentParticipant(item) || !participantMatchesIdentity(item, memberID) { + continue + } + return participantBridgeTargetForParticipant(item, memberID) + } + } + return newParticipantBridgeTarget(memberID, memberID) +} + +func (h *Handler) participantBridgeTargetForBridgeID(bridgeID string) participantBridgeTarget { + bridgeID = strings.TrimSpace(bridgeID) + if bridgeID == "" { + return participantBridgeTarget{} + } + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(participant.ChannelCSGClaw, bridgeID); ok && isCSGClawAgentParticipant(item) { + return participantBridgeTargetForParticipant(item, bridgeID) + } + for _, item := range h.participant.List(participant.ListOptions{Channel: participant.ChannelCSGClaw}) { + if !isCSGClawAgentParticipant(item) || !participantMatchesIdentity(item, bridgeID) { + continue + } + return participantBridgeTargetForParticipant(item, bridgeID) + } + } + if bridgeID == agent.ManagerParticipantID { + return newParticipantBridgeTarget(agent.ManagerParticipantID, agent.ManagerUserID) + } + return newParticipantBridgeTarget(bridgeID, bridgeID) +} + +func participantBridgeTargetForParticipant(item apitypes.Participant, aliases ...string) participantBridgeTarget { + allAliases := []string{item.ID, item.ChannelUserRef, item.AgentID} + allAliases = append(allAliases, aliases...) + return newParticipantBridgeTarget(item.ID, allAliases...) +} + +func isCSGClawAgentParticipant(item apitypes.Participant) bool { + return strings.TrimSpace(item.ID) != "" && + strings.EqualFold(strings.TrimSpace(item.Channel), participant.ChannelCSGClaw) && + strings.EqualFold(strings.TrimSpace(item.Type), participant.TypeAgent) +} + +func participantMatchesIdentity(item apitypes.Participant, id string) bool { + id = strings.TrimSpace(id) + return id != "" && (strings.TrimSpace(item.ID) == id || + strings.TrimSpace(item.ChannelUserRef) == id || + strings.TrimSpace(item.AgentID) == id) +} + +func roomForParticipantBridgeTarget(room im.Room, target participantBridgeTarget) im.Room { + if strings.TrimSpace(target.bridgeID) == "" { + return room + } + out := room + out.Members = make([]string, 0, len(room.Members)) + seen := make(map[string]struct{}, len(room.Members)) + for _, memberID := range room.Members { + deliveryID := strings.TrimSpace(memberID) + if target.matches(deliveryID) { + deliveryID = target.bridgeID + } + if deliveryID == "" { + continue + } + if _, ok := seen[deliveryID]; ok { + continue + } + seen[deliveryID] = struct{}{} + out.Members = append(out.Members, deliveryID) + } + return out +} + +func messageForParticipantBridgeTarget(message im.Message, target participantBridgeTarget) im.Message { + if strings.TrimSpace(target.bridgeID) == "" || len(target.aliases) == 0 { + return message + } + out := message + if len(message.Mentions) > 0 { + out.Mentions = append([]im.Mention(nil), message.Mentions...) + for idx := range out.Mentions { + if target.matches(out.Mentions[idx].ID) { + out.Mentions[idx].ID = target.bridgeID + } + } + } + out.Content = contentForParticipantBridgeTarget(message.Content, target) + return out +} + +func contentForParticipantBridgeTarget(content string, target participantBridgeTarget) string { + if content == "" { + return content + } + for _, alias := range target.aliases { + alias = strings.TrimSpace(alias) + if alias == "" || alias == target.bridgeID { + continue + } + content = strings.ReplaceAll(content, fmt.Sprintf(``, alias), fmt.Sprintf(``, target.bridgeID)) + } + return content +} + +func (h *Handler) handleParticipantEventsStream(w http.ResponseWriter, r *http.Request, participantID string) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming is not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + events, cancel := h.participantBridge.Subscribe(participantID) + defer func() { + cancel() + h.requeueBufferedParticipantEvents(participantID, events) + }() + controller := http.NewResponseController(w) + + if _, err := io.WriteString(w, ": connected\n\n"); err != nil { + return + } + if err := flushParticipantSSE(controller, flusher); err != nil { + return + } + h.replayRecentParticipantMessages(participantID, r.Header.Get("Last-Event-ID")) + heartbeat := time.NewTicker(participantHeartbeatInterval) + defer heartbeat.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-heartbeat.C: + if err := writeParticipantSSEComment(w, controller, flusher, "heartbeat"); err != nil { + return + } + case evt, ok := <-events: + if !ok { + return + } + if err := writeParticipantSSEEvent(w, controller, flusher, evt); err != nil { + h.participantBridge.Requeue(participantID, evt) + return + } + h.participantBridge.Ack(participantID, evt.MessageID) + } + } +} + +func writeParticipantSSEEvent(w http.ResponseWriter, controller *http.ResponseController, fallback http.Flusher, evt im.ParticipantEvent) error { + data, err := evt.MarshalJSONLine() + if err != nil { + return err + } + if id := participantSSEID(evt.MessageID); id != "" { + if _, err := fmt.Fprintf(w, "id: %s\n", id); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, "event: message\ndata: %s\n\n", data); err != nil { + return err + } + return flushParticipantSSE(controller, fallback) +} + +func writeParticipantSSEComment(w http.ResponseWriter, controller *http.ResponseController, fallback http.Flusher, comment string) error { + if _, err := fmt.Fprintf(w, ": %s\n\n", comment); err != nil { + return err + } + return flushParticipantSSE(controller, fallback) +} + +func flushParticipantSSE(controller *http.ResponseController, fallback http.Flusher) error { + if controller != nil { + if err := controller.Flush(); err == nil { + return nil + } else if !errors.Is(err, http.ErrNotSupported) { + return err + } + } + if fallback == nil { + return nil + } + fallback.Flush() + return nil +} + +func (h *Handler) requeueBufferedParticipantEvents(participantID string, events <-chan im.ParticipantEvent) { + if h == nil || h.participantBridge == nil { + return + } + for evt := range events { + h.participantBridge.Requeue(participantID, evt) + } +} + +func (h *Handler) replayRecentParticipantMessages(participantID, lastEventID string) { + if h == nil || h.im == nil || h.participantBridge == nil { + return + } + rooms := h.im.ListRoomsWithOptions(im.ListMessagesOptions{IncludeThreadReplies: true}) + cutoff := time.Now().UTC().Add(-participantReplayWindow) + replayAfter, hasReplayCursor := replayCursor(rooms, lastEventID) + for _, room := range rooms { + for idx, message := range room.Messages { + if !message.CreatedAt.IsZero() && message.CreatedAt.Before(cutoff) { + continue + } + if hasReplayCursor && isAtOrBeforeReplayCursor(message, lastEventID, replayAfter) { + continue + } + if h.isAgentSender(message.SenderID) { + continue + } + if h.hasLaterMessageFromBridgeTarget(room.Messages[idx+1:], participantID) { + continue + } + sender, ok := h.im.User(message.SenderID) + if !ok { + continue + } + if reason, ok, err := newConversationCommandReason(message.Content); err != nil { + slog.Warn("parse new conversation command failed", "participant_id", participantID, "message_id", message.ID, "error", err) + h.enqueueParticipantMessageEventForBridgeID(room, sender, message, participantID, "") + continue + } else if ok { + missed := h.publishNewConversationParticipantEvent(context.Background(), room, sender, message, reason) + h.reconnectMissedParticipantAgents(sender.ID, missed) + continue + } + // Route replay through the bridge so the stable message ID remains the + // dedupe key for events already delivered live or drained from pending. + h.enqueueParticipantMessageEventForBridgeID(room, sender, message, participantID, "") + } + } +} + +func (h *Handler) hasLaterMessageFromBridgeTarget(messages []im.Message, bridgeID string) bool { + target := h.participantBridgeTargetForBridgeID(bridgeID) + for _, message := range messages { + if target.matches(message.SenderID) { + return true + } + } + return false +} + +func replayCursor(rooms []im.Room, lastEventID string) (time.Time, bool) { + lastEventID = strings.TrimSpace(lastEventID) + if lastEventID == "" { + return time.Time{}, false + } + for _, room := range rooms { + for _, message := range room.Messages { + if message.ID == lastEventID { + return message.CreatedAt, true + } + } + } + return time.Time{}, false +} + +func isAtOrBeforeReplayCursor(message im.Message, lastEventID string, replayAfter time.Time) bool { + if message.ID == strings.TrimSpace(lastEventID) { + return true + } + if replayAfter.IsZero() || message.CreatedAt.IsZero() { + return false + } + return !message.CreatedAt.After(replayAfter) +} + +func participantSSEID(messageID string) string { + messageID = strings.TrimSpace(messageID) + messageID = strings.ReplaceAll(messageID, "\r", "") + messageID = strings.ReplaceAll(messageID, "\n", "") + return messageID +} + +func (h *Handler) reconnectMissedParticipantAgents(senderID string, participantIDs []string) { + if h == nil || h.svc == nil || h.isAgentSender(senderID) || len(participantIDs) == 0 { + return + } + seen := make(map[string]struct{}, len(participantIDs)) + for _, participantID := range participantIDs { + agentID := h.runtimeAgentIDForBridgeID(participantID) + if agentID == "" { + continue + } + if _, ok := seen[agentID]; ok { + continue + } + seen[agentID] = struct{}{} + if _, ok := h.svc.Agent(agentID); !ok { + continue + } + go h.recoverMissedParticipantDelivery(agentID) + } +} + +func (h *Handler) recoverMissedParticipantDelivery(participantID string) { + if h == nil || h.svc == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + view, err := h.svc.RuntimeView(ctx, participantID) + if err != nil { + slog.Warn("participant delivery recovery failed", "agent_id", participantID, "error", err) + return + } + if err := h.applyParticipantDeliveryRecoveryPolicy(ctx, view); err != nil { + slog.Warn("participant delivery recovery failed", "agent_id", participantID, "runtime_kind", view.RuntimeKind, "state", view.State, "error", err) + } +} + +func (h *Handler) applyParticipantDeliveryRecoveryPolicy(ctx context.Context, view agent.RuntimeView) error { + if h == nil || h.svc == nil { + return nil + } + switch view.State { + case agentruntime.StateCreated, agentruntime.StateStopped, agentruntime.StateExited, agentruntime.StateFailed: + _, err := h.svc.Start(ctx, view.AgentID) + return err + case agentruntime.StateRunning: + _, err := h.svc.Start(ctx, view.AgentID) + return err + case "", agentruntime.StateUnknown: + fallthrough + default: + _, err := h.svc.Recreate(ctx, view.AgentID) + return err + } +} + +func (h *Handler) isAgentSender(senderID string) bool { + if h == nil || h.svc == nil { + return false + } + _, ok := h.svc.Agent(h.runtimeAgentIDForBridgeID(senderID)) + return ok +} + +func (h *Handler) runtimeAgentIDForBridgeID(id string) string { + id = strings.TrimSpace(id) + if id == "" { + return "" + } + if id == agent.ManagerParticipantID { + return agent.ManagerUserID + } + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(participant.ChannelCSGClaw, id); ok { + if agentID := strings.TrimSpace(item.AgentID); agentID != "" { + return agentID + } + } + } + return id +} + +func hasLaterMessageFrom(messages []im.Message, senderID string) bool { + senderID = strings.TrimSpace(senderID) + if senderID == "" { + return false + } + for _, message := range messages { + if message.SenderID == senderID { + return true + } + } + return false +} + +func (h *Handler) handleParticipantSendMessage(w http.ResponseWriter, r *http.Request, participantID string) { + if h.im == nil { + http.Error(w, "im service is not configured", http.StatusServiceUnavailable) + return + } + var req im.ParticipantSendMessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + roomID := req.ResolvedRoomID() + text := req.ResolvedText() + threadRootID := req.ResolvedThreadRootID() + + message, err := h.im.DeliverMessage(im.DeliverMessageRequest{ + RoomID: roomID, + SenderID: participantID, + Content: text, + MessageID: req.MessageID, + ThreadRootID: threadRootID, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + h.publishMessageCreated(roomID, participantID, message) + h.publishThreadUpdated(roomID, message) + writeJSON(w, http.StatusOK, map[string]string{"message_id": message.ID}) +} diff --git a/internal/api/participant_test.go b/internal/api/participant_test.go new file mode 100644 index 00000000..dcd18a72 --- /dev/null +++ b/internal/api/participant_test.go @@ -0,0 +1,694 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" + csgclawchannel "csgclaw/internal/channel/csgclaw" + "csgclaw/internal/channel/feishu" + "csgclaw/internal/im" + "csgclaw/internal/participant" +) + +func TestCreateCSGClawAgentParticipantViaAPI(t *testing.T) { + agentSvc, _ := mustNewSeededServiceWithPath(t, nil) + imSvc := im.NewService() + participantSvc := participant.NewService( + participant.NewMemoryStore(nil), + participant.WithAgentService(agentSvc), + participant.WithIMService(imSvc), + ) + srv := &Handler{ + svc: agentSvc, + im: imSvc, + participant: participantSvc, + } + + body := `{ + "id": "qa", + "type": "agent", + "name": "QA Display Name", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "name": "QA Display Name", + "role": "worker", + "runtime_kind": "picoclaw_sandbox", + "image": "agent-image:test" + } + } + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants", strings.NewReader(body)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var created apitypes.Participant + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode response: %v", err) + } + if created.ID != "qa" || created.Channel != "csgclaw" || created.Type != "agent" || created.AgentID != "u-qa" { + t.Fatalf("created participant = %+v, want csgclaw agent qa bound to u-qa", created) + } + if _, ok := agentSvc.Agent("u-qa"); !ok { + t.Fatal("agent u-qa was not created") + } + if _, ok := agentSvc.Agent("u-qa-display-name"); ok { + t.Fatal("agent ID was derived from display name") + } + if user, ok := imSvc.User("u-qa"); !ok || !strings.EqualFold(user.Name, "QA Display Name") { + t.Fatalf("channel user = %+v, ok=%v; want u-qa display user", user, ok) + } +} + +func TestCreateFeishuAgentParticipantViaAPIReusesExistingAgent(t *testing.T) { + agentSvc, _ := mustNewSeededServiceWithPath(t, []agent.Agent{{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }}) + participantSvc := participant.NewService( + participant.NewMemoryStore(nil), + participant.WithAgentService(agentSvc), + ) + srv := &Handler{ + svc: agentSvc, + participant: participantSvc, + } + + body := `{ + "id": "test", + "type": "agent", + "name": "QA Feishu", + "channel_app_ref": "cli_xxx", + "channel_user": { + "ref": "ou_xxx", + "kind": "open_id" + }, + "agent_binding": { + "mode": "reuse", + "agent_id": "u-qa" + } + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/participants", strings.NewReader(body)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var created apitypes.Participant + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode response: %v", err) + } + if created.ID != "test" || created.Channel != "feishu" || created.AgentID != "u-qa" { + t.Fatalf("created participant = %+v, want feishu:test bound to u-qa", created) + } + if created.ChannelUserRef != "ou_xxx" || created.ChannelUserKind != "open_id" || created.ChannelAppRef != "cli_xxx" { + t.Fatalf("created channel identity = %+v, want Feishu app/open_id identity", created) + } +} + +func TestCreateFeishuHumanParticipantViaAPI(t *testing.T) { + participantSvc := participant.NewService(participant.NewMemoryStore(nil)) + srv := &Handler{participant: participantSvc} + + body := `{ + "id": "alice", + "type": "human", + "name": "Alice", + "channel_app_ref": "cli_xxx", + "channel_user": { + "ref": "ou_alice", + "kind": "open_id" + } + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/participants", strings.NewReader(body)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var created apitypes.Participant + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode response: %v", err) + } + if created.ID != "alice" || created.Type != "human" || created.AgentID != "" { + t.Fatalf("created participant = %+v, want unbound human alice", created) + } + if created.ChannelUserRef != "ou_alice" || created.ChannelUserKind != "open_id" || created.ChannelAppRef != "cli_xxx" { + t.Fatalf("created channel identity = %+v, want Feishu human open_id identity", created) + } +} + +func TestListAgentsIncludesParticipantsWhenRequested(t *testing.T) { + agentSvc, _ := mustNewSeededServiceWithPath(t, []agent.Agent{{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + }}) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "qa", + Channel: "csgclaw", + Type: "agent", + Name: "QA", + ChannelUserRef: "u-qa", + AgentID: "u-qa", + Mentionable: true, + }})) + srv := &Handler{ + svc: agentSvc, + participant: participantSvc, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?include_participants=true", nil) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var agents []map[string]any + if err := json.NewDecoder(rec.Body).Decode(&agents); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(agents) != 1 { + t.Fatalf("agents = %+v, want one agent", agents) + } + participants, ok := agents[0]["participants"].([]any) + if !ok || len(participants) != 1 { + t.Fatalf("participants = %#v, want one participant", agents[0]["participants"]) + } + got, ok := participants[0].(map[string]any) + if !ok || got["id"] != "qa" || got["channel"] != "csgclaw" { + t.Fatalf("participant expansion = %#v, want csgclaw qa", participants[0]) + } +} + +func TestParticipantMessageRouteSendsAsParticipantChannelUser(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-bob", Name: "bob", Handle: "bob"}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", "u-bob"}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "bob", + Channel: "csgclaw", + Type: "human", + Name: "Bob", + ChannelUserRef: "u-bob", + ChannelUserKind: "local_user_id", + LifecycleStatus: "active", + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + participantBridge: im.NewParticipantBridge("secret"), + serverAccessToken: "secret", + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/bob/messages", strings.NewReader(`{ + "room_id": "room-1", + "content": "hello from participant route" + }`)) + req.Header.Set("Authorization", "Bearer secret") + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + messages, err := imSvc.ListMessages("room-1") + if err != nil { + t.Fatalf("ListMessages() error = %v", err) + } + if len(messages) != 1 { + t.Fatalf("messages = %+v, want one delivered message", messages) + } + if messages[0].SenderID != "u-bob" || messages[0].Content != "hello from participant route" { + t.Fatalf("delivered message = %+v, want sender u-bob with posted content", messages[0]) + } +} + +func TestParticipantMessageRouteCanonicalizesAgentIDAlias(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager"}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", agent.ManagerParticipantID}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + participantBridge: im.NewParticipantBridge(""), + serverNoAuth: true, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{ + "room_id": "room-1", + "text": "hello from manager alias" + }`)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + messages, err := imSvc.ListMessages("room-1") + if err != nil { + t.Fatalf("ListMessages() error = %v", err) + } + if len(messages) != 1 { + t.Fatalf("messages = %+v, want one delivered message", messages) + } + if messages[0].SenderID != agent.ManagerParticipantID || messages[0].Content != "hello from manager alias" { + t.Fatalf("delivered message = %+v, want canonical manager participant sender", messages[0]) + } +} + +func TestParticipantNotificationRouteAcceptsNotificationParticipant(t *testing.T) { + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "alerts", + Channel: "csgclaw", + Type: "notification", + Name: "Alerts", + ChannelUserRef: "n-alerts", + ChannelUserKind: "local_user_id", + LifecycleStatus: "active", + Mentionable: true, + Metadata: map[string]any{ + "delivery_mode": "webhook", + "webhook_token": "secret-token", + }, + }})) + srv := &Handler{participant: participantSvc} + srv.SetNotificationDeliver(&noopFanouter{}) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/alerts/notifications", strings.NewReader(`{"hello":"world"}`)) + req.Header.Set("Authorization", "Bearer secret-token") + req.Header.Set("Content-Type", "application/json") + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String()) + } +} + +func TestParticipantEventsRouteRequiresAuthorization(t *testing.T) { + srv := &Handler{ + im: im.NewService(), + participantBridge: im.NewParticipantBridge("secret"), + serverAccessToken: "secret", + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/participants/u-manager/events", nil) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } +} + +func TestParticipantEventsRouteCanonicalizesAgentIDAlias(t *testing.T) { + now := time.Now().UTC() + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-agent-hhtz4b", Name: "qa", Handle: "qa"}, + }, + Rooms: []im.Room{{ + ID: "room-qa", + IsDirect: true, + Members: []string{"u-admin", "u-agent-hhtz4b"}, + Messages: []im.Message{{ + ID: "msg-qa", + SenderID: "u-admin", + Content: "qa only", + CreatedAt: now, + }}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "agent-hhtz4b", + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: "qa", + ChannelUserRef: "u-agent-hhtz4b", + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: "u-agent-hhtz4b", + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + participantBridge: im.NewParticipantBridge(""), + serverNoAuth: true, + } + + writer := &recordingFailingEventWriter{header: make(http.Header)} + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/participants/u-agent-hhtz4b/events", nil).WithContext(ctx) + + srv.Routes().ServeHTTP(writer, req) + + if got := writer.String(); !strings.Contains(got, `"message_id":"msg-qa"`) || !strings.Contains(got, `"account":"agent-hhtz4b"`) { + t.Fatalf("event stream = %q, want replay delivered on canonical participant id agent-hhtz4b", got) + } +} + +type recordingFailingEventWriter struct { + header http.Header + body strings.Builder +} + +func (w *recordingFailingEventWriter) Header() http.Header { + return w.header +} + +func (w *recordingFailingEventWriter) Write(data []byte) (int, error) { + w.body.Write(data) + if strings.Contains(string(data), "event: message") { + return 0, errors.New("stop after message event") + } + return len(data), nil +} + +func (w *recordingFailingEventWriter) WriteHeader(int) {} + +func (w *recordingFailingEventWriter) Flush() {} + +func (w *recordingFailingEventWriter) String() string { + return w.body.String() +} + +func TestCreateMessageResolvesCSGClawParticipantMentionToBridgeID(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager", Role: agent.RoleManager}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", agent.ManagerParticipantID}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewParticipantBridge("secret") + bus := im.NewBus() + events, cancel := bridge.Subscribe(agent.ManagerParticipantID) + defer cancel() + srv := &Handler{ + im: imSvc, + csgclaw: csgclawchannel.NewService(imSvc), + imBus: bus, + participant: participantSvc, + participantBridge: bridge, + serverNoAuth: true, + } + busEvents, cancelBus := bus.Subscribe() + done := make(chan struct{}) + go func() { + defer close(done) + for evt := range busEvents { + srv.PublishParticipantEvent(evt) + } + }() + defer func() { + cancelBus() + <-done + }() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/messages", strings.NewReader(`{ + "room_id": "room-1", + "sender_id": "u-admin", + "mention_id": "manager", + "content": "hello manager" + }`)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + select { + case evt := <-events: + if evt.Context.Account != agent.ManagerParticipantID || len(evt.Mentions) != 1 || evt.Mentions[0] != agent.ManagerParticipantID { + t.Fatalf("event = %+v, want bridge delivery for participant %q", evt, agent.ManagerParticipantID) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for manager bridge event") + } +} + +func TestPublishParticipantEventDeliversToParticipantIDWhenRoomUsesChannelUserRef(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-agent-hhtz4b", Name: "qa", Handle: "qa"}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", "u-agent-hhtz4b"}, + Messages: []im.Message{{ + ID: "msg-1", + SenderID: "u-admin", + Content: "hello qa", + CreatedAt: time.Now().UTC(), + }}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "agent-hhtz4b", + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: "qa", + ChannelUserRef: "u-agent-hhtz4b", + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: "u-agent-hhtz4b", + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewParticipantBridge("secret") + events, cancel := bridge.Subscribe("agent-hhtz4b") + defer cancel() + srv := &Handler{ + im: imSvc, + participant: participantSvc, + participantBridge: bridge, + } + room, ok := imSvc.Room("room-1") + if !ok || len(room.Messages) != 1 { + t.Fatalf("room = %+v, want one message", room) + } + sender, ok := imSvc.User("u-admin") + if !ok { + t.Fatal("missing admin sender") + } + + srv.PublishParticipantEvent(im.Event{ + Type: im.EventTypeMessageCreated, + RoomID: "room-1", + Sender: &sender, + Message: &room.Messages[0], + }) + + select { + case evt := <-events: + if evt.MessageID != "msg-1" || evt.Context.Account != "agent-hhtz4b" { + t.Fatalf("event = %+v, want participant-keyed delivery for agent-hhtz4b", evt) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for participant-keyed bridge event") + } +} + +func TestParticipantEventsRouteReceivesParticipantIDQueue(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager", Role: agent.RoleManager}, + {ID: agent.ManagerUserID, Name: "manager", Handle: "manager", Role: agent.RoleManager}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", agent.ManagerParticipantID}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewParticipantBridge("secret") + srv := &Handler{ + im: imSvc, + participant: participantSvc, + participantBridge: bridge, + serverAccessToken: "secret", + } + ctx, cancelReq := context.WithCancel(context.Background()) + defer cancelReq() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/participants/manager/events", nil).WithContext(ctx) + req.Header.Set("Authorization", "Bearer secret") + done := make(chan struct{}) + go func() { + srv.Routes().ServeHTTP(rec, req) + close(done) + }() + waitForCondition(t, time.Second, 10*time.Millisecond, func() bool { + return bridge.SubscriberCount(agent.ManagerParticipantID) > 0 + }) + if got := bridge.SubscriberCount(agent.ManagerUserID); got != 0 { + t.Fatalf("u-manager subscriber count = %d, want 0 because only participant ID should be used for CSGClaw delivery", got) + } + + room := im.Room{ID: "room-1", IsDirect: true, Members: []string{"u-admin", agent.ManagerParticipantID}} + sender := im.User{ID: "u-admin", Name: "admin", Handle: "admin"} + message := im.Message{ + ID: "msg-1", + SenderID: "u-admin", + Content: "hello manager", + CreatedAt: time.Now().UTC(), + } + bridge.PublishMessageEvent(room, sender, message) + waitForCondition(t, time.Second, 10*time.Millisecond, func() bool { + return strings.Contains(rec.Body.String(), `"message_id":"msg-1"`) + }) + cancelReq() + <-done +} + +func TestFeishuParticipantEventsRouteUsesParticipantChannelUserRef(t *testing.T) { + feishuSvc := feishu.NewService() + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "qa", + Channel: participant.ChannelFeishu, + Type: participant.TypeAgent, + Name: "QA", + ChannelUserRef: "ou_qa", + ChannelUserKind: participant.ChannelUserKindOpenID, + AgentID: "u-qa", + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + feishu: feishuSvc, + participant: participantSvc, + serverAccessToken: "secret", + } + + ctx, cancelReq := context.WithCancel(context.Background()) + defer cancelReq() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/qa/events", nil).WithContext(ctx) + req.Header.Set("Authorization", "Bearer secret") + done := make(chan struct{}) + go func() { + srv.Routes().ServeHTTP(rec, req) + close(done) + }() + + waitForCondition(t, time.Second, 10*time.Millisecond, func() bool { + return strings.Contains(rec.Body.String(), ": connected") + }) + feishuSvc.MessageBus().Publish(feishu.MessageEvent{ + Type: feishu.MessageEventTypeMessageCreated, + RoomID: "oc_alpha", + Message: &im.Message{ + ID: "om_qa", + SenderID: "ou_user", + Content: "hello qa", + Mentions: []im.Mention{ + {ID: "ou_qa"}, + }, + }, + }) + waitForCondition(t, time.Second, 10*time.Millisecond, func() bool { + return strings.Contains(rec.Body.String(), `"id":"om_qa"`) + }) + cancelReq() + <-done +} + +type noopFanouter struct{} + +func (noopFanouter) DeliverFanout(string, string) error { return nil } diff --git a/internal/api/rest_handlers.go b/internal/api/rest_handlers.go index 3587448f..087787da 100644 --- a/internal/api/rest_handlers.go +++ b/internal/api/rest_handlers.go @@ -9,9 +9,24 @@ func (h *Handler) getUpgradeStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) createUpgradeApply(w http.ResponseWriter, r *http.Request) { h.handleUpgradeApply(w, r) } -func (h *Handler) listBots(w http.ResponseWriter, r *http.Request) { h.handleBots(w, r) } -func (h *Handler) createBot(w http.ResponseWriter, r *http.Request) { h.handleBots(w, r) } -func (h *Handler) deleteBot(w http.ResponseWriter, r *http.Request) { h.handleBotByID(w, r) } +func (h *Handler) listParticipants(w http.ResponseWriter, r *http.Request) { + h.handleParticipants(w, r) +} +func (h *Handler) createParticipant(w http.ResponseWriter, r *http.Request) { + h.handleParticipants(w, r) +} +func (h *Handler) handleParticipantByID(w http.ResponseWriter, r *http.Request) { + h.handleParticipantByIDPath(w, r) +} +func (h *Handler) getParticipantEvents(w http.ResponseWriter, r *http.Request) { + h.handleParticipantEvents(w, r) +} +func (h *Handler) createParticipantMessage(w http.ResponseWriter, r *http.Request) { + h.handleParticipantMessage(w, r) +} +func (h *Handler) createParticipantNotification(w http.ResponseWriter, r *http.Request) { + h.pushNotificationParticipant(w, r) +} func (h *Handler) listAgents(w http.ResponseWriter, r *http.Request) { h.handleAgents(w, r) } func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { h.handleAgents(w, r) } func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) { h.handleAgentByID(w, r) } @@ -32,6 +47,18 @@ func (h *Handler) recreateAgent(w http.ResponseWriter, r *http.Request) { func (h *Handler) upgradeAgent(w http.ResponseWriter, r *http.Request) { h.handleAgentUpgradeByID(w, r) } +func (h *Handler) getAgentLLMModels(w http.ResponseWriter, r *http.Request) { + h.handleAgentLLMModelsByID(w, r) +} +func (h *Handler) createAgentLLMChatCompletions(w http.ResponseWriter, r *http.Request) { + h.handleAgentLLMChatCompletionsByID(w, r) +} +func (h *Handler) createAgentLLMResponses(w http.ResponseWriter, r *http.Request) { + h.handleAgentLLMResponsesByID(w, r) +} +func (h *Handler) getAgentLLMResponsesWebsocket(w http.ResponseWriter, r *http.Request) { + h.handleAgentLLMResponsesWebsocketByID(w, r) +} func (h *Handler) listHubTemplates(w http.ResponseWriter, r *http.Request) { h.handleHubTemplates(w, r) } @@ -111,9 +138,6 @@ func (h *Handler) updateFeishuConfig(w http.ResponseWriter, r *http.Request) { func (h *Handler) reloadFeishuConfig(w http.ResponseWriter, r *http.Request) { h.handleFeishuConfigReload(w) } -func (h *Handler) getFeishuBotEvents(w http.ResponseWriter, r *http.Request) { - h.handleFeishuBotByID(w, r) -} func (h *Handler) listFeishuUsers(w http.ResponseWriter, r *http.Request) { h.handleFeishuUsers(w, r) } func (h *Handler) createFeishuUser(w http.ResponseWriter, r *http.Request) { h.handleFeishuUsers(w, r) } func (h *Handler) deleteFeishuUser(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/router.go b/internal/api/router.go index 2f7a700c..564dee6a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6,7 +6,6 @@ func (h *Handler) Routes() chi.Router { router := chi.NewRouter() h.registerCoreRoutes(router) h.registerChannelRoutes(router) - h.registerBotCompatibilityRoutes(router) return router } @@ -34,6 +33,16 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { r.Get("/", h.getAgentProfile) r.Put("/", h.updateAgentProfile) }) + r.Route("/llm", func(r chi.Router) { + r.Get("/models", h.getAgentLLMModels) + r.Get("/v1/models", h.getAgentLLMModels) + r.Post("/chat/completions", h.createAgentLLMChatCompletions) + r.Post("/v1/chat/completions", h.createAgentLLMChatCompletions) + r.Post("/responses", h.createAgentLLMResponses) + r.Post("/v1/responses", h.createAgentLLMResponses) + r.Get("/responses", h.getAgentLLMResponsesWebsocket) + r.Get("/v1/responses", h.getAgentLLMResponsesWebsocket) + }) r.Post("/recreate", h.recreateAgent) r.Post("/upgrade", h.upgradeAgent) }) @@ -76,11 +85,6 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { }) r.Post("/invite", h.createIMRoomMembersInvite) }) - r.Route("/users", func(r chi.Router) { - r.Get("/", h.listUsers) - r.Post("/", h.createUser) - r.Delete("/{id}", h.deleteUser) - }) r.Route("/messages", func(r chi.Router) { r.Get("/", h.listMessages) r.Post("/", h.createMessage) @@ -117,23 +121,21 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { func (h *Handler) registerChannelRoutes(router chi.Router) { router.Route("/api/v1/channels", func(r chi.Router) { - // Generic bot CRUD for all channels. - r.Route("/{channel}/bots", func(r chi.Router) { - r.Get("/", h.listBots) - r.Post("/", h.createBot) - }) - r.Route("/{channel}/bots/{id}", func(r chi.Router) { - r.Get("/", h.handleBotByID) - r.Patch("/", h.handleBotByID) - r.Delete("/", h.deleteBot) + r.Route("/{channel}/participants", func(r chi.Router) { + r.Get("/", h.listParticipants) + r.Post("/", h.createParticipant) + }) + r.Route("/{channel}/participants/{id}", func(r chi.Router) { + r.Get("/", h.handleParticipantByID) + r.Patch("/", h.handleParticipantByID) + r.Delete("/", h.handleParticipantByID) + r.Get("/events", h.getParticipantEvents) + r.Post("/messages", h.createParticipantMessage) + r.Post("/notifications", h.createParticipantNotification) }) r.Post("/{channel}/activities/{activity_id}:decide", h.handleChannelActivityDecision) - // Channel-specific bot operations (not exposed on generic /{channel}/bots/{id}). - r.Post("/csgclaw/bots/{id}/notifications", h.pushNotificationBot) - r.Get("/feishu/bots/{id}/events", h.getFeishuBotEvents) - - // CSGClaw channel IM routes (flat paths so /csgclaw/bots stays on generic CRUD). + // CSGClaw channel IM routes. r.Route("/csgclaw/users", func(r chi.Router) { r.Get("/", h.listUsers) r.Post("/", h.createUser) @@ -159,7 +161,7 @@ func (h *Handler) registerChannelRoutes(router chi.Router) { r.Post("/", h.createMessage) }) - // Feishu channel routes (flat paths; bot list/CRUD uses generic /{channel}/bots). + // Feishu channel routes. r.Route("/feishu/config", func(r chi.Router) { r.Get("/", h.getFeishuConfig) r.Put("/", h.updateFeishuConfig) diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go index 9b1431da..08049cbc 100644 --- a/internal/apiclient/client.go +++ b/internal/apiclient/client.go @@ -13,6 +13,7 @@ import ( "csgclaw/internal/apitypes" "csgclaw/internal/config" + "csgclaw/internal/participant" ) type HTTPClient interface { @@ -43,45 +44,50 @@ func New(endpoint, token string, client HTTPClient) *Client { } } -func (c *Client) ListBots(ctx context.Context, channel, role, botType string) ([]apitypes.Bot, error) { - var bots []apitypes.Bot +func (c *Client) ListParticipants(ctx context.Context, channel, typ, agentID string) ([]apitypes.Participant, error) { + var participants []apitypes.Participant values := url.Values{} - if strings.TrimSpace(role) != "" { - values.Set("role", strings.TrimSpace(role)) + if strings.TrimSpace(typ) != "" { + values.Set("type", strings.TrimSpace(typ)) } - if strings.TrimSpace(botType) != "" { - values.Set("type", strings.TrimSpace(botType)) + if strings.TrimSpace(agentID) != "" { + values.Set("agent_id", strings.TrimSpace(agentID)) } - path, err := botCollectionPath(channel) + path, err := participantCollectionPath(channel) if err != nil { return nil, err } if encoded := values.Encode(); encoded != "" { path += "?" + encoded } - if err := c.GetJSON(ctx, path, &bots); err != nil { + if err := c.GetJSON(ctx, path, &participants); err != nil { return nil, err } - return bots, nil + return participants, nil } -func (c *Client) CreateBot(ctx context.Context, req apitypes.CreateBotRequest) (apitypes.Bot, error) { - var created apitypes.Bot - path, err := botCollectionPath(req.Channel) +func (c *Client) CreateParticipant(ctx context.Context, req participant.CreateRequest) (apitypes.Participant, error) { + var created apitypes.Participant + path, err := participantCollectionPath(req.Channel) if err != nil { - return apitypes.Bot{}, err + return apitypes.Participant{}, err } if err := c.DoJSON(ctx, http.MethodPost, path, req, &created); err != nil { - return apitypes.Bot{}, err + return apitypes.Participant{}, err } return created, nil } -func (c *Client) DeleteBot(ctx context.Context, channel, id string) error { - path, err := botItemPath(channel, id) +func (c *Client) DeleteParticipant(ctx context.Context, channel, id, deleteAgent string) error { + path, err := participantItemPath(channel, id) if err != nil { return err } + if strings.TrimSpace(deleteAgent) != "" { + values := url.Values{} + values.Set("delete_agent", strings.TrimSpace(deleteAgent)) + path += "?" + values.Encode() + } return c.DoNoContent(ctx, http.MethodDelete, path) } @@ -191,6 +197,9 @@ func (c *Client) ListUsers(ctx context.Context) ([]apitypes.User, error) { func (c *Client) ListUsersByChannel(ctx context.Context, channel string) ([]apitypes.User, error) { var users []apitypes.User + if strings.TrimSpace(channel) == "" { + channel = "csgclaw" + } path, err := channelPath(channel, "users") if err != nil { return nil, err @@ -203,6 +212,9 @@ func (c *Client) ListUsersByChannel(ctx context.Context, channel string) ([]apit func (c *Client) CreateUser(ctx context.Context, channel string, req apitypes.CreateUserRequest) (apitypes.User, error) { var created apitypes.User + if strings.TrimSpace(channel) == "" { + channel = "csgclaw" + } path, err := channelPath(channel, "users") if err != nil { return apitypes.User{}, err @@ -488,29 +500,29 @@ func channelPath(channelName, resource string) (string, error) { } } -func botCollectionPath(channelName string) (string, error) { +func participantCollectionPath(channelName string) (string, error) { channelName = strings.ToLower(strings.TrimSpace(channelName)) if channelName == "" { channelName = "csgclaw" } switch channelName { case "csgclaw", "feishu": - return "/api/v1/channels/" + channelName + "/bots", nil + return "/api/v1/channels/" + channelName + "/participants", nil default: return "", fmt.Errorf("unsupported channel %q", channelName) } } -func botItemPath(channelName, botID string) (string, error) { - path, err := botCollectionPath(channelName) +func participantItemPath(channelName, participantID string) (string, error) { + path, err := participantCollectionPath(channelName) if err != nil { return "", err } - botID = strings.TrimSpace(botID) - if botID == "" { - return "", fmt.Errorf("bot id is required") + participantID = strings.TrimSpace(participantID) + if participantID == "" { + return "", fmt.Errorf("participant id is required") } - return path + "/" + url.PathEscape(botID), nil + return path + "/" + url.PathEscape(participantID), nil } func memberCreatePath(channelName, roomID string) (string, error) { @@ -570,7 +582,7 @@ func userDeletePath(channelName, userID string) (string, error) { } switch channelName { case "": - return "/api/v1/users/" + url.PathEscape(userID), nil + return "/api/v1/channels/csgclaw/users/" + url.PathEscape(userID), nil case "csgclaw": return "/api/v1/channels/csgclaw/users/" + url.PathEscape(userID), nil case "feishu": diff --git a/internal/apiclient/notification_bots.go b/internal/apiclient/notification_bots.go deleted file mode 100644 index b33cca8f..00000000 --- a/internal/apiclient/notification_bots.go +++ /dev/null @@ -1,37 +0,0 @@ -package apiclient - -import ( - "context" - "net/http" - - "csgclaw/internal/apitypes" -) - -func (c *Client) CreateNotificationBot(ctx context.Context, req apitypes.CreateBotRequest) (apitypes.Bot, error) { - var created apitypes.Bot - path, err := botCollectionPath(req.Channel) - if err != nil { - return apitypes.Bot{}, err - } - req.Type = "notification" - if err := c.DoJSON(ctx, http.MethodPost, path, req, &created); err != nil { - return apitypes.Bot{}, err - } - return created, nil -} - -func (c *Client) PatchNotificationBot(ctx context.Context, channel, id string, req apitypes.PatchNotificationBotRequest) (apitypes.Bot, error) { - var updated apitypes.Bot - path, err := botItemPath(channel, id) - if err != nil { - return apitypes.Bot{}, err - } - if err := c.DoJSON(ctx, http.MethodPatch, path, req, &updated); err != nil { - return apitypes.Bot{}, err - } - return updated, nil -} - -func (c *Client) DeleteNotificationBot(ctx context.Context, channel, id string) error { - return c.DeleteBot(ctx, channel, id) -} diff --git a/internal/apitypes/participant.go b/internal/apitypes/participant.go new file mode 100644 index 00000000..de9d09e1 --- /dev/null +++ b/internal/apitypes/participant.go @@ -0,0 +1,26 @@ +package apitypes + +import "time" + +type Participant struct { + ID string `json:"id"` + Channel string `json:"channel"` + Type string `json:"type"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + ChannelUserRef string `json:"channel_user_ref,omitempty"` + ChannelUserKind string `json:"channel_user_kind,omitempty"` + ChannelAppRef string `json:"channel_app_ref,omitempty"` + AgentID string `json:"agent_id,omitempty"` + LifecycleStatus string `json:"lifecycle_status"` + Presence string `json:"presence,omitempty"` + Mentionable bool `json:"mentionable"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ParticipantRef struct { + Channel string `json:"channel,omitempty"` + ID string `json:"id"` +} diff --git a/internal/apitypes/types.go b/internal/apitypes/types.go index 9310e078..571f27cf 100644 --- a/internal/apitypes/types.go +++ b/internal/apitypes/types.go @@ -5,7 +5,7 @@ import ( "time" ) -type Bot struct { +type LegacyBot struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` @@ -28,28 +28,6 @@ type Bot struct { CreatedAt time.Time `json:"created_at"` } -type CreateBotRequest struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Type string `json:"type,omitempty"` - Image string `json:"image,omitempty"` - Avatar string `json:"avatar,omitempty"` - Role string `json:"role"` - Channel string `json:"channel,omitempty"` - RuntimeKind string `json:"runtime_kind,omitempty"` - FromTemplate string `json:"from_template,omitempty"` - RuntimeOptions map[string]any `json:"runtime_options,omitempty"` - AgentProfile *CreateAgentProfile `json:"agent_profile,omitempty"` -} - -type PatchNotificationBotRequest struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Avatar string `json:"avatar,omitempty"` - RuntimeOptions map[string]any `json:"runtime_options,omitempty"` -} - type User struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/app/channelwiring/notification_bot.go b/internal/app/channelwiring/notification_bot.go deleted file mode 100644 index bd59b14f..00000000 --- a/internal/app/channelwiring/notification_bot.go +++ /dev/null @@ -1,31 +0,0 @@ -package channelwiring - -import ( - "context" - - "csgclaw/internal/bot" - "csgclaw/internal/channel/csgclaw/notification_bot" - notificationpull "csgclaw/internal/channel/csgclaw/notification_bot/pull" - "csgclaw/internal/im" -) - -// WireNotificationBotPull starts the pull supervisor for notification bots and returns the fanout deliverer. -func WireNotificationBotPull(ctx context.Context, botSvc *bot.Service, imSvc *im.Service, apiBaseURL, accessToken string) notification_bot.Fanouter { - if botSvc == nil { - return nil - } - deliver := NewNotificationDeliver(imSvc, apiBaseURL, accessToken) - if deliver == nil { - return nil - } - go notificationpull.NewSupervisor(botSvc, deliver).Run(ctx) - return deliver -} - -// NewNotificationDeliver posts notification fan-out via POST /api/v1/messages. -func NewNotificationDeliver(imSvc *im.Service, apiBaseURL, accessToken string) *notification_bot.APIDeliver { - if imSvc == nil { - return nil - } - return notification_bot.NewAPIDeliver(imSvc, apiBaseURL, accessToken) -} diff --git a/internal/app/channelwiring/notification_participant.go b/internal/app/channelwiring/notification_participant.go new file mode 100644 index 00000000..94997175 --- /dev/null +++ b/internal/app/channelwiring/notification_participant.go @@ -0,0 +1,74 @@ +package channelwiring + +import ( + "context" + "strings" + + "csgclaw/internal/channel/csgclaw/notification" + notificationpull "csgclaw/internal/channel/csgclaw/notification/pull" + "csgclaw/internal/im" + "csgclaw/internal/participant" +) + +// WireNotificationParticipantPull starts the pull supervisor for notification participants and returns the fanout deliverer. +func WireNotificationParticipantPull(ctx context.Context, participantSvc *participant.Service, imSvc *im.Service, apiBaseURL, accessToken string) notification.Fanouter { + if participantSvc == nil { + return nil + } + deliver := NewNotificationDeliver(imSvc, apiBaseURL, accessToken) + if deliver == nil { + return nil + } + go notificationpull.NewSupervisor(notificationPullSource{ + participant: participantSvc, + }, deliver).Run(ctx) + return deliver +} + +// NewNotificationDeliver posts notification fan-out via POST /api/v1/messages. +func NewNotificationDeliver(imSvc *im.Service, apiBaseURL, accessToken string) *notification.APIDeliver { + if imSvc == nil { + return nil + } + return notification.NewAPIDeliver(imSvc, apiBaseURL, accessToken) +} + +type notificationPullSource struct { + participant *participant.Service +} + +func (s notificationPullSource) Reload() error { + return nil +} + +func (s notificationPullSource) ListNotificationParticipants(channel string) ([]notificationpull.NotificationParticipant, error) { + out := make([]notificationpull.NotificationParticipant, 0) + if s.participant != nil { + for _, item := range s.participant.List(participant.ListOptions{ + Channel: channel, + Type: participant.TypeNotification, + }) { + id := strings.TrimSpace(item.ID) + if id == "" { + continue + } + out = append(out, notificationpull.NotificationParticipant{ + ID: id, + UserID: item.ChannelUserRef, + }) + } + } + return out, nil +} + +func (s notificationPullSource) LookupNotificationParticipantForDelivery(channel, id string) (map[string]any, string, bool) { + channel = strings.TrimSpace(channel) + id = strings.TrimSpace(id) + if s.participant != nil { + item, ok := s.participant.Get(channel, id) + if ok && strings.EqualFold(strings.TrimSpace(item.Type), participant.TypeNotification) { + return item.Metadata, item.ChannelUserRef, true + } + } + return nil, "", false +} diff --git a/internal/app/channelwiring/notification_participant_test.go b/internal/app/channelwiring/notification_participant_test.go new file mode 100644 index 00000000..9d2d241a --- /dev/null +++ b/internal/app/channelwiring/notification_participant_test.go @@ -0,0 +1,46 @@ +package channelwiring + +import ( + "testing" + "time" + + "csgclaw/internal/apitypes" + "csgclaw/internal/participant" +) + +func TestNotificationPullSourceUsesNotificationParticipants(t *testing.T) { + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{ + { + ID: "alerts", + Channel: participant.ChannelCSGClaw, + Type: participant.TypeNotification, + Name: "Alerts", + ChannelUserRef: "n-alerts", + ChannelUserKind: participant.ChannelUserKindLocalUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + Metadata: map[string]any{ + "delivery_mode": "pull", + "remote_token": "secret-token", + }, + CreatedAt: time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC), + }, + })) + source := notificationPullSource{participant: participantSvc} + + items, err := source.ListNotificationParticipants(participant.ChannelCSGClaw) + if err != nil { + t.Fatalf("ListNotificationParticipants() error = %v", err) + } + if len(items) != 1 || items[0].ID != "alerts" || items[0].UserID != "n-alerts" { + t.Fatalf("participants = %+v, want alerts notification participant", items) + } + + metadata, userID, ok := source.LookupNotificationParticipantForDelivery(participant.ChannelCSGClaw, "alerts") + if !ok { + t.Fatal("LookupNotificationParticipantForDelivery() ok = false, want true") + } + if userID != "n-alerts" || metadata["remote_token"] != "secret-token" { + t.Fatalf("lookup metadata=%#v userID=%q, want stored participant delivery config", metadata, userID) + } +} diff --git a/internal/app/runtimewiring/openclaw.go b/internal/app/runtimewiring/openclaw.go index aff8a33e..98f10b0c 100644 --- a/internal/app/runtimewiring/openclaw.go +++ b/internal/app/runtimewiring/openclaw.go @@ -26,10 +26,10 @@ func UpdateOpenClawFeishuProvider(svc *agent.Service, provider feishu.BotCredent updateRuntimeFeishuProvider(svc, agentruntime.KindOpenClawSandbox, provider) } -func openClawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string, _ feishu.BotCredentialProvider) map[string]string { +func openClawBoxEnvVars(baseURL, accessToken, participantID, _ string, llmBaseURL, modelID string, _ feishu.BotCredentialProvider) map[string]string { env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) env["CSGCLAW_BASE_URL"] = baseURL env["CSGCLAW_ACCESS_TOKEN"] = accessToken - env["CSGCLAW_BOT_ID"] = botID + env["CSGCLAW_BOT_ID"] = participantID return env } diff --git a/internal/app/runtimewiring/picoclaw.go b/internal/app/runtimewiring/picoclaw.go index 416b72c4..56a6c21c 100644 --- a/internal/app/runtimewiring/picoclaw.go +++ b/internal/app/runtimewiring/picoclaw.go @@ -27,20 +27,21 @@ func UpdatePicoClawFeishuProvider(svc *agent.Service, provider feishu.BotCredent updateRuntimeFeishuProvider(svc, agentruntime.KindPicoClawSandbox, provider) } -func picoClawRuntimeEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { +func picoClawRuntimeEnvVars(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) picoclawModelID := picoclawBridgeModelID(modelID) env["CSGCLAW_BASE_URL"] = baseURL env["CSGCLAW_ACCESS_TOKEN"] = accessToken + env["PICOCLAW_CHANNELS_CSGCLAW_ENABLED"] = "true" env["PICOCLAW_CHANNELS_CSGCLAW_BASE_URL"] = baseURL env["PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN"] = accessToken - env["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"] = botID + env["PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID"] = participantID env["PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"] = modelID env["PICOCLAW_CUSTOM_MODEL_NAME"] = modelID env["PICOCLAW_CUSTOM_MODEL_ID"] = picoclawModelID env["PICOCLAW_CUSTOM_MODEL_API_KEY"] = accessToken env["PICOCLAW_CUSTOM_MODEL_BASE_URL"] = llmBaseURL - addFeishuBoxEnvVars(env, botID, provider) + addFeishuBoxEnvVars(env, agentID, provider) return env } @@ -56,8 +57,14 @@ func addFeishuBoxEnvVars(envVars map[string]string, botID string, provider feish if !ok { return } - envVars["PICOCLAW_CHANNELS_FEISHU_APP_ID"] = app.AppID - envVars["PICOCLAW_CHANNELS_FEISHU_APP_SECRET"] = app.AppSecret + appID := strings.TrimSpace(app.AppID) + appSecret := strings.TrimSpace(app.AppSecret) + if appID == "" || appSecret == "" { + return + } + envVars["PICOCLAW_CHANNELS_FEISHU_ENABLED"] = "true" + envVars["PICOCLAW_CHANNELS_FEISHU_APP_ID"] = appID + envVars["PICOCLAW_CHANNELS_FEISHU_APP_SECRET"] = appSecret } func picoclawBridgeModelID(modelID string) string { diff --git a/internal/app/runtimewiring/picoclaw_test.go b/internal/app/runtimewiring/picoclaw_test.go new file mode 100644 index 00000000..e5ef3096 --- /dev/null +++ b/internal/app/runtimewiring/picoclaw_test.go @@ -0,0 +1,88 @@ +package runtimewiring + +import ( + "testing" + + "csgclaw/internal/channel/feishu" +) + +func TestPicoClawRuntimeEnvVarsUseParticipantIDForCSGClawChannel(t *testing.T) { + env := picoClawRuntimeEnvVars( + "http://10.0.0.8:18080", + "shared-token", + "manager", + "u-manager", + "http://10.0.0.8:18080/api/v1/agents/u-manager/llm", + "minimax-m2.7", + nil, + ) + + if got, want := env["PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID"], "manager"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID = %q, want %q", got, want) + } + if _, ok := env["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"]; ok { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_BOT_ID should not be emitted") + } + if got, want := env["PICOCLAW_CHANNELS_CSGCLAW_ENABLED"], "true"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_ENABLED = %q, want %q", got, want) + } +} + +func TestPicoClawRuntimeEnvVarsEnableFeishuOnlyForConfiguredBot(t *testing.T) { + env := picoClawRuntimeEnvVars( + "http://10.0.0.8:18080", + "shared-token", + "u-missing", + "u-missing", + "http://10.0.0.8:18080/api/v1/agents/u-missing/llm", + "minimax-m2.7", + staticFeishuProvider{apps: map[string]feishu.AppConfig{ + "u-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, + }}, + ) + for _, key := range []string{ + "PICOCLAW_CHANNELS_FEISHU_ENABLED", + "PICOCLAW_CHANNELS_FEISHU_APP_ID", + "PICOCLAW_CHANNELS_FEISHU_APP_SECRET", + } { + if _, ok := env[key]; ok { + t.Fatalf("%s should not be emitted for an unconfigured Feishu bot", key) + } + } + + env = picoClawRuntimeEnvVars( + "http://10.0.0.8:18080", + "shared-token", + "manager", + "u-manager", + "http://10.0.0.8:18080/api/v1/agents/u-manager/llm", + "minimax-m2.7", + staticFeishuProvider{apps: map[string]feishu.AppConfig{ + "u-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, + }}, + ) + if got, want := env["PICOCLAW_CHANNELS_FEISHU_ENABLED"], "true"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_FEISHU_ENABLED = %q, want %q", got, want) + } + if got, want := env["PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID"], "manager"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID = %q, want %q", got, want) + } + if _, ok := env["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"]; ok { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_BOT_ID should not be emitted") + } + if got, want := env["PICOCLAW_CHANNELS_FEISHU_APP_ID"], "cli_manager"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_FEISHU_APP_ID = %q, want %q", got, want) + } + if got, want := env["PICOCLAW_CHANNELS_FEISHU_APP_SECRET"], "manager-secret"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_FEISHU_APP_SECRET = %q, want %q", got, want) + } +} + +type staticFeishuProvider struct { + apps map[string]feishu.AppConfig +} + +func (p staticFeishuProvider) BotConfig(botID string) (feishu.AppConfig, bool) { + app, ok := p.apps[botID] + return app, ok +} diff --git a/internal/app/runtimewiring/sandbox.go b/internal/app/runtimewiring/sandbox.go index 13a0c670..b5334deb 100644 --- a/internal/app/runtimewiring/sandbox.go +++ b/internal/app/runtimewiring/sandbox.go @@ -12,7 +12,7 @@ import ( "csgclaw/internal/sandbox" ) -type sandboxRuntimeEnvBuilder func(baseURL, accessToken, botID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string +type sandboxRuntimeEnvBuilder func(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string func withSandboxRuntimeHost(host agent.PicoClawRuntimeHost, feishuProvider feishu.BotCredentialProvider, buildRuntimeEnv sandboxRuntimeEnvBuilder, newRuntime func(sandboxgateway.Dependencies) agentruntime.Runtime) agent.ServiceOption { return func(s *agent.Service) error { diff --git a/internal/bot/model.go b/internal/bot/model.go deleted file mode 100644 index 9d53e6f2..00000000 --- a/internal/bot/model.go +++ /dev/null @@ -1,200 +0,0 @@ -package bot - -import ( - "fmt" - "slices" - "strings" - - "csgclaw/internal/apitypes" -) - -const ( - BotTypeNormal = "normal" - BotTypeNotification = "notification" - // NotificationBotIDPrefix separates notification bot ids from worker agent ids (u-{name}). - NotificationBotIDPrefix = "n-" -) - -type Role string - -const ( - RoleManager Role = "manager" - RoleWorker Role = "worker" -) - -type Channel string - -const ( - ChannelCSGClaw Channel = "csgclaw" - ChannelFeishu Channel = "feishu" -) - -type Bot = apitypes.Bot - -type CreateRequest = apitypes.CreateBotRequest - -func NormalizeBotType(botType string) string { - switch strings.ToLower(strings.TrimSpace(botType)) { - case BotTypeNotification: - return BotTypeNotification - default: - return BotTypeNormal - } -} - -func IsNotificationBot(b Bot) bool { - return NormalizeBotType(b.Type) == BotTypeNotification -} - -// notificationBotsAllowedForListChannel reports whether notification bots may appear in List results. -func notificationBotsAllowedForListChannel(listChannel, botChannel string) bool { - if listChannel != "" { - return listChannel == string(ChannelCSGClaw) - } - return botChannel == string(ChannelCSGClaw) -} - -// shouldIncludeBotInList applies channel and optional type list criteria. -// Empty listType returns all bot types allowed for the channel (csgclaw: normal+notification; feishu: normal only). -func shouldIncludeBotInList(b Bot, listChannel, listType string) bool { - normalizedType := "" - if t := strings.TrimSpace(listType); t != "" { - normalizedType = NormalizeBotType(t) - } - isNotification := IsNotificationBot(b) - - switch normalizedType { - case BotTypeNormal: - return !isNotification - case BotTypeNotification: - if !isNotification { - return false - } - return notificationBotsAllowedForListChannel(listChannel, b.Channel) - default: - if isNotification { - return notificationBotsAllowedForListChannel(listChannel, b.Channel) - } - return true - } -} - -func NormalizeCreateRequest(req CreateRequest) (CreateRequest, error) { - req.ID = strings.TrimSpace(req.ID) - req.Name = strings.TrimSpace(req.Name) - req.Description = strings.TrimSpace(req.Description) - req.Image = strings.TrimSpace(req.Image) - req.Avatar = strings.TrimSpace(req.Avatar) - req.RuntimeKind = strings.TrimSpace(req.RuntimeKind) - req.FromTemplate = strings.TrimSpace(req.FromTemplate) - req.Type = NormalizeBotType(req.Type) - if req.Name == "" { - return CreateRequest{}, fmt.Errorf("name is required") - } - - role, err := NormalizeRole(req.Role) - if err != nil { - return CreateRequest{}, err - } - channel, err := NormalizeChannel(req.Channel) - if err != nil { - return CreateRequest{}, err - } - req.Role = string(role) - req.Channel = string(channel) - return req, nil -} - -func ValidateCreateRequest(req CreateRequest) error { - _, err := NormalizeCreateRequest(req) - return err -} - -func NormalizeBot(b Bot) (Bot, error) { - b.ID = strings.TrimSpace(b.ID) - b.Name = strings.TrimSpace(b.Name) - b.Description = strings.TrimSpace(b.Description) - b.Avatar = strings.TrimSpace(b.Avatar) - b.AgentID = strings.TrimSpace(b.AgentID) - b.UserID = strings.TrimSpace(b.UserID) - b.Type = NormalizeBotType(b.Type) - if b.ID == "" { - return Bot{}, fmt.Errorf("id is required") - } - if b.Name == "" { - return Bot{}, fmt.Errorf("name is required") - } - - role, err := NormalizeRole(b.Role) - if err != nil { - return Bot{}, err - } - channel, err := NormalizeChannel(b.Channel) - if err != nil { - return Bot{}, err - } - b.Role = string(role) - b.Channel = string(channel) - if IsNotificationBot(b) { - b.Available = false - } else { - b.Available = true - } - return b, nil -} - -func ValidateBot(b Bot) error { - _, err := NormalizeBot(b) - return err -} - -func NormalizeRole(role string) (Role, error) { - switch Role(strings.ToLower(strings.TrimSpace(role))) { - case RoleManager: - return RoleManager, nil - case RoleWorker: - return RoleWorker, nil - default: - return "", fmt.Errorf("role must be one of %q or %q", RoleManager, RoleWorker) - } -} - -func NormalizeChannel(channel string) (Channel, error) { - switch Channel(strings.ToLower(strings.TrimSpace(channel))) { - case "", ChannelCSGClaw: - return ChannelCSGClaw, nil - case ChannelFeishu: - return ChannelFeishu, nil - default: - return "", fmt.Errorf("channel must be one of %q or %q", ChannelCSGClaw, ChannelFeishu) - } -} - -func sortedBotsFromMap(items map[string]Bot) []Bot { - bots := make([]Bot, 0, len(items)) - for _, b := range items { - bots = append(bots, b) - } - slices.SortFunc(bots, func(a, b Bot) int { - if a.CreatedAt.Equal(b.CreatedAt) { - if a.ID != b.ID { - if a.ID < b.ID { - return -1 - } - return 1 - } - if a.Channel < b.Channel { - return -1 - } - if a.Channel > b.Channel { - return 1 - } - return 0 - } - if a.CreatedAt.Before(b.CreatedAt) { - return -1 - } - return 1 - }) - return bots -} diff --git a/internal/bot/model_test.go b/internal/bot/model_test.go deleted file mode 100644 index c29dacb1..00000000 --- a/internal/bot/model_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package bot - -import "testing" - -func TestNormalizeBotTypeDefaultsToNormal(t *testing.T) { - if got := NormalizeBotType(""); got != BotTypeNormal { - t.Fatalf("NormalizeBotType(\"\") = %q, want %q", got, BotTypeNormal) - } - if got := NormalizeBotType("notification"); got != BotTypeNotification { - t.Fatalf("NormalizeBotType(notification) = %q, want %q", got, BotTypeNotification) - } -} - -func TestNormalizeBotSetsTypeDefault(t *testing.T) { - b, err := NormalizeBot(Bot{ - ID: "u-test", - Name: "test", - Role: "worker", - Channel: "csgclaw", - }) - if err != nil { - t.Fatalf("NormalizeBot() error = %v", err) - } - if b.Type != BotTypeNormal { - t.Fatalf("Type = %q, want %q", b.Type, BotTypeNormal) - } -} - -func TestShouldIncludeBotInList(t *testing.T) { - t.Parallel() - notify := Bot{ID: "n-a", Type: BotTypeNotification, Channel: string(ChannelCSGClaw)} - worker := Bot{ID: "u-a", Type: BotTypeNormal, Channel: string(ChannelCSGClaw)} - - if !shouldIncludeBotInList(worker, string(ChannelCSGClaw), "") { - t.Fatal("normal bot should appear in csgclaw list") - } - if !shouldIncludeBotInList(notify, string(ChannelCSGClaw), "") { - t.Fatal("notification bot should appear in csgclaw list") - } - if shouldIncludeBotInList(notify, string(ChannelFeishu), "") { - t.Fatal("notification bot should be excluded for feishu list") - } - if !shouldIncludeBotInList(notify, "", "") { - t.Fatal("notification bot should appear when listing all channels") - } - notifyFeishu := Bot{ID: "n-f", Type: BotTypeNotification, Channel: string(ChannelFeishu)} - if shouldIncludeBotInList(notifyFeishu, "", "") { - t.Fatal("feishu notification bot should be excluded when listing all channels") - } - if !shouldIncludeBotInList(notify, string(ChannelCSGClaw), BotTypeNotification) { - t.Fatal("type=notification should include csgclaw notification bot") - } - if shouldIncludeBotInList(worker, string(ChannelCSGClaw), BotTypeNotification) { - t.Fatal("type=notification should exclude normal bot") - } - if !shouldIncludeBotInList(worker, string(ChannelCSGClaw), BotTypeNormal) { - t.Fatal("type=normal should include worker bot") - } - if shouldIncludeBotInList(notify, string(ChannelCSGClaw), BotTypeNormal) { - t.Fatal("type=normal should exclude notification bot") - } -} diff --git a/internal/bot/notification.go b/internal/bot/notification.go deleted file mode 100644 index 554a1ab0..00000000 --- a/internal/bot/notification.go +++ /dev/null @@ -1,252 +0,0 @@ -package bot - -import ( - "context" - "fmt" - "strings" - "time" - - "csgclaw/internal/channel/csgclaw/notification_bot" - "csgclaw/internal/im" - "csgclaw/internal/utils" -) - -func (s *Service) Reload() error { - if s == nil || s.store == nil { - return fmt.Errorf("bot store is required") - } - return s.store.Reload() -} - -func (s *Service) ListNotificationBots(channel string) ([]Bot, error) { - return s.List(channel, "", BotTypeNotification) -} - -func (s *Service) LookupNotificationBotForDelivery(channel, id string) (runtimeOptions map[string]any, userID string, ok bool) { - if s == nil || s.store == nil { - return nil, "", false - } - b, found, err := s.store.GetByChannelID(channel, id) - if err != nil || !found || !IsNotificationBot(b) { - return nil, "", false - } - userID = strings.TrimSpace(b.UserID) - if userID == "" { - userID = strings.TrimSpace(b.ID) - } - return notification_bot.FlatFromRuntimeOptionsMap(b.RuntimeOptions), userID, true -} - -func (s *Service) GetNotificationBot(channel, id string) (Bot, error) { - if s == nil || s.store == nil { - return Bot{}, fmt.Errorf("bot store is required") - } - b, ok, err := s.store.GetByChannelID(channel, id) - if err != nil { - return Bot{}, err - } - if !ok || !IsNotificationBot(b) { - return Bot{}, fmt.Errorf("notification bot %q not found", id) - } - return s.presentNotificationBot(b), nil -} - -func (s *Service) CreateNotificationBot(ctx context.Context, req CreateRequest) (Bot, error) { - if s == nil || s.store == nil { - return Bot{}, fmt.Errorf("bot store is required") - } - req.Type = BotTypeNotification - if strings.TrimSpace(req.Role) == "" { - req.Role = string(RoleWorker) - } - normalized, err := NormalizeCreateRequest(req) - if err != nil { - return Bot{}, err - } - if normalized.Channel != string(ChannelCSGClaw) { - return Bot{}, fmt.Errorf("notification bots are only supported on channel %q", ChannelCSGClaw) - } - if existing, ok := s.findByChannelName(normalized.Channel, normalized.Name); ok { - return Bot{}, fmt.Errorf("bot name %q already exists in channel %q with id %q", normalized.Name, normalized.Channel, existing.ID) - } - botID := notificationBotID(normalized) - if err := s.validateNotificationBotID(normalized.Channel, botID); err != nil { - return Bot{}, err - } - - userID, userCreatedAt, err := s.ensureChannelUserForBot(ctx, normalized.Channel, channelBotIdentity{ - ID: botID, - Name: normalized.Name, - Description: normalized.Description, - Avatar: normalized.Avatar, - Role: "Worker", - }) - if err != nil { - return Bot{}, err - } - createdAt := userCreatedAt.UTC() - if createdAt.IsZero() { - createdAt = time.Now().UTC() - } - runtimeOptions := utils.CloneAnyMap(normalized.RuntimeOptions) - flat := notification_bot.NormalizeFlatForStorage(notification_bot.FlatFromRuntimeOptionsMap(runtimeOptions)) - b := Bot{ - ID: botID, - Name: normalized.Name, - Description: normalized.Description, - Avatar: normalized.Avatar, - Type: BotTypeNotification, - Role: string(RoleWorker), - Channel: normalized.Channel, - UserID: userID, - RuntimeOptions: flat, - CreatedAt: createdAt, - } - if err := s.store.Save(b); err != nil { - return Bot{}, err - } - return s.presentNotificationBot(b), nil -} - -func (s *Service) PatchNotificationBot(ctx context.Context, channel, id string, patch CreateRequest) (Bot, error) { - if s == nil || s.store == nil { - return Bot{}, fmt.Errorf("bot store is required") - } - existing, ok, err := s.store.GetByChannelID(channel, id) - if err != nil { - return Bot{}, err - } - if !ok || !IsNotificationBot(existing) { - return Bot{}, fmt.Errorf("notification bot %q not found", id) - } - if name := strings.TrimSpace(patch.Name); name != "" { - existing.Name = name - } - if desc := strings.TrimSpace(patch.Description); desc != "" { - existing.Description = desc - } - if avatar := strings.TrimSpace(patch.Avatar); avatar != "" { - existing.Avatar = avatar - } - if len(patch.RuntimeOptions) > 0 { - merged := notification_bot.MergeRuntimeOptionsPatch( - notification_bot.FlatFromRuntimeOptionsMap(existing.RuntimeOptions), - patch.RuntimeOptions, - ) - existing.RuntimeOptions = notification_bot.NormalizeFlatForStorage(merged) - } - if err := s.store.Save(existing); err != nil { - return Bot{}, err - } - return s.presentNotificationBot(existing), nil -} - -func (s *Service) presentNotificationBot(b Bot) Bot { - b.Type = BotTypeNotification - flat := notification_bot.FlatFromRuntimeOptionsMap(b.RuntimeOptions) - b.Available = notification_bot.ProfileDeliveryComplete(flat) - view := notification_bot.ViewRuntimeOptionsForAPI(b.RuntimeOptions) - if len(view) > 0 { - b.RuntimeOptions = view - } - return b -} - -func notificationBotID(req CreateRequest) string { - if id := strings.TrimSpace(req.ID); id != "" { - return id - } - return NotificationBotIDPrefix + strings.TrimSpace(req.Name) -} - -func (s *Service) validateNotificationBotID(channel, botID string) error { - if s == nil || s.store == nil { - return fmt.Errorf("bot store is required") - } - botID = strings.TrimSpace(botID) - if botID == "" { - return fmt.Errorf("bot id is required") - } - if _, ok, err := s.store.GetByChannelID(channel, botID); err != nil { - return err - } else if ok { - return fmt.Errorf("bot id %q already exists", botID) - } - if s.agents != nil { - if a, ok := s.agents.Agent(botID); ok { - return fmt.Errorf("bot id %q conflicts with existing agent %q (role %q)", botID, a.ID, a.Role) - } - } - for _, b := range s.store.List() { - if b.Channel != channel { - continue - } - if IsNotificationBot(b) { - continue - } - if strings.TrimSpace(b.ID) == botID || strings.TrimSpace(b.AgentID) == botID { - return fmt.Errorf("bot id %q conflicts with existing channel bot %q", botID, b.ID) - } - } - return nil -} - -// BotByChannelID returns the stored bot record without API redaction. -func (s *Service) BotByChannelID(channel, id string) (Bot, bool, error) { - if s == nil || s.store == nil { - return Bot{}, false, fmt.Errorf("bot store is required") - } - return s.store.GetByChannelID(channel, id) -} - -type channelBotIdentity struct { - ID string - Name string - Description string - Handle string - Role string - Avatar string -} - -func (s *Service) ensureChannelUserForBot(ctx context.Context, channelName string, identity channelBotIdentity) (string, time.Time, error) { - handle := strings.TrimSpace(identity.Handle) - if handle == "" { - if h, ok := sanitizeHandle(strings.ToLower(strings.ReplaceAll(strings.TrimSpace(identity.Name), " ", "-"))); ok { - handle = h - } else if h, ok := notificationBotHandleFromID(identity.ID); ok { - handle = h - } else { - handle = "notification" - } - } - switch channelName { - case string(ChannelCSGClaw): - if s.imProv == nil { - return "", time.Time{}, fmt.Errorf("im provisioner is required") - } - result, err := s.imProv.EnsureAgentUser(ctx, im.AgentIdentity{ - ID: identity.ID, - Name: identity.Name, - Description: identity.Description, - Handle: handle, - Role: identity.Role, - Avatar: identity.Avatar, - }) - if err != nil { - return "", time.Time{}, fmt.Errorf("failed to ensure im user: %w", err) - } - return result.User.ID, result.User.CreatedAt, nil - default: - return "", time.Time{}, fmt.Errorf("notification bots are only supported on channel %q", ChannelCSGClaw) - } -} - -func notificationBotHandleFromID(id string) (string, bool) { - stem := strings.TrimSpace(id) - for _, prefix := range []string{NotificationBotIDPrefix, "u-"} { - if h, ok := sanitizeHandle(strings.ToLower(strings.TrimPrefix(stem, prefix))); ok { - return h, true - } - } - return "", false -} diff --git a/internal/bot/notification_test.go b/internal/bot/notification_test.go deleted file mode 100644 index 4dce2208..00000000 --- a/internal/bot/notification_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package bot - -import ( - "context" - "strings" - "testing" - - "csgclaw/internal/channel/feishu" - "csgclaw/internal/im" -) - -func TestNotificationBotIDUsesDedicatedPrefix(t *testing.T) { - t.Parallel() - got := notificationBotID(CreateRequest{Name: "alerts"}) - if got != "n-alerts" { - t.Fatalf("notificationBotID() = %q, want n-alerts", got) - } - if got := notificationBotID(CreateRequest{ID: "custom-id", Name: "alerts"}); got != "custom-id" { - t.Fatalf("explicit id = %q, want custom-id", got) - } -} - -func TestCreateNotificationBotRejectsChannelBotIDConflict(t *testing.T) { - imSvc := im.NewService() - botStore, err := NewMemoryStore([]Bot{{ - ID: "u-alice", - Name: "alice", - Type: BotTypeNormal, - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - }}) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - botSvc, err := NewService(botStore) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - botSvc.SetDependencies(nil, imSvc) - - _, err = botSvc.CreateNotificationBot(context.Background(), CreateRequest{ - ID: "u-alice", - Name: "notify-alice", - RuntimeOptions: map[string]any{ - "delivery_mode": "webhook", - "webhook_token": "tok", - }, - }) - if err == nil || (!strings.Contains(err.Error(), "conflicts with existing channel bot") && !strings.Contains(err.Error(), "already exists")) { - t.Fatalf("CreateNotificationBot() error = %v, want id conflict", err) - } -} - -func TestCreateNotificationBotUsesDedicatedIMUser(t *testing.T) { - imSvc := im.NewService() - botStore, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - botSvc, err := NewService(botStore) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - botSvc.SetDependencies(nil, imSvc) - - created, err := botSvc.CreateNotificationBot(context.Background(), CreateRequest{ - Name: "alice", - RuntimeOptions: map[string]any{ - "delivery_mode": "webhook", - "webhook_token": "tok", - }, - }) - if err != nil { - t.Fatalf("CreateNotificationBot() error = %v", err) - } - if created.ID != "n-alice" { - t.Fatalf("created.ID = %q, want n-alice", created.ID) - } - if created.UserID != "n-alice" { - t.Fatalf("created.UserID = %q, want dedicated IM user n-alice", created.UserID) - } -} - -func TestDeleteNotificationBotSkipsSharedIMUser(t *testing.T) { - imSvc := im.NewService() - if _, _, err := imSvc.EnsureAgentUser(im.EnsureAgentUserRequest{ - ID: "u-shared", - Name: "shared-worker", - Handle: "shared-worker", - Role: "worker", - }); err != nil { - t.Fatalf("EnsureAgentUser(worker) error = %v", err) - } - - botStore, err := NewMemoryStore([]Bot{{ - ID: "n-notify", - Name: "notify", - Type: BotTypeNotification, - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - UserID: "u-shared", - }}) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - botSvc, err := NewService(botStore) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - botSvc.SetDependencies(nil, imSvc) - - if err := botSvc.Delete(context.Background(), string(ChannelCSGClaw), "n-notify"); err != nil { - t.Fatalf("Delete() error = %v", err) - } - if _, ok := imSvc.User("u-shared"); !ok { - t.Fatal("IM user u-shared was deleted but is still referenced by a worker") - } -} - -func TestServiceListIncludesNotificationBotsForCSGClawOnly(t *testing.T) { - botStore, err := NewMemoryStore([]Bot{ - {ID: "u-worker", Name: "worker", Type: BotTypeNormal, Role: string(RoleWorker), Channel: string(ChannelCSGClaw), AgentID: "u-worker", UserID: "u-worker"}, - {ID: "n-notify", Name: "notify", Type: BotTypeNotification, Role: string(RoleWorker), Channel: string(ChannelCSGClaw), UserID: "n-notify"}, - {ID: "n-feishu", Name: "feishu-notify", Type: BotTypeNotification, Role: string(RoleWorker), Channel: string(ChannelFeishu), UserID: "n-feishu"}, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - botSvc, err := NewService(botStore) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - - csgclaw, err := botSvc.List(string(ChannelCSGClaw), "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) - } - if len(csgclaw) != 2 { - t.Fatalf("List(csgclaw) len = %d, want worker + notification", len(csgclaw)) - } - feishu, err := botSvc.List(string(ChannelFeishu), "", "") - if err != nil { - t.Fatalf("List(feishu) error = %v", err) - } - for _, b := range feishu { - if IsNotificationBot(b) { - t.Fatalf("List(feishu) included notification bot %q", b.ID) - } - } - - notifyOnly, err := botSvc.List(string(ChannelCSGClaw), "", BotTypeNotification) - if err != nil { - t.Fatalf("List(csgclaw, notification) error = %v", err) - } - if len(notifyOnly) != 1 || notifyOnly[0].ID != "n-notify" { - t.Fatalf("List(csgclaw, notification) = %+v, want n-notify only", notifyOnly) - } - normalOnly, err := botSvc.List(string(ChannelCSGClaw), "", BotTypeNormal) - if err != nil { - t.Fatalf("List(csgclaw, normal) error = %v", err) - } - if len(normalOnly) != 1 || normalOnly[0].ID != "u-worker" { - t.Fatalf("List(csgclaw, normal) = %+v, want u-worker only", normalOnly) - } -} - -func TestServiceListFeishuSkipsConfiguredBotsForNotificationType(t *testing.T) { - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - feishuSvc := feishu.NewServiceWithBotOpenIDResolver( - map[string]feishu.AppConfig{ - "u-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, - }, - func(_ context.Context, app feishu.AppConfig) (feishu.BotInfo, error) { - return feishu.BotInfo{OpenID: "ou_manager", AppName: "Manager Bot"}, nil - }, - ) - svc, err := NewServiceWithDependencies(store, nil, nil, feishuSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - got, err := svc.List(string(ChannelFeishu), "", BotTypeNotification) - if err != nil { - t.Fatalf("List(feishu, notification) error = %v", err) - } - if len(got) != 0 { - t.Fatalf("List(feishu, notification) = %+v, want empty (no configured feishu bots)", got) - } -} diff --git a/internal/bot/service.go b/internal/bot/service.go deleted file mode 100644 index c42d288d..00000000 --- a/internal/bot/service.go +++ /dev/null @@ -1,718 +0,0 @@ -package bot - -import ( - "context" - "fmt" - "slices" - "strings" - "time" - - "csgclaw/internal/agent" - "csgclaw/internal/apitypes" - "csgclaw/internal/channel/feishu" - "csgclaw/internal/im" - "csgclaw/internal/utils" -) - -type Service struct { - store *Store - agents *agent.Service - im *im.Service - imBus *im.Bus - imProv *im.Provisioner - feishu *feishu.Service -} - -func NewService(store *Store) (*Service, error) { - if store == nil { - return nil, fmt.Errorf("bot store is required") - } - return &Service{store: store}, nil -} - -func NewServiceWithDependencies(store *Store, agentSvc *agent.Service, imSvc *im.Service, feishuSvc ...*feishu.Service) (*Service, error) { - s, err := NewService(store) - if err != nil { - return nil, err - } - s.SetDependencies(agentSvc, imSvc, feishuSvc...) - return s, nil -} - -func (s *Service) SetDependencies(agentSvc *agent.Service, imSvc *im.Service, feishuSvc ...*feishu.Service) { - if s == nil { - return - } - s.agents = agentSvc - s.im = imSvc - s.imProv = im.NewProvisioner(imSvc, s.imBus) - if len(feishuSvc) > 0 { - s.feishu = feishuSvc[0] - } -} - -func (s *Service) SetIMBus(bus *im.Bus) { - if s == nil { - return - } - s.imBus = bus - s.imProv = im.NewProvisioner(s.im, bus) -} - -func (s *Service) List(channel, role, botType string) ([]Bot, error) { - if s == nil || s.store == nil { - return nil, fmt.Errorf("bot store is required") - } - - all := s.store.List() - normalizedChannel := "" - if strings.TrimSpace(channel) != "" { - normalized, err := NormalizeChannel(channel) - if err != nil { - return nil, err - } - normalizedChannel = string(normalized) - } - - normalizedRole := "" - if strings.TrimSpace(role) != "" { - normalized, err := NormalizeRole(role) - if err != nil { - return nil, err - } - normalizedRole = string(normalized) - } - - filtered := make([]Bot, 0, len(all)) - for _, b := range all { - if !shouldIncludeBotInList(b, normalizedChannel, botType) { - continue - } - if normalizedChannel != "" && b.Channel != normalizedChannel { - continue - } - if normalizedRole != "" && b.Role != normalizedRole { - continue - } - filtered = append(filtered, b) - } - filtered = s.refreshBotAvailability(filtered) - if normalizedChannel == string(ChannelFeishu) && NormalizeBotType(botType) != BotTypeNotification { - var err error - filtered, err = s.appendConfiguredFeishuBots(context.Background(), filtered, normalizedRole) - if err != nil { - return nil, err - } - } - return filtered, nil -} - -func (s *Service) refreshBotAvailability(bots []Bot) []Bot { - if s == nil || s.agents == nil { - return bots - } - refreshed := make([]Bot, 0, len(bots)) - for _, b := range bots { - if IsNotificationBot(b) { - refreshed = append(refreshed, s.presentNotificationBot(b)) - continue - } - agentID := strings.TrimSpace(b.AgentID) - b.Available = false - if agentID != "" { - if a, ok := s.agents.Agent(agentID); ok { - b.AgentID = a.ID - b.Available = strings.EqualFold(strings.TrimSpace(a.Status), "running") - b.RuntimeKind = strings.TrimSpace(a.RuntimeKind) - b.Image = strings.TrimSpace(a.Image) - b.Avatar = strings.TrimSpace(a.Avatar) - b.Status = strings.TrimSpace(a.Status) - b.Provider = strings.TrimSpace(a.AgentProfile.Provider) - b.ModelID = strings.TrimSpace(a.AgentProfile.ModelID) - b.ProfileComplete = a.ProfileComplete || a.AgentProfile.ProfileComplete - b.EnvRestartRequired = a.AgentProfile.EnvRestartRequired - b.ImageUpgradeRequired = a.AgentProfile.ImageUpgradeRequired - } - } - refreshed = append(refreshed, b) - } - return refreshed -} - -func (s *Service) appendConfiguredFeishuBots(ctx context.Context, bots []Bot, role string) ([]Bot, error) { - if s.feishu == nil { - return bots, nil - } - apps := s.feishu.AppConfigs() - if len(apps) == 0 { - return bots, nil - } - - seen := make(map[string]struct{}, len(bots)) - for _, b := range bots { - id := strings.TrimSpace(b.ID) - seen[id] = struct{}{} - seen[configuredFeishuBotDisplayID(id)] = struct{}{} - } - - configuredIDs := make([]string, 0, len(apps)) - for id := range apps { - id = strings.TrimSpace(id) - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - configuredIDs = append(configuredIDs, id) - } - slices.Sort(configuredIDs) - - for _, id := range configuredIDs { - displayID := configuredFeishuBotDisplayID(id) - if displayID == "" { - continue - } - if _, ok := seen[displayID]; ok { - continue - } - botRole := string(RoleWorker) - if id == agent.ManagerUserID { - botRole = string(RoleManager) - } - if role != "" && botRole != role { - continue - } - agentID := "" - available := false - if s.agents != nil { - if a, ok := s.agents.Agent(id); ok { - agentID = a.ID - available = strings.EqualFold(strings.TrimSpace(a.Status), "running") - } - } - openID, appName, err := s.feishu.ResolveBotOpenID(ctx, id) - if err != nil { - return nil, fmt.Errorf("resolve configured feishu bot %q open_id: %w", id, err) - } - name := strings.TrimSpace(appName) - if name == "" { - name = displayID - } - bots = append(bots, Bot{ - ID: id, - Name: name, - Role: botRole, - Channel: string(ChannelFeishu), - AgentID: agentID, - UserID: strings.TrimSpace(openID), - Available: available, - }) - seen[displayID] = struct{}{} - } - return bots, nil -} - -func configuredFeishuBotDisplayID(id string) string { - id = strings.TrimSpace(id) - return strings.TrimPrefix(id, "u-") -} - -func (s *Service) Delete(ctx context.Context, channel, id string) error { - if s == nil || s.store == nil { - return fmt.Errorf("bot store is required") - } - id = strings.TrimSpace(id) - if id == "" { - return fmt.Errorf("bot id is required") - } - if strings.TrimSpace(channel) == "" { - channel = string(ChannelCSGClaw) - } - deleted, ok, err := s.store.GetByChannelID(channel, id) - if err != nil { - return err - } - target, err := s.deletionTarget(ctx, channel, id, deleted, ok) - if err != nil { - return err - } - userDeleted, err := s.deleteChannelUser(target) - if err != nil { - return err - } - agentDeleted, err := s.deleteBackingAgent(ctx, target) - if err != nil { - if userDeleted { - return fmt.Errorf("bot %q partially deleted: channel user removed, but backing agent cleanup failed; retry delete to finish cleanup: %w", id, err) - } - return err - } - if ok { - if _, deletedOK, err := s.store.DeleteByChannelID(channel, id); err != nil { - if userDeleted || agentDeleted { - return fmt.Errorf("bot %q partially deleted: backing resources were removed, but bot state cleanup failed; retry delete to finish cleanup: %w", id, err) - } - return err - } else if !deletedOK { - return nil - } - } - return nil -} - -func (s *Service) deletionTarget(ctx context.Context, channel, id string, stored Bot, storedOK bool) (Bot, error) { - if storedOK { - return stored, nil - } - - target := Bot{ - ID: id, - Channel: channel, - AgentID: id, - UserID: id, - } - if s.agents != nil { - if a, ok := s.agents.Agent(id); ok { - target.AgentID = a.ID - target.Role = strings.ToLower(strings.TrimSpace(a.Role)) - } - } - if target.Role == "" { - target.Role = string(RoleWorker) - } - switch strings.TrimSpace(channel) { - case string(ChannelCSGClaw): - target.UserID = id - case string(ChannelFeishu): - target.UserID = "" - if s.feishu != nil { - if user, ok, err := s.feishu.ResolveBotUser(ctx, id); err != nil { - return Bot{}, fmt.Errorf("resolve feishu user for bot %q: %w", id, err) - } else if ok { - target.UserID = strings.TrimSpace(user.ID) - } - } - } - return target, nil -} - -func (s *Service) deleteBackingAgent(ctx context.Context, target Bot) (bool, error) { - if IsNotificationBot(target) { - return false, nil - } - if s == nil || s.agents == nil { - return false, nil - } - agentID := strings.TrimSpace(target.AgentID) - if agentID == "" { - return false, nil - } - role := strings.ToLower(strings.TrimSpace(target.Role)) - if role == "" { - if a, ok := s.agents.Agent(agentID); ok { - role = strings.ToLower(strings.TrimSpace(a.Role)) - } - } - if role != string(RoleWorker) { - return false, nil - } - for _, b := range s.store.List() { - if sameChannelBot(target, b) { - continue - } - if strings.TrimSpace(b.AgentID) == agentID { - return false, nil - } - } - if err := s.agents.Delete(ctx, agentID); err != nil { - if isNotFoundError(err) { - return false, nil - } - return false, fmt.Errorf("delete backing agent %q: %w", agentID, err) - } - return true, nil -} - -func (s *Service) deleteChannelUser(target Bot) (bool, error) { - if IsNotificationBot(target) { - botID := strings.TrimSpace(target.ID) - userID := strings.TrimSpace(target.UserID) - if userID != "" && botID != "" && userID != botID { - return false, nil - } - } - if s.channelUserStillReferenced(target) { - return false, nil - } - userID := strings.TrimSpace(target.UserID) - if userID == "" { - return false, nil - } - switch strings.TrimSpace(target.Channel) { - case string(ChannelCSGClaw): - if s.im == nil { - return false, nil - } - if err := s.im.DeleteUser(userID); err != nil { - if isNotFoundError(err) { - return false, nil - } - return false, fmt.Errorf("delete csgclaw user %q: %w", userID, err) - } - return true, nil - case string(ChannelFeishu): - if s.feishu == nil { - return false, nil - } - if err := s.feishu.DeleteUser(userID); err != nil { - if isNotFoundError(err) { - return false, nil - } - return false, fmt.Errorf("delete feishu user %q: %w", userID, err) - } - return true, nil - } - return false, nil -} - -func sameChannelBot(a, b Bot) bool { - return strings.TrimSpace(a.Channel) == strings.TrimSpace(b.Channel) && - strings.TrimSpace(a.ID) == strings.TrimSpace(b.ID) -} - -func (s *Service) channelUserStillReferenced(target Bot) bool { - if s == nil { - return false - } - userID := strings.TrimSpace(target.UserID) - if userID == "" { - userID = strings.TrimSpace(target.ID) - } - if userID == "" { - return false - } - deletingAgentID := strings.TrimSpace(target.AgentID) - if deletingAgentID == "" { - deletingAgentID = strings.TrimSpace(target.ID) - } - if s.agents != nil { - // The backing agent still exists in the store until deleteBackingAgent runs. - // Only treat other agents as references. - if _, ok := s.agents.Agent(userID); ok && userID != deletingAgentID { - return true - } - if botID := strings.TrimSpace(target.ID); botID != "" && botID != userID { - if _, ok := s.agents.Agent(botID); ok && botID != deletingAgentID { - return true - } - } - } - if s.store == nil { - return false - } - for _, b := range s.store.List() { - if sameChannelBot(target, b) { - continue - } - if strings.TrimSpace(b.UserID) == userID { - return true - } - if !IsNotificationBot(b) && strings.TrimSpace(b.AgentID) == userID { - return true - } - } - return false -} - -func isNotFoundError(err error) bool { - return err != nil && strings.Contains(strings.ToLower(err.Error()), "not found") -} - -func (s *Service) Create(ctx context.Context, req CreateRequest) (Bot, error) { - if s == nil || s.store == nil { - return Bot{}, fmt.Errorf("bot store is required") - } - - normalized, err := NormalizeCreateRequest(req) - if err != nil { - return Bot{}, err - } - if s.agents == nil { - return Bot{}, fmt.Errorf("agent service is required") - } - switch normalized.Role { - case string(RoleManager): - return s.createManager(ctx, normalized, false) - case string(RoleWorker): - return s.createWorker(ctx, normalized) - default: - return Bot{}, fmt.Errorf("role must be one of %q or %q", RoleManager, RoleWorker) - } -} - -func (s *Service) CreateManager(ctx context.Context, req CreateRequest, forceRecreateAgent bool) (Bot, error) { - if s == nil || s.store == nil { - return Bot{}, fmt.Errorf("bot store is required") - } - req.Role = string(RoleManager) - normalized, err := NormalizeCreateRequest(req) - if err != nil { - return Bot{}, err - } - if s.agents == nil { - return Bot{}, fmt.Errorf("agent service is required") - } - return s.createManager(ctx, normalized, forceRecreateAgent) -} - -func (s *Service) createWorker(ctx context.Context, normalized CreateRequest) (Bot, error) { - var err error - if existing, ok := s.findByChannelName(normalized.Channel, normalized.Name); ok { - return Bot{}, fmt.Errorf("bot name %q already exists in channel %q with id %q", normalized.Name, normalized.Channel, existing.ID) - } - if id := strings.TrimSpace(normalized.ID); id != "" && !strings.HasPrefix(id, "u-") { - return Bot{}, fmt.Errorf("worker bot id must be a canonical user id starting with u-: %s", id) - } - - created, ok := s.agents.Agent(workerAgentID(normalized)) - if ok { - if !strings.EqualFold(strings.TrimSpace(created.Role), agent.RoleWorker) { - return Bot{}, fmt.Errorf("agent id %q already exists with role %q", created.ID, created.Role) - } - } else { - created, err = s.agents.Create(ctx, agent.CreateRequest{ - Spec: agent.CreateAgentSpec{ - ID: normalized.ID, - Name: normalized.Name, - Description: normalized.Description, - Image: normalized.Image, - Avatar: normalized.Avatar, - Role: agent.RoleWorker, - RuntimeKind: normalized.RuntimeKind, - FromTemplate: normalized.FromTemplate, - RuntimeOptions: utils.CloneAnyMap(normalized.RuntimeOptions), - AgentProfile: agentProfileFromBotRequest(normalized.AgentProfile), - }, - }) - if err != nil { - return Bot{}, err - } - } - - userID, userCreatedAt, err := s.ensureChannelUser(ctx, normalized.Channel, created) - if err != nil { - // TODO: compensate by deleting the agent/box created above once agent deletion - // semantics are safe to call from bot creation. - return Bot{}, err - } - - createdAt := userCreatedAt.UTC() - if createdAt.IsZero() { - createdAt = created.CreatedAt.UTC() - } - if createdAt.IsZero() { - createdAt = time.Now().UTC() - } - b := Bot{ - ID: created.ID, - Name: created.Name, - Description: normalized.Description, - Avatar: strings.TrimSpace(created.Avatar), - Role: normalized.Role, - Channel: normalized.Channel, - AgentID: created.ID, - UserID: userID, - Available: true, - CreatedAt: createdAt, - } - if _, ok, err := s.store.GetByChannelID(b.Channel, b.ID); err != nil { - return Bot{}, err - } else if ok { - if err := s.store.Save(b); err != nil { - return Bot{}, err - } - return b, nil - } - if err := s.store.Save(b); err != nil { - return Bot{}, err - } - return b, nil -} - -func agentProfileFromBotRequest(req *apitypes.CreateAgentProfile) agent.AgentProfile { - if req == nil { - return agent.AgentProfile{} - } - return agent.AgentProfile{ - Name: req.Name, - Description: req.Description, - Provider: req.Provider, - BaseURL: req.BaseURL, - APIKey: req.APIKey, - Headers: req.Headers, - ModelID: req.ModelID, - ReasoningEffort: req.ReasoningEffort, - EnableFastMode: req.EnableFastMode, - RequestOptions: req.RequestOptions, - Env: req.Env, - ProfileComplete: req.ProfileComplete, - } -} - -func (s *Service) createManager(ctx context.Context, normalized CreateRequest, forceRecreateAgent bool) (Bot, error) { - if normalized.ID != "" && normalized.ID != agent.ManagerUserID { - return Bot{}, fmt.Errorf("manager bot id must be %q", agent.ManagerUserID) - } - - manager, ok := s.agents.Agent(agent.ManagerUserID) - if forceRecreateAgent || !ok || strings.ToLower(strings.TrimSpace(manager.Role)) != agent.RoleManager { - ensured, err := s.agents.EnsureManager(ctx, forceRecreateAgent) - if err != nil { - return Bot{}, err - } - manager = ensured - } - - userID, userCreatedAt, err := s.ensureChannelUser(ctx, normalized.Channel, manager) - if err != nil { - return Bot{}, err - } - - createdAt := userCreatedAt.UTC() - if createdAt.IsZero() { - createdAt = manager.CreatedAt.UTC() - } - if createdAt.IsZero() { - createdAt = time.Now().UTC() - } - b := Bot{ - ID: manager.ID, - Name: normalized.Name, - Description: normalized.Description, - Role: string(RoleManager), - Channel: normalized.Channel, - AgentID: manager.ID, - UserID: userID, - Available: true, - CreatedAt: createdAt, - } - if _, ok, err := s.store.GetByChannelID(b.Channel, b.ID); err != nil { - return Bot{}, err - } else if ok { - if err := s.store.Save(b); err != nil { - return Bot{}, err - } - return b, nil - } - if err := s.store.Save(b); err != nil { - return Bot{}, err - } - return b, nil -} - -func workerAgentID(req CreateRequest) string { - if id := strings.TrimSpace(req.ID); id != "" { - return id - } - return fmt.Sprintf("u-%s", strings.TrimSpace(req.Name)) -} - -func (s *Service) findByChannelName(channel, name string) (Bot, bool) { - channel = strings.TrimSpace(channel) - name = strings.TrimSpace(name) - for _, existing := range s.store.List() { - if !strings.EqualFold(strings.TrimSpace(existing.Channel), channel) { - continue - } - if strings.EqualFold(strings.TrimSpace(existing.Name), name) { - return existing, true - } - } - return Bot{}, false -} - -func (s *Service) ensureChannelUser(ctx context.Context, channelName string, created agent.Agent) (string, time.Time, error) { - switch channelName { - case string(ChannelCSGClaw): - if s.imProv == nil { - return "", time.Time{}, fmt.Errorf("im provisioner is required") - } - result, err := s.imProv.EnsureAgentUser(ctx, im.AgentIdentity{ - ID: created.ID, - Name: created.Name, - Description: created.Description, - Handle: deriveAgentHandle(created), - Role: displayRole(created), - Avatar: created.Avatar, - }) - if err != nil { - return "", time.Time{}, fmt.Errorf("failed to ensure im user: %w", err) - } - return result.User.ID, result.User.CreatedAt, nil - case string(ChannelFeishu): - if s.feishu == nil { - return "", time.Time{}, fmt.Errorf("feishu service is required") - } - user, err := s.feishu.EnsureUser(feishu.CreateUserRequest{ - ID: created.ID, - Name: created.Name, - Handle: deriveAgentHandle(created), - Role: displayRole(created), - Avatar: created.Avatar, - }) - if err != nil { - return "", time.Time{}, fmt.Errorf("failed to ensure feishu user: %w", err) - } - return user.ID, user.CreatedAt, nil - default: - return "", time.Time{}, fmt.Errorf("channel must be one of %q or %q", ChannelCSGClaw, ChannelFeishu) - } -} - -func deriveAgentHandle(a agent.Agent) string { - if handle, ok := sanitizeHandle(strings.ToLower(strings.ReplaceAll(strings.TrimSpace(a.Name), " ", "-"))); ok { - return handle - } - if handle, ok := sanitizeHandle(strings.ToLower(strings.TrimPrefix(strings.TrimSpace(a.ID), "u-"))); ok { - return handle - } - switch strings.ToLower(strings.TrimSpace(a.Role)) { - case agent.RoleManager: - return "manager" - case agent.RoleWorker: - return "worker" - default: - return "agent" - } -} - -func displayRole(a agent.Agent) string { - switch strings.ToLower(strings.TrimSpace(a.Role)) { - case agent.RoleManager: - return "manager" - case agent.RoleWorker: - return "Worker" - default: - return "Agent" - } -} - -func sanitizeHandle(input string) (string, bool) { - var b strings.Builder - hasAlphaNum := false - for _, r := range input { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - hasAlphaNum = true - b.WriteRune(r) - continue - } - if r == '.' || r == '_' || r == '-' { - b.WriteRune(r) - } - } - if b.Len() == 0 || !hasAlphaNum { - return "", false - } - return b.String(), true -} diff --git a/internal/bot/service_test.go b/internal/bot/service_test.go deleted file mode 100644 index 3430a259..00000000 --- a/internal/bot/service_test.go +++ /dev/null @@ -1,1817 +0,0 @@ -package bot - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "csgclaw/internal/agent" - "csgclaw/internal/apitypes" - "csgclaw/internal/app/runtimewiring" - "csgclaw/internal/channel/feishu" - "csgclaw/internal/config" - "csgclaw/internal/hub" - "csgclaw/internal/im" - agentruntime "csgclaw/internal/runtime" - "csgclaw/internal/runtime/sandboxgateway" - "csgclaw/internal/sandbox" - "csgclaw/internal/sandbox/sandboxtest" -) - -var ( - fakeBotRuntimeStateMu sync.RWMutex - fakeBotRuntimeStates = make(map[string]agentruntime.Info) -) - -func init() { - _ = agent.TestOnlySetResponsesAPIProbe(func(context.Context, string, string, string, map[string]string) error { - return nil - }) -} - -type fakeBotAgentRuntime struct { - kind string - del func(context.Context, agentruntime.Handle) error -} - -func (f fakeBotAgentRuntime) Kind() string { - return f.kind -} - -func (f fakeBotAgentRuntime) WorkspaceRoot(agentHome string) string { - return filepath.Join(agentHome, "workspace") -} - -func (f fakeBotAgentRuntime) New(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { - return agentruntime.Handle{ - RuntimeID: spec.RuntimeID, - HandleID: fmt.Sprintf("%s-%s", f.kind, spec.AgentName), - }, nil -} - -func (f fakeBotAgentRuntime) Provision(_ context.Context, req agentruntime.ProvisionRequest) error { - if strings.TrimSpace(req.WorkspaceOverlay) == "" { - return nil - } - homeDir, err := os.UserHomeDir() - if err != nil { - return err - } - workspaceRoot := filepath.Join(homeDir, config.AppDirName, "agents", req.AgentName, "workspace") - return sandboxgateway.OverlayWorkspaceTree(req.WorkspaceOverlay, workspaceRoot) -} - -func (f fakeBotAgentRuntime) Start(context.Context, agentruntime.Handle) (agentruntime.State, error) { - return agentruntime.StateRunning, nil -} - -func (f fakeBotAgentRuntime) Stop(context.Context, agentruntime.Handle) (agentruntime.State, error) { - return agentruntime.StateStopped, nil -} - -func (f fakeBotAgentRuntime) Delete(ctx context.Context, h agentruntime.Handle) error { - if f.del != nil { - return f.del(ctx, h) - } - return nil -} - -func (f fakeBotAgentRuntime) State(_ context.Context, h agentruntime.Handle) (agentruntime.State, error) { - return fakeBotRuntimeInfoForHandle(h).State, nil -} - -func (f fakeBotAgentRuntime) Info(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) { - return fakeBotRuntimeInfoForHandle(h), nil -} - -func (f fakeBotAgentRuntime) EnsureGatewayConfig(agentName, botID, modelID string) error { - return nil -} - -func (f fakeBotAgentRuntime) ProjectsGuestPath() string { - return "/projects" -} - -func (f fakeBotAgentRuntime) CreateGatewayBox(_ context.Context, _ sandbox.Runtime, _ string, name, _ string, _ agentruntime.Profile) (sandbox.Instance, sandbox.Info, error) { - info := sandbox.Info{ - ID: "box-" + name, - Name: name, - State: sandbox.StateRunning, - CreatedAt: time.Now().UTC(), - } - return sandboxtest.NewInstance(info), info, nil -} - -func (f fakeBotAgentRuntime) GatewayCreateSpec(image, name, _ string, _ agentruntime.Profile) (sandbox.CreateSpec, error) { - return sandbox.CreateSpec{Image: image, Name: name}, nil -} - -func fakeBotRuntimeInfoForHandle(h agentruntime.Handle) agentruntime.Info { - fakeBotRuntimeStateMu.RLock() - defer fakeBotRuntimeStateMu.RUnlock() - if info, ok := fakeBotRuntimeStates[strings.TrimSpace(h.HandleID)]; ok { - return info - } - if info, ok := fakeBotRuntimeStates[strings.TrimSpace(h.RuntimeID)]; ok { - return info - } - return agentruntime.Info{ - HandleID: strings.TrimSpace(h.HandleID), - State: agentruntime.StateRunning, - CreatedAt: time.Now().UTC(), - } -} - -func resetFakeBotRuntimeStates() { - fakeBotRuntimeStateMu.Lock() - defer fakeBotRuntimeStateMu.Unlock() - clear(fakeBotRuntimeStates) -} - -func setFakeBotRuntimeState(runtimeID, handleID string, state sandbox.State) { - info := agentruntime.Info{ - HandleID: strings.TrimSpace(handleID), - State: agentruntime.State(state), - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - } - fakeBotRuntimeStateMu.Lock() - defer fakeBotRuntimeStateMu.Unlock() - if runtimeID = strings.TrimSpace(runtimeID); runtimeID != "" { - fakeBotRuntimeStates[runtimeID] = info - } - if handleID = strings.TrimSpace(handleID); handleID != "" { - fakeBotRuntimeStates[handleID] = info - } -} - -func testRuntimeIDForAgentName(agentName string) string { - agentName = strings.TrimSpace(agentName) - if strings.EqualFold(agentName, agent.ManagerName) { - return "rt-" + agent.ManagerUserID - } - if agentName == "" { - return "" - } - return "rt-u-" + agentName -} - -func init() { - agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { - if err := runtimewiring.WithPicoClawSandboxRuntime(nil)(s); err != nil { - return err - } - return runtimewiring.WithOpenClawSandboxRuntime(nil)(s) - }) -} - -func testAgentModelConfig() config.ModelConfig { - return config.ModelConfig{ - Provider: config.ProviderLLMAPI, - BaseURL: "http://127.0.0.1:4000", - APIKey: "sk-test", - ModelID: "default-model", - } -} - -func TestServiceListReturnsAllWhenChannelEmpty(t *testing.T) { - svc := mustNewBotService(t, []Bot{ - { - ID: "bot-csgclaw", - Name: "CSGClaw Bot", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-feishu", - Name: "Feishu Bot", - Role: string(RoleWorker), - Channel: string(ChannelFeishu), - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - - got, err := svc.List("", "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(got) != 2 { - t.Fatalf("List() len = %d, want 2", len(got)) - } - if got[0].ID != "bot-csgclaw" || got[1].ID != "bot-feishu" { - t.Fatalf("List() = %+v, want all bots in store order", got) - } -} - -func TestServiceListFiltersByNormalizedChannel(t *testing.T) { - svc := mustNewBotService(t, []Bot{ - { - ID: "bot-csgclaw", - Name: "CSGClaw Bot", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-feishu", - Name: "Feishu Bot", - Role: string(RoleWorker), - Channel: string(ChannelFeishu), - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - - got, err := svc.List(" FEISHU ", "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(got) != 1 || got[0].ID != "bot-feishu" { - t.Fatalf("List(FEISHU) = %+v, want only bot-feishu", got) - } -} - -func TestServiceListFiltersByNormalizedRole(t *testing.T) { - svc := mustNewBotService(t, []Bot{ - { - ID: "bot-manager", - Name: "Manager Bot", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-worker", - Name: "Worker Bot", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - - got, err := svc.List("", " WORKER ", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(got) != 1 || got[0].ID != "bot-worker" { - t.Fatalf("List(role=WORKER) = %+v, want only bot-worker", got) - } -} - -func TestServiceListFiltersByChannelAndRole(t *testing.T) { - svc := mustNewBotService(t, []Bot{ - { - ID: "bot-csgclaw-manager", - Name: "CSGClaw Manager", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-feishu-manager", - Name: "Feishu Manager", - Role: string(RoleManager), - Channel: string(ChannelFeishu), - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - { - ID: "bot-feishu-worker", - Name: "Feishu Worker", - Role: string(RoleWorker), - Channel: string(ChannelFeishu), - CreatedAt: time.Date(2026, 4, 12, 11, 0, 0, 0, time.UTC), - }, - }) - - got, err := svc.List("feishu", "manager", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(got) != 1 || got[0].ID != "bot-feishu-manager" { - t.Fatalf("List(feishu, manager) = %+v, want only bot-feishu-manager", got) - } -} - -func TestServiceListFeishuIncludesConfiguredUnavailableBots(t *testing.T) { - store, err := NewMemoryStore([]Bot{ - { - ID: "u-worker", - Name: "Worker", - Role: string(RoleWorker), - Channel: string(ChannelFeishu), - AgentID: "u-worker", - UserID: "ou_worker", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - feishuSvc := feishu.NewServiceWithBotOpenIDResolver( - map[string]feishu.AppConfig{ - "u-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, - "u-worker": {AppID: "cli_worker", AppSecret: "worker-secret"}, - }, - func(_ context.Context, app feishu.AppConfig) (feishu.BotInfo, error) { - switch app.AppID { - case "cli_manager": - return feishu.BotInfo{OpenID: "ou_manager", AppName: "Manager Bot"}, nil - case "cli_worker": - return feishu.BotInfo{OpenID: "ou_worker", AppName: "Worker Bot"}, nil - default: - t.Fatalf("unexpected app_id %q", app.AppID) - return feishu.BotInfo{}, nil - } - }, - ) - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: "u-worker", - Name: "worker", - Role: agent.RoleWorker, - RuntimeKind: agent.RuntimeKindPicoClawSandbox, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - AgentProfile: agent.AgentProfile{ModelID: "default-model"}, - }, - }) - svc, err := NewServiceWithDependencies(store, agentSvc, nil, feishuSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - mustSetAgentRuntimeState(t, "worker", "box-worker", sandbox.StateRunning) - - got, err := svc.List(string(ChannelFeishu), "", "") - if err != nil { - t.Fatalf("List(feishu) error = %v", err) - } - if len(got) != 2 { - t.Fatalf("List(feishu) = %+v, want stored bot plus configured manager", got) - } - if got[0].ID != "u-worker" || !got[0].Available { - t.Fatalf("stored bot = %+v, want available u-worker", got[0]) - } - if got[1].ID != "u-manager" || got[1].Name != "Manager Bot" || got[1].Role != string(RoleManager) || got[1].AgentID != "" || got[1].UserID != "ou_manager" || got[1].Available { - t.Fatalf("configured bot = %+v, want unavailable manager with configured id and open_id", got[1]) - } -} - -func TestServiceListFeishuConfiguredBotUsesMatchingAgent(t *testing.T) { - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: "u-manager", - Name: "manager", - Role: agent.RoleManager, - RuntimeKind: agent.RuntimeKindPicoClawSandbox, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - AgentProfile: agent.AgentProfile{ModelID: "default-model"}, - }, - }) - feishuSvc := feishu.NewServiceWithBotOpenIDResolver( - map[string]feishu.AppConfig{ - "u-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, - }, - func(_ context.Context, app feishu.AppConfig) (feishu.BotInfo, error) { - if app.AppID != "cli_manager" { - t.Fatalf("unexpected app_id %q", app.AppID) - } - return feishu.BotInfo{OpenID: "ou_manager", AppName: "Manager Bot"}, nil - }, - ) - svc, err := NewServiceWithDependencies(store, agentSvc, nil, feishuSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - mustSetAgentRuntimeState(t, "manager", "box-manager", sandbox.StateRunning) - - got, err := svc.List(string(ChannelFeishu), "", "") - if err != nil { - t.Fatalf("List(feishu) error = %v", err) - } - if len(got) != 1 { - t.Fatalf("List(feishu) = %+v, want configured manager", got) - } - if got[0].ID != "u-manager" || got[0].AgentID != "u-manager" || !got[0].Available { - t.Fatalf("configured bot = %+v, want configured id u-manager bound to available u-manager agent", got[0]) - } -} - -func TestServiceListStoredBotUnavailableWhenAgentNotRunning(t *testing.T) { - store, err := NewMemoryStore([]Bot{ - { - ID: "u-worker", - Name: "Worker", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-worker", - UserID: "u-worker", - Available: true, - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: "u-worker", - Name: "worker", - Role: agent.RoleWorker, - RuntimeKind: agent.RuntimeKindPicoClawSandbox, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - AgentProfile: agent.AgentProfile{ModelID: "default-model"}, - }, - }) - svc, err := NewServiceWithDependencies(store, agentSvc, nil) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - mustSetAgentRuntimeState(t, "worker", "box-worker", sandbox.StateStopped) - - got, err := svc.List(string(ChannelCSGClaw), "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) - } - if len(got) != 1 { - t.Fatalf("List(csgclaw) = %+v, want one stored bot", got) - } - if got[0].AgentID != "u-worker" || got[0].Available { - t.Fatalf("stored bot = %+v, want unavailable bot bound to stopped agent", got[0]) - } -} - -func TestServiceListIncludesAgentViewFieldsForCSGClawBots(t *testing.T) { - store, err := NewMemoryStore([]Bot{ - { - ID: "u-worker", - Name: "Worker", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-worker", - UserID: "u-worker", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: "u-worker", - Name: "worker", - Role: agent.RoleWorker, - RuntimeKind: agent.RuntimeKindCodex, - Image: "agent-image:test", - Status: "running", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - ProfileComplete: true, - AgentProfile: agent.AgentProfile{ - Provider: agent.ProviderCSGHubLite, - ModelID: "glm-4.5", - ProfileComplete: true, - EnvRestartRequired: true, - ImageUpgradeRequired: true, - }, - }, - }) - svc, err := NewServiceWithDependencies(store, agentSvc, nil) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - mustSetAgentRuntimeState(t, "worker", "box-worker", sandbox.StateRunning) - - got, err := svc.List(string(ChannelCSGClaw), "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) - } - if len(got) != 1 { - t.Fatalf("List(csgclaw) = %+v, want one stored bot", got) - } - if got[0].RuntimeKind != agent.RuntimeKindCodex || got[0].Image != "agent-image:test" || got[0].Status != "running" { - t.Fatalf("bot runtime fields = %+v, want codex/agent-image:test/running", got[0]) - } - if got[0].Provider != agent.ProviderCSGHubLite || got[0].ModelID != "glm-4.5" { - t.Fatalf("bot profile fields = %+v, want csghub_lite/glm-4.5", got[0]) - } - if !got[0].ProfileComplete || !got[0].EnvRestartRequired || !got[0].ImageUpgradeRequired { - t.Fatalf("bot completeness fields = %+v, want profile_complete, env_restart_required, and image_upgrade_required", got[0]) - } -} - -func TestServiceListWithoutFiltersRefreshesAvailability(t *testing.T) { - store, err := NewMemoryStore([]Bot{ - { - ID: "u-worker", - Name: "Worker", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-worker", - UserID: "u-worker", - Available: true, - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: "u-worker", - Name: "worker", - Role: agent.RoleWorker, - RuntimeKind: agent.RuntimeKindPicoClawSandbox, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - AgentProfile: agent.AgentProfile{ModelID: "default-model"}, - }, - }) - svc, err := NewServiceWithDependencies(store, agentSvc, nil) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - mustSetAgentRuntimeState(t, "worker", "box-worker", sandbox.StateStopped) - - got, err := svc.List("", "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(got) != 1 { - t.Fatalf("List() = %+v, want one stored bot", got) - } - if got[0].Available { - t.Fatalf("stored bot = %+v, want unavailable bot in unfiltered list", got[0]) - } -} - -func TestServiceListFeishuConfiguredBotsRespectRoleFilter(t *testing.T) { - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - feishuSvc := feishu.NewServiceWithBotOpenIDResolver( - map[string]feishu.AppConfig{ - "u-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, - "u-worker": {AppID: "cli_worker", AppSecret: "worker-secret"}, - }, - func(_ context.Context, app feishu.AppConfig) (feishu.BotInfo, error) { - switch app.AppID { - case "cli_manager": - return feishu.BotInfo{OpenID: "ou_manager", AppName: "Manager Bot"}, nil - case "cli_worker": - return feishu.BotInfo{OpenID: "ou_worker", AppName: "Worker Bot"}, nil - default: - t.Fatalf("unexpected app_id %q", app.AppID) - return feishu.BotInfo{}, nil - } - }, - ) - svc, err := NewServiceWithDependencies(store, nil, nil, feishuSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - got, err := svc.List(string(ChannelFeishu), string(RoleManager), "") - if err != nil { - t.Fatalf("List(feishu, manager) error = %v", err) - } - if len(got) != 1 || got[0].ID != "u-manager" || got[0].Name != "Manager Bot" || got[0].UserID != "ou_manager" || got[0].AgentID != "" || got[0].Available { - t.Fatalf("List(feishu, manager) = %+v, want unavailable configured manager only", got) - } -} - -func TestServiceListRejectsInvalidChannel(t *testing.T) { - svc := mustNewBotService(t, nil) - - _, err := svc.List("slack", "", "") - if err == nil || !strings.Contains(err.Error(), "channel must be one of") { - t.Fatalf("List(slack) error = %v, want channel validation error", err) - } -} - -func TestServiceListRejectsInvalidRole(t *testing.T) { - svc := mustNewBotService(t, nil) - - _, err := svc.List("", "agent", "") - if err == nil || !strings.Contains(err.Error(), "role must be one of") { - t.Fatalf("List(role=agent) error = %v, want role validation error", err) - } -} - -func TestServiceDeleteRemovesBotForChannel(t *testing.T) { - svc := mustNewBotService(t, []Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "u-alice", - Name: "Alice", - Role: string(RoleWorker), - Channel: string(ChannelFeishu), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - - if err := svc.Delete(context.Background(), "feishu", "u-alice"); err != nil { - t.Fatalf("Delete() error = %v", err) - } - feishuBots, err := svc.List("feishu", "", "") - if err != nil { - t.Fatalf("List(feishu) error = %v", err) - } - if len(feishuBots) != 0 { - t.Fatalf("List(feishu) = %+v, want deleted", feishuBots) - } - csgclawBots, err := svc.List("csgclaw", "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) - } - if len(csgclawBots) != 1 || csgclawBots[0].ID != "u-alice" { - t.Fatalf("List(csgclaw) = %+v, want retained u-alice", csgclawBots) - } -} - -func TestServiceDeleteRemovesCSGClawUser(t *testing.T) { - store, err := NewMemoryStore([]Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-admin", - Users: []im.User{ - {ID: "u-admin", Name: "admin", Handle: "admin", IsOnline: true}, - {ID: "u-alice", Name: "Alice", Handle: "alice", IsOnline: true}, - }, - Rooms: []im.Room{ - { - ID: "room-1", - Title: "Alice", - Members: []string{"u-admin", "u-alice"}, - Messages: []im.Message{{ID: "msg-1", SenderID: "u-alice", Content: "hello"}}, - }, - }, - }) - svc, err := NewServiceWithDependencies(store, nil, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - if err := svc.Delete(context.Background(), "csgclaw", "u-alice"); err != nil { - t.Fatalf("Delete() error = %v", err) - } - - bots, err := svc.List("csgclaw", "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) - } - if len(bots) != 0 { - t.Fatalf("List(csgclaw) = %+v, want deleted", bots) - } - if _, ok := imSvc.User("u-alice"); ok { - t.Fatal("User(u-alice) ok = true, want false after bot delete") - } - if _, ok := imSvc.Room("room-1"); ok { - t.Fatal("Room(room-1) ok = true, want false after removing DM user") - } -} - -func TestServiceDeleteRemovesCSGClawUserAndBackingAgent(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: "u-alice", - Name: "alice", - Role: agent.RoleWorker, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - store, err := NewMemoryStore([]Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-admin", - Users: []im.User{ - {ID: "u-admin", Name: "admin", Handle: "admin", IsOnline: true}, - {ID: "u-alice", Name: "Alice", Handle: "alice", IsOnline: true}, - {ID: "u-bob", Name: "Bob", Handle: "bob", IsOnline: true}, - }, - Rooms: []im.Room{ - { - ID: "room-dm", - Title: "Alice", - IsDirect: true, - Members: []string{"u-admin", "u-alice"}, - Messages: []im.Message{{ID: "msg-1", SenderID: "u-alice", Content: "hello"}}, - }, - { - ID: "room-group", - Title: "ops", - Members: []string{"u-admin", "u-alice", "u-bob"}, - Messages: []im.Message{ - {ID: "msg-2", SenderID: "u-alice", Content: "ping"}, - {ID: "msg-3", SenderID: "u-bob", Content: "pong"}, - }, - }, - }, - }) - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - if err := svc.Delete(context.Background(), "csgclaw", "u-alice"); err != nil { - t.Fatalf("Delete() error = %v", err) - } - if _, ok := agentSvc.Agent("u-alice"); ok { - t.Fatal("Agent(u-alice) ok = true, want false after bot delete") - } - if _, ok := imSvc.User("u-alice"); ok { - t.Fatal("User(u-alice) ok = true, want false after bot delete") - } - if _, ok := imSvc.Room("room-dm"); ok { - t.Fatal("Room(room-dm) ok = true, want DM removed after user delete") - } - group, ok := imSvc.Room("room-group") - if !ok { - t.Fatal("Room(room-group) ok = false, want group to remain") - } - for _, memberID := range group.Members { - if memberID == "u-alice" { - t.Fatalf("group members = %+v, want u-alice removed", group.Members) - } - } - for _, message := range group.Messages { - if message.SenderID == "u-alice" { - t.Fatalf("group messages = %+v, want u-alice messages removed", group.Messages) - } - } -} - -func TestServiceDeleteRemovesBackingAgentWhenLastReference(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: "u-alice", - Name: "alice", - Role: agent.RoleWorker, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - store, err := NewMemoryStore([]Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(RoleWorker), - Channel: string(ChannelFeishu), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, nil) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - if err := svc.Delete(context.Background(), "feishu", "u-alice"); err != nil { - t.Fatalf("Delete() error = %v", err) - } - if _, ok := agentSvc.Agent("u-alice"); ok { - t.Fatal("Agent() ok = true, want false after deleting last bot reference") - } -} - -func TestServiceDeleteIsIdempotent(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: "u-alice", - Name: "alice", - Role: agent.RoleWorker, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - store, err := NewMemoryStore([]Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-admin", - Users: []im.User{ - {ID: "u-admin", Name: "admin", Handle: "admin", IsOnline: true}, - {ID: "u-alice", Name: "Alice", Handle: "alice", IsOnline: true}, - }, - }) - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - if err := svc.Delete(context.Background(), "csgclaw", "u-alice"); err != nil { - t.Fatalf("first Delete() error = %v", err) - } - if err := svc.Delete(context.Background(), "csgclaw", "u-alice"); err != nil { - t.Fatalf("second Delete() error = %v, want idempotent success", err) - } -} - -func TestServiceDeleteCleansResidualResourcesWhenBotMissing(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: "u-codex", - Name: "codex", - Role: agent.RoleWorker, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-admin", - Users: []im.User{ - {ID: "u-admin", Name: "admin", Handle: "admin", IsOnline: true}, - {ID: "u-codex", Name: "Codex", Handle: "codex", IsOnline: true}, - }, - Rooms: []im.Room{ - { - ID: "room-codex", - Title: "Codex", - Members: []string{"u-admin", "u-codex"}, - Messages: []im.Message{{ID: "msg-1", SenderID: "u-codex", Content: "hello"}}, - }, - }, - }) - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - if err := svc.Delete(context.Background(), "csgclaw", "u-codex"); err != nil { - t.Fatalf("Delete() error = %v, want residual cleanup success", err) - } - if _, ok := agentSvc.Agent("u-codex"); ok { - t.Fatal("Agent(u-codex) ok = true, want false after residual cleanup") - } - if _, ok := imSvc.User("u-codex"); ok { - t.Fatal("User(u-codex) ok = true, want false after residual cleanup") - } - if _, ok := imSvc.Room("room-codex"); ok { - t.Fatal("Room(room-codex) ok = true, want false after residual cleanup") - } -} - -func TestServiceDeleteKeepsBotRecordWhenChannelUserDeletionFails(t *testing.T) { - store, err := NewMemoryStore([]Bot{ - { - ID: "u-manager", - Name: "Manager", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - AgentID: "u-manager", - UserID: "u-manager", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-manager", - Users: []im.User{ - {ID: "u-manager", Name: "manager", Handle: "manager", IsOnline: true}, - }, - }) - svc, err := NewServiceWithDependencies(store, nil, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - if err := svc.Delete(context.Background(), "csgclaw", "u-manager"); err == nil { - t.Fatal("Delete() error = nil, want current user conflict") - } - bots, err := svc.List("csgclaw", "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-manager" { - t.Fatalf("List(csgclaw) = %+v, want retained u-manager after failed delete", bots) - } -} - -func TestServiceDeleteReturnsPartialErrorWhenAgentCleanupFailsAfterUserDeletion(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - resetFakeBotRuntimeStates() - t.Cleanup(resetFakeBotRuntimeStates) - restoreDefault := agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { - if err := agent.WithRuntime(fakeBotAgentRuntime{ - kind: agent.RuntimeKindPicoClawSandbox, - del: func(context.Context, agentruntime.Handle) error { - return fmt.Errorf("boom") - }, - })(s); err != nil { - return err - } - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindOpenClawSandbox})(s); err != nil { - return err - } - return agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindCodex})(s) - }) - t.Cleanup(restoreDefault) - - statePath := filepath.Join(t.TempDir(), "agents.json") - data, err := json.Marshal(map[string]any{"agents": []agent.Agent{ - { - ID: "u-alice", - Name: "alice", - Role: agent.RoleWorker, - RuntimeKind: agent.RuntimeKindPicoClawSandbox, - BoxID: "box-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }}) - if err != nil { - t.Fatalf("marshal agents: %v", err) - } - if err := os.WriteFile(statePath, append(data, '\n'), 0o600); err != nil { - t.Fatalf("write agents: %v", err) - } - agentSvc, err := agent.NewService(testAgentModelConfig(), config.ServerConfig{}, "manager-image:test", statePath) - if err != nil { - t.Fatalf("agent.NewService() error = %v", err) - } - store, err := NewMemoryStore([]Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-admin", - Users: []im.User{ - {ID: "u-admin", Name: "admin", Handle: "admin", IsOnline: true}, - {ID: "u-alice", Name: "Alice", Handle: "alice", IsOnline: true}, - }, - }) - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - err = svc.Delete(context.Background(), "csgclaw", "u-alice") - if err == nil || !strings.Contains(err.Error(), "partially deleted") || !strings.Contains(err.Error(), "retry delete") { - t.Fatalf("Delete() error = %v, want partial-delete retry guidance", err) - } - if _, ok := imSvc.User("u-alice"); ok { - t.Fatal("User(u-alice) ok = true, want false after partial delete") - } - bots, err := svc.List("csgclaw", "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-alice" { - t.Fatalf("List(csgclaw) = %+v, want retained bot after partial delete", bots) - } -} - -func TestNewServiceRequiresStore(t *testing.T) { - _, err := NewService(nil) - if err == nil || !strings.Contains(err.Error(), "bot store is required") { - t.Fatalf("NewService(nil) error = %v, want store required", err) - } -} - -func TestServiceCreateCSGClawWorkerCreatesAgentUserAndBot(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - resetFakeBotRuntimeStates() - t.Cleanup(resetFakeBotRuntimeStates) - restoreDefault := agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindPicoClawSandbox})(s); err != nil { - return err - } - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindOpenClawSandbox})(s); err != nil { - return err - } - return agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindCodex})(s) - }) - t.Cleanup(restoreDefault) - - agentSvc, err := agent.NewService(testAgentModelConfig(), config.ServerConfig{}, "manager-image:test", "") - if err != nil { - t.Fatalf("agent.NewService() error = %v", err) - } - imSvc := im.NewService() - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - bus := im.NewBus() - events, cancel := bus.Subscribe() - defer cancel() - svc.SetIMBus(bus) - - got, err := svc.Create(context.Background(), CreateRequest{ - Name: "alice", - Description: "test lead", - Image: "agent-image:1", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - RuntimeKind: agent.RuntimeKindCodex, - AgentProfile: &apitypes.CreateAgentProfile{ - Provider: agent.ProviderCSGHubLite, - ModelID: "glm-4.5", - ReasoningEffort: "high", - }, - }) - if err != nil { - t.Fatalf("Create() error = %v", err) - } - if got.ID != "u-alice" || got.AgentID != "u-alice" || got.UserID != "u-alice" { - t.Fatalf("Create() = %+v, want u-alice bot/agent/user IDs", got) - } - if got.Role != string(RoleWorker) || got.Channel != string(ChannelCSGClaw) { - t.Fatalf("Create() = %+v, want worker csgclaw", got) - } - if got.Description != "test lead" { - t.Fatalf("Create().Description = %q, want test lead", got.Description) - } - createdAgent, ok := agentSvc.Agent("u-alice") - if !ok { - t.Fatal("agent u-alice not created") - } - if createdAgent.RuntimeKind != agent.RuntimeKindCodex { - t.Fatalf("agent.RuntimeKind = %q, want %q", createdAgent.RuntimeKind, agent.RuntimeKindCodex) - } - if createdAgent.Image != "agent-image:1" { - t.Fatalf("agent.Image = %q, want agent-image:1", createdAgent.Image) - } - if createdAgent.AgentProfile.Provider != agent.ProviderCSGHubLite || createdAgent.AgentProfile.ModelID != "glm-4.5" { - t.Fatalf("agent profile = %s/%s, want csghub_lite/glm-4.5", createdAgent.AgentProfile.Provider, createdAgent.AgentProfile.ModelID) - } - if createdAgent.AgentProfile.ReasoningEffort != "high" { - t.Fatalf("agent reasoning = %q, want high", createdAgent.AgentProfile.ReasoningEffort) - } - users := imSvc.ListUsers() - if !containsUser(users, "u-alice") { - t.Fatalf("users = %+v, want u-alice", users) - } - rooms := imSvc.ListRooms() - if len(rooms) != 1 || !containsMember(rooms[0].Members, "u-admin") || !containsMember(rooms[0].Members, "u-alice") { - t.Fatalf("rooms = %+v, want one bootstrap room with admin and u-alice", rooms) - } - first := mustReceiveEventWithin(t, events, time.Second) - if first.Type != im.EventTypeUserCreated || first.User == nil || first.User.ID != "u-alice" { - t.Fatalf("first event = %+v, want user_created for u-alice", first) - } - second := mustReceiveEventWithin(t, events, time.Second) - if second.Type != im.EventTypeRoomCreated || second.Room == nil { - t.Fatalf("second event = %+v, want room_created with room payload", second) - } - third := mustReceiveEventWithin(t, events, 2*time.Second) - if third.Type != im.EventTypeMessageCreated || third.Message == nil { - t.Fatalf("third event = %+v, want bootstrap message", third) - } - listed, err := svc.List(string(ChannelCSGClaw), "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(listed) != 1 || listed[0].ID != "u-alice" { - t.Fatalf("List(csgclaw) = %+v, want u-alice", listed) - } - if listed[0].Description != "test lead" { - t.Fatalf("List(csgclaw)[0].Description = %q, want test lead", listed[0].Description) - } -} - -func TestServiceCreateFeishuWorkerCreatesAgentUserAndBot(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - resetFakeBotRuntimeStates() - t.Cleanup(resetFakeBotRuntimeStates) - restoreDefault := agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindPicoClawSandbox})(s); err != nil { - return err - } - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindOpenClawSandbox})(s); err != nil { - return err - } - return agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindCodex})(s) - }) - t.Cleanup(restoreDefault) - - agentSvc, err := agent.NewService(testAgentModelConfig(), config.ServerConfig{}, "manager-image:test", "") - if err != nil { - t.Fatalf("agent.NewService() error = %v", err) - } - feishuSvc := feishu.NewService() - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, nil, feishuSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - got, err := svc.Create(context.Background(), CreateRequest{ - Name: "alice", - Description: "test lead", - Role: string(RoleWorker), - Channel: string(ChannelFeishu), - RuntimeKind: agent.RuntimeKindCodex, - }) - if err != nil { - t.Fatalf("Create() error = %v", err) - } - if got.ID != "u-alice" || got.AgentID != "u-alice" || got.UserID != "u-alice" { - t.Fatalf("Create() = %+v, want u-alice bot/agent/user IDs", got) - } - if got.Role != string(RoleWorker) || got.Channel != string(ChannelFeishu) { - t.Fatalf("Create() = %+v, want worker feishu", got) - } - if _, ok := agentSvc.Agent("u-alice"); !ok { - t.Fatal("agent u-alice not created") - } - if !containsUser(feishuSvc.ListUsers(), "u-alice") { - t.Fatalf("feishu users = %+v, want u-alice", feishuSvc.ListUsers()) - } - listed, err := svc.List(string(ChannelFeishu), "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(listed) != 1 || listed[0].ID != "u-alice" { - t.Fatalf("List(feishu) = %+v, want u-alice", listed) - } -} - -func TestServiceCreateWorkerReusesAgentAcrossChannels(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - provider := sandboxtest.NewProvider() - t.Cleanup(agent.TestOnlySetSandboxProvider(provider)) - resetFakeBotRuntimeStates() - t.Cleanup(resetFakeBotRuntimeStates) - restoreDefault := agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindPicoClawSandbox})(s); err != nil { - return err - } - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindOpenClawSandbox})(s); err != nil { - return err - } - return agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindCodex})(s) - }) - t.Cleanup(restoreDefault) - - agentSvc, err := agent.NewService(testAgentModelConfig(), config.ServerConfig{}, "manager-image:test", "") - if err != nil { - t.Fatalf("agent.NewService() error = %v", err) - } - imSvc := im.NewService() - feishuSvc := feishu.NewService() - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc, feishuSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - csgclawBot, err := svc.Create(context.Background(), CreateRequest{ - Name: "alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - RuntimeKind: agent.RuntimeKindCodex, - }) - if err != nil { - t.Fatalf("Create(csgclaw worker) error = %v", err) - } - time.Sleep(5 * time.Millisecond) - feishuBot, err := svc.Create(context.Background(), CreateRequest{ - Name: "alice", - Role: string(RoleWorker), - Channel: string(ChannelFeishu), - RuntimeKind: agent.RuntimeKindCodex, - }) - if err != nil { - t.Fatalf("Create(feishu worker) error = %v", err) - } - if csgclawBot.ID != "u-alice" || feishuBot.ID != "u-alice" || csgclawBot.AgentID != feishuBot.AgentID { - t.Fatalf("created bots = %+v / %+v, want shared u-alice agent", csgclawBot, feishuBot) - } - if !feishuBot.CreatedAt.After(csgclawBot.CreatedAt) { - t.Fatalf("created_at = %v / %v, want feishu bot time after csgclaw channel user time", csgclawBot.CreatedAt, feishuBot.CreatedAt) - } - if !containsUser(imSvc.ListUsers(), "u-alice") { - t.Fatalf("im users = %+v, want u-alice", imSvc.ListUsers()) - } - if !containsUser(feishuSvc.ListUsers(), "u-alice") { - t.Fatalf("feishu users = %+v, want u-alice", feishuSvc.ListUsers()) - } - all, err := svc.List("", "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(all) != 2 { - t.Fatalf("List() = %+v, want two channel bindings", all) - } - if _, ok := agentSvc.Agent("u-alice"); !ok { - t.Fatal("Agent(u-alice) ok = false, want shared backing agent") - } -} - -func TestServiceCreateWorkerRejectsDuplicateNameInSameChannel(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - provider := sandboxtest.NewProvider() - t.Cleanup(agent.TestOnlySetSandboxProvider(provider)) - resetFakeBotRuntimeStates() - t.Cleanup(resetFakeBotRuntimeStates) - restoreDefault := agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindPicoClawSandbox})(s); err != nil { - return err - } - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindOpenClawSandbox})(s); err != nil { - return err - } - return agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindCodex})(s) - }) - t.Cleanup(restoreDefault) - - agentSvc, err := agent.NewService(testAgentModelConfig(), config.ServerConfig{}, "manager-image:test", "") - if err != nil { - t.Fatalf("agent.NewService() error = %v", err) - } - imSvc := im.NewService() - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - if _, err := svc.Create(context.Background(), CreateRequest{ - Name: "alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - RuntimeKind: agent.RuntimeKindCodex, - }); err != nil { - t.Fatalf("first Create(worker) error = %v", err) - } - - _, err = svc.Create(context.Background(), CreateRequest{ - Name: "alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - RuntimeKind: agent.RuntimeKindCodex, - }) - if err == nil || !strings.Contains(err.Error(), `bot name "alice" already exists in channel "csgclaw"`) { - t.Fatalf("second Create(worker) error = %v, want duplicate name error", err) - } -} - -func TestServiceCreateWorkerUsesFromTemplateWorkspace(t *testing.T) { - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - resetFakeBotRuntimeStates() - t.Cleanup(resetFakeBotRuntimeStates) - restoreDefault := agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { - return agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindPicoClawSandbox})(s) - }) - t.Cleanup(restoreDefault) - - hubSvc := mustNewBotLocalTemplateHubService(t, "frontend-worker", hub.Template{ - ID: "frontend-worker", - Name: "frontend-worker", - Description: "frontend worker", - Role: hub.TemplateRoleWorker, - RuntimeKind: agent.RuntimeKindPicoClawSandbox, - Image: "worker-image:1", - }) - agentSvc, err := agent.NewService( - testAgentModelConfig(), - config.ServerConfig{}, - "manager-image:1", - "", - agent.WithHubService(hubSvc), - ) - if err != nil { - t.Fatalf("agent.NewService() error = %v", err) - } - imSvc := im.NewService() - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - if _, err := svc.Create(context.Background(), CreateRequest{ - Name: "alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - FromTemplate: "local.frontend-worker", - }); err != nil { - t.Fatalf("Create(worker) error = %v", err) - } - - skillPath := filepath.Join(homeDir, config.AppDirName, "agents", "alice", "workspace", "skills", "custom", "SKILL.md") - if _, err := os.Stat(skillPath); err != nil { - t.Fatalf("template skill missing after bot create: %v", err) - } -} - -func TestServiceCreateCSGClawManagerBindsBootstrappedAgent(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: agent.ManagerUserID, - Name: agent.ManagerName, - Role: agent.RoleManager, - RuntimeKind: agent.RuntimeKindPicoClawSandbox, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - AgentProfile: agent.AgentProfile{ModelID: "default-model"}, - }, - }) - imSvc := im.NewService() - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - got, err := svc.Create(context.Background(), CreateRequest{ - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - }) - if err != nil { - t.Fatalf("Create(manager) error = %v", err) - } - if got.ID != agent.ManagerUserID || got.AgentID != agent.ManagerUserID || got.UserID != agent.ManagerUserID { - t.Fatalf("Create(manager) = %+v, want u-manager IDs", got) - } - if got.Role != string(RoleManager) || got.Channel != string(ChannelCSGClaw) { - t.Fatalf("Create(manager) = %+v, want manager csgclaw", got) - } - if !containsUser(imSvc.ListUsers(), agent.ManagerUserID) { - t.Fatalf("users = %+v, want u-manager", imSvc.ListUsers()) - } -} - -func TestServiceCreateManagerBindsSameAgentAcrossChannels(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: agent.ManagerUserID, - Name: agent.ManagerName, - Role: agent.RoleManager, - RuntimeKind: agent.RuntimeKindPicoClawSandbox, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - AgentProfile: agent.AgentProfile{ModelID: "default-model"}, - }, - }) - imSvc := im.NewService() - feishuSvc := feishu.NewService() - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc, feishuSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - csgclawBot, err := svc.Create(context.Background(), CreateRequest{ - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - }) - if err != nil { - t.Fatalf("Create(csgclaw manager) error = %v", err) - } - feishuBot, err := svc.Create(context.Background(), CreateRequest{ - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelFeishu), - }) - if err != nil { - t.Fatalf("Create(feishu manager) error = %v", err) - } - if csgclawBot.ID != agent.ManagerUserID || feishuBot.ID != agent.ManagerUserID || csgclawBot.AgentID != feishuBot.AgentID { - t.Fatalf("created managers = %+v / %+v, want shared manager agent", csgclawBot, feishuBot) - } - all, err := svc.List("", "", "") - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(all) != 2 { - t.Fatalf("List() = %+v, want two channel bindings", all) - } -} - -func TestServiceCreateCSGClawManagerReusesExistingBotAndRestoresMissingUser(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: agent.ManagerUserID, - Name: agent.ManagerName, - Role: agent.RoleManager, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - imSvc := im.NewService() - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - if _, err := svc.Create(context.Background(), CreateRequest{ - ID: agent.ManagerUserID, - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - }); err != nil { - t.Fatalf("first Create(manager) error = %v", err) - } - if err := imSvc.DeleteUser(agent.ManagerUserID); err != nil { - t.Fatalf("DeleteUser(manager) error = %v", err) - } - if containsUser(imSvc.ListUsers(), agent.ManagerUserID) { - t.Fatalf("users = %+v, want u-manager removed before second create", imSvc.ListUsers()) - } - - got, err := svc.Create(context.Background(), CreateRequest{ - ID: agent.ManagerUserID, - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - }) - if err != nil { - t.Fatalf("second Create(manager) error = %v", err) - } - if got.ID != agent.ManagerUserID || got.UserID != agent.ManagerUserID { - t.Fatalf("second Create(manager) = %+v, want u-manager", got) - } - if !containsUser(imSvc.ListUsers(), agent.ManagerUserID) { - t.Fatalf("users = %+v, want u-manager restored", imSvc.ListUsers()) - } -} - -func TestServiceCreateFeishuManagerEnsuresExistingUser(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: agent.ManagerUserID, - Name: agent.ManagerName, - Role: agent.RoleManager, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - feishuSvc := feishu.NewService() - if _, err := feishuSvc.CreateUser(feishu.CreateUserRequest{ID: agent.ManagerUserID, Name: "manager"}); err != nil { - t.Fatalf("CreateUser(manager) error = %v", err) - } - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, nil, feishuSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - got, err := svc.Create(context.Background(), CreateRequest{ - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelFeishu), - }) - if err != nil { - t.Fatalf("Create(feishu manager) error = %v", err) - } - if got.ID != agent.ManagerUserID || got.UserID != agent.ManagerUserID || got.Channel != string(ChannelFeishu) { - t.Fatalf("Create(feishu manager) = %+v, want u-manager feishu", got) - } -} - -func TestServiceCreateFeishuManagerUsesConfiguredOpenID(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - { - ID: agent.ManagerUserID, - Name: agent.ManagerName, - Role: agent.RoleManager, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - feishuSvc := feishu.NewServiceWithBotOpenIDResolver( - map[string]feishu.AppConfig{ - "u-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, - }, - func(_ context.Context, app feishu.AppConfig) (feishu.BotInfo, error) { - if got, want := app.AppID, "cli_manager"; got != want { - t.Fatalf("resolve app_id = %q, want %q", got, want) - } - return feishu.BotInfo{OpenID: "ou_manager", AppName: "Manager Bot"}, nil - }, - ) - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, nil, feishuSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - got, err := svc.Create(context.Background(), CreateRequest{ - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelFeishu), - }) - if err != nil { - t.Fatalf("Create(feishu manager) error = %v", err) - } - if got.ID != agent.ManagerUserID || got.AgentID != agent.ManagerUserID || got.UserID != "ou_manager" || got.Channel != string(ChannelFeishu) { - t.Fatalf("Create(feishu manager) = %+v, want u-manager agent with ou_manager user", got) - } - - bots, err := svc.List(string(ChannelFeishu), "", "") - if err != nil { - t.Fatalf("List(feishu) error = %v", err) - } - if len(bots) != 1 || bots[0].UserID != "ou_manager" { - t.Fatalf("List(feishu) = %+v, want manager user_id ou_manager", bots) - } -} - -func TestServiceCreateManagerBootstrapsMissingAgent(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc := mustNewSeededAgentService(t, nil) - imSvc := im.NewService() - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - got, err := svc.Create(context.Background(), CreateRequest{ - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - }) - if err != nil { - t.Fatalf("Create(manager) error = %v", err) - } - if got.ID != agent.ManagerUserID || got.AgentID != agent.ManagerUserID || got.UserID != agent.ManagerUserID { - t.Fatalf("Create(manager) = %+v, want u-manager IDs", got) - } - if _, ok := agentSvc.Agent(agent.ManagerUserID); !ok { - t.Fatal("manager agent was not bootstrapped") - } -} - -func TestServiceCreateRejectsUnsupportedCombination(t *testing.T) { - svc := mustNewBotService(t, nil) - - _, err := svc.Create(context.Background(), CreateRequest{ - ID: "custom-manager", - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - }) - if err == nil || !strings.Contains(err.Error(), "agent service is required") { - t.Fatalf("Create(manager without agent service) error = %v, want agent service error", err) - } - - _, err = svc.Create(context.Background(), CreateRequest{ - Name: "alice", - Role: string(RoleWorker), - Channel: "slack", - }) - if err == nil || !strings.Contains(err.Error(), "channel must be one of") { - t.Fatalf("Create(feishu) error = %v, want unsupported channel", err) - } -} - -func TestServiceCreateManagerRejectsCustomID(t *testing.T) { - agentSvc := mustNewSeededAgentService(t, []agent.Agent{ - {ID: agent.ManagerUserID, Name: agent.ManagerName, Role: agent.RoleManager}, - }) - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewServiceWithDependencies(store, agentSvc, im.NewService()) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - - _, err = svc.Create(context.Background(), CreateRequest{ - ID: "bot-manager", - Name: "manager", - Role: string(RoleManager), - Channel: string(ChannelCSGClaw), - }) - if err == nil || !strings.Contains(err.Error(), `manager bot id must be "u-manager"`) { - t.Fatalf("Create(manager custom ID) error = %v, want manager ID error", err) - } -} - -func containsUser(users []im.User, id string) bool { - for _, user := range users { - if user.ID == id { - return true - } - } - return false -} - -func containsMember(members []string, id string) bool { - for _, member := range members { - if member == id { - return true - } - } - return false -} - -func mustReceiveEventWithin(t *testing.T, events <-chan im.Event, timeout time.Duration) im.Event { - t.Helper() - select { - case evt := <-events: - return evt - case <-time.After(timeout): - t.Fatalf("timed out waiting for IM event after %s", timeout) - return im.Event{} - } -} - -func mustNewBotService(t *testing.T, bots []Bot) *Service { - t.Helper() - store, err := NewMemoryStore(bots) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - svc, err := NewService(store) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - return svc -} - -func mustNewBotLocalTemplateHubService(t *testing.T, id string, item hub.Template) *hub.Service { - t.Helper() - - registryRoot := t.TempDir() - workspaceRoot := t.TempDir() - if err := os.WriteFile(filepath.Join(workspaceRoot, "USER.md"), []byte("template user\n"), 0o644); err != nil { - t.Fatalf("WriteFile(USER.md) error = %v", err) - } - if err := os.MkdirAll(filepath.Join(workspaceRoot, "skills", "custom"), 0o755); err != nil { - t.Fatalf("MkdirAll(skill dir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(workspaceRoot, "skills", "custom", "SKILL.md"), []byte("# Custom\n"), 0o644); err != nil { - t.Fatalf("WriteFile(SKILL.md) error = %v", err) - } - - store := hub.NewLocalStore(registryRoot) - if _, err := store.Publish(context.Background(), hub.PublishSpec{ - ID: id, - Name: item.Name, - Description: item.Description, - Role: item.Role, - RuntimeKind: item.RuntimeKind, - Image: item.Image, - WorkspaceRef: hub.WorkspaceRef{Kind: hub.WorkspaceKindDir, Path: workspaceRoot}, - UpdatedAt: time.Date(2026, 5, 12, 9, 0, 0, 0, time.UTC), - }); err != nil { - t.Fatalf("Publish() error = %v", err) - } - - svc, err := hub.NewService(config.HubConfig{ - DefaultRegistry: "local", - Registries: []config.HubRegistryConfig{ - {Name: "local", Kind: hub.RegistryKindLocal, Path: registryRoot, Enabled: true}, - }, - }, hub.DefaultStoreFactory) - if err != nil { - t.Fatalf("hub.NewService() error = %v", err) - } - return svc -} - -func mustNewSeededAgentService(t *testing.T, agents []agent.Agent) *agent.Service { - t.Helper() - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - resetFakeBotRuntimeStates() - t.Cleanup(resetFakeBotRuntimeStates) - restoreDefault := agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindPicoClawSandbox})(s); err != nil { - return err - } - if err := agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindOpenClawSandbox})(s); err != nil { - return err - } - return agent.WithRuntime(fakeBotAgentRuntime{kind: agent.RuntimeKindCodex})(s) - }) - t.Cleanup(restoreDefault) - - if agents == nil { - agents = []agent.Agent{} - } - for i := range agents { - if strings.TrimSpace(agents[i].RuntimeKind) == "" { - agents[i].RuntimeKind = agent.RuntimeKindPicoClawSandbox - } - } - dir := t.TempDir() - statePath := filepath.Join(dir, "agents.json") - data, err := json.Marshal(map[string]any{"agents": agents}) - if err != nil { - t.Fatalf("marshal agents: %v", err) - } - if err := os.WriteFile(statePath, append(data, '\n'), 0o600); err != nil { - t.Fatalf("write agents: %v", err) - } - svc, err := agent.NewService(testAgentModelConfig(), config.ServerConfig{}, "manager-image:test", statePath) - if err != nil { - t.Fatalf("agent.NewService() error = %v", err) - } - return svc -} - -func mustSetAgentRuntimeState(t *testing.T, agentName, boxID string, state sandbox.State) { - t.Helper() - setFakeBotRuntimeState(testRuntimeIDForAgentName(agentName), boxID, state) -} diff --git a/internal/bot/store.go b/internal/bot/store.go deleted file mode 100644 index 390a58d4..00000000 --- a/internal/bot/store.go +++ /dev/null @@ -1,189 +0,0 @@ -package bot - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "sync" -) - -type Store struct { - mu sync.RWMutex - path string - items map[string]Bot -} - -type persistedState struct { - Bots []Bot `json:"bots"` -} - -func NewStore(path string) (*Store, error) { - s := &Store{ - path: path, - items: make(map[string]Bot), - } - if err := s.load(); err != nil { - return nil, err - } - return s, nil -} - -func NewMemoryStore(bots []Bot) (*Store, error) { - s := &Store{ - items: make(map[string]Bot), - } - for _, b := range bots { - normalized, err := NormalizeBot(b) - if err != nil { - return nil, err - } - s.items[botStoreKey(normalized)] = normalized - } - return s, nil -} - -func (s *Store) List() []Bot { - s.mu.RLock() - defer s.mu.RUnlock() - return sortedBotsFromMap(s.items) -} - -func (s *Store) Get(id string) (Bot, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - if b, ok := s.items[id]; ok { - return b, true - } - for _, b := range sortedBotsFromMap(s.items) { - if b.ID == id { - return b, true - } - } - return Bot{}, false -} - -func (s *Store) GetByChannelID(channel, id string) (Bot, bool, error) { - normalizedChannel, err := NormalizeChannel(channel) - if err != nil { - return Bot{}, false, err - } - key := botStoreKeyParts(string(normalizedChannel), strings.TrimSpace(id)) - s.mu.RLock() - defer s.mu.RUnlock() - b, ok := s.items[key] - return b, ok, nil -} - -func (s *Store) Save(b Bot) error { - normalized, err := NormalizeBot(b) - if err != nil { - return err - } - - s.mu.Lock() - defer s.mu.Unlock() - s.items[botStoreKey(normalized)] = normalized - return s.saveLocked() -} - -func (s *Store) DeleteByChannelID(channel, id string) (Bot, bool, error) { - normalizedChannel, err := NormalizeChannel(channel) - if err != nil { - return Bot{}, false, err - } - key := botStoreKeyParts(string(normalizedChannel), strings.TrimSpace(id)) - s.mu.Lock() - defer s.mu.Unlock() - b, ok := s.items[key] - if !ok { - return Bot{}, false, nil - } - delete(s.items, key) - if err := s.saveLocked(); err != nil { - return Bot{}, false, err - } - return b, true, nil -} - -func (s *Store) Reload() error { - s.mu.Lock() - defer s.mu.Unlock() - if s.path == "" { - return nil - } - items, err := s.readState() - if err != nil { - return err - } - s.items = items - return nil -} - -func (s *Store) load() error { - items, err := s.readState() - if err != nil { - return err - } - for id, b := range items { - s.items[id] = b - } - return nil -} - -func (s *Store) readState() (map[string]Bot, error) { - items := make(map[string]Bot) - if s.path == "" { - return items, nil - } - - data, err := os.ReadFile(s.path) - if err != nil { - if os.IsNotExist(err) { - return items, nil - } - return nil, fmt.Errorf("read bot state: %w", err) - } - - var state persistedState - if err := json.Unmarshal(data, &state); err != nil { - return nil, fmt.Errorf("decode bot state: %w", err) - } - for _, b := range state.Bots { - normalized, err := NormalizeBot(b) - if err != nil { - return nil, fmt.Errorf("decode bot state: %w", err) - } - items[botStoreKey(normalized)] = normalized - } - return items, nil -} - -func (s *Store) saveLocked() error { - if s.path == "" { - return nil - } - - data, err := json.MarshalIndent(persistedState{ - Bots: sortedBotsFromMap(s.items), - }, "", " ") - if err != nil { - return fmt.Errorf("encode bot state: %w", err) - } - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return fmt.Errorf("create bot state dir: %w", err) - } - if err := os.WriteFile(s.path, append(data, '\n'), 0o600); err != nil { - return fmt.Errorf("write bot state: %w", err) - } - return nil -} - -func botStoreKey(b Bot) string { - return botStoreKeyParts(b.Channel, b.ID) -} - -func botStoreKeyParts(channel, id string) string { - return channel + "\x00" + id -} diff --git a/internal/bot/store_test.go b/internal/bot/store_test.go deleted file mode 100644 index d27064e5..00000000 --- a/internal/bot/store_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package bot - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestNormalizeCreateRequestDefaultsChannel(t *testing.T) { - got, err := NormalizeCreateRequest(CreateRequest{ - Name: " Alice ", - Description: " test lead ", - Role: " WORKER ", - }) - if err != nil { - t.Fatalf("NormalizeCreateRequest() error = %v", err) - } - if got.Name != "Alice" { - t.Fatalf("Name = %q, want %q", got.Name, "Alice") - } - if got.Description != "test lead" { - t.Fatalf("Description = %q, want %q", got.Description, "test lead") - } - if got.Role != string(RoleWorker) { - t.Fatalf("Role = %q, want %q", got.Role, RoleWorker) - } - if got.Channel != string(ChannelCSGClaw) { - t.Fatalf("Channel = %q, want %q", got.Channel, ChannelCSGClaw) - } -} - -func TestNormalizeCreateRequestRejectsInvalidFields(t *testing.T) { - tests := []struct { - name string - req CreateRequest - want string - }{ - { - name: "empty name", - req: CreateRequest{Role: string(RoleWorker)}, - want: "name is required", - }, - { - name: "invalid role", - req: CreateRequest{Name: "alice", Role: "agent"}, - want: "role must be one of", - }, - { - name: "invalid channel", - req: CreateRequest{Name: "alice", Role: string(RoleWorker), Channel: "slack"}, - want: "channel must be one of", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := NormalizeCreateRequest(tt.req) - if err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("NormalizeCreateRequest() error = %v, want containing %q", err, tt.want) - } - }) - } -} - -func TestStoreSaveListAndReload(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "state", "bots.json") - - store, err := NewStore(path) - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - - first := Bot{ - ID: "bot-2", - Name: "Bob", - Description: " handles deployments ", - Role: "WORKER", - Channel: "FEISHU", - AgentID: "agent-2", - UserID: "user-2", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - } - second := Bot{ - ID: "bot-1", - Name: "Manager", - Role: string(RoleManager), - AgentID: "agent-1", - UserID: "user-1", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - } - if err := store.Save(first); err != nil { - t.Fatalf("Save(first) error = %v", err) - } - if err := store.Save(second); err != nil { - t.Fatalf("Save(second) error = %v", err) - } - - got := store.List() - if len(got) != 2 { - t.Fatalf("List() len = %d, want 2", len(got)) - } - if got[0].ID != "bot-1" || got[1].ID != "bot-2" { - t.Fatalf("List() order = %+v, want bot-1 then bot-2", got) - } - if got[0].Channel != string(ChannelCSGClaw) { - t.Fatalf("List()[0].Channel = %q, want default %q", got[0].Channel, ChannelCSGClaw) - } - if got[1].Role != string(RoleWorker) || got[1].Channel != string(ChannelFeishu) { - t.Fatalf("List()[1] = %+v, want normalized worker/feishu", got[1]) - } - - reloaded, err := NewStore(path) - if err != nil { - t.Fatalf("NewStore(reload) error = %v", err) - } - loaded, ok := reloaded.Get("bot-2") - if !ok { - t.Fatal("Get(bot-2) ok = false, want true") - } - if loaded.Role != string(RoleWorker) || loaded.Channel != string(ChannelFeishu) { - t.Fatalf("Get(bot-2) = %+v, want normalized worker/feishu", loaded) - } - if loaded.Description != "handles deployments" { - t.Fatalf("Get(bot-2).Description = %q, want trimmed description", loaded.Description) - } -} - -func TestStoreAllowsSameBotIDAcrossChannels(t *testing.T) { - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - - csgclawBot := Bot{ - ID: "u-alice", - Name: "Alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - } - feishuBot := csgclawBot - feishuBot.Channel = string(ChannelFeishu) - if err := store.Save(csgclawBot); err != nil { - t.Fatalf("Save(csgclaw) error = %v", err) - } - if err := store.Save(feishuBot); err != nil { - t.Fatalf("Save(feishu) error = %v", err) - } - - all := store.List() - if len(all) != 2 { - t.Fatalf("List() = %+v, want two channel-scoped bots", all) - } - if _, ok, err := store.GetByChannelID(string(ChannelCSGClaw), "u-alice"); err != nil || !ok { - t.Fatalf("GetByChannelID(csgclaw, u-alice) = ok %v err %v, want ok", ok, err) - } - if _, ok, err := store.GetByChannelID(string(ChannelFeishu), "u-alice"); err != nil || !ok { - t.Fatalf("GetByChannelID(feishu, u-alice) = ok %v err %v, want ok", ok, err) - } -} - -func TestStoreDeleteByChannelIDRemovesOnlyMatchingChannel(t *testing.T) { - store, err := NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - - csgclawBot := Bot{ - ID: "u-alice", - Name: "Alice", - Role: string(RoleWorker), - Channel: string(ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - } - feishuBot := csgclawBot - feishuBot.Channel = string(ChannelFeishu) - if err := store.Save(csgclawBot); err != nil { - t.Fatalf("Save(csgclaw) error = %v", err) - } - if err := store.Save(feishuBot); err != nil { - t.Fatalf("Save(feishu) error = %v", err) - } - - deleted, ok, err := store.DeleteByChannelID("feishu", "u-alice") - if err != nil { - t.Fatalf("DeleteByChannelID() error = %v", err) - } - if !ok || deleted.Channel != string(ChannelFeishu) { - t.Fatalf("DeleteByChannelID() = %+v, %v; want feishu bot", deleted, ok) - } - if _, ok, err := store.GetByChannelID(string(ChannelFeishu), "u-alice"); err != nil || ok { - t.Fatalf("GetByChannelID(feishu) = ok %v err %v, want deleted", ok, err) - } - if _, ok, err := store.GetByChannelID(string(ChannelCSGClaw), "u-alice"); err != nil || !ok { - t.Fatalf("GetByChannelID(csgclaw) = ok %v err %v, want retained", ok, err) - } -} - -func TestStoreSaveRejectsInvalidBot(t *testing.T) { - store, err := NewStore("") - if err != nil { - t.Fatalf("NewStore() error = %v", err) - } - - if err := store.Save(Bot{Name: "Alice", Role: string(RoleWorker)}); err == nil || !strings.Contains(err.Error(), "id is required") { - t.Fatalf("Save() error = %v, want id is required", err) - } - if err := store.Save(Bot{ID: "bot-1", Role: string(RoleWorker)}); err == nil || !strings.Contains(err.Error(), "name is required") { - t.Fatalf("Save() error = %v, want name is required", err) - } -} - -func TestNewStoreRejectsInvalidState(t *testing.T) { - for _, tc := range []struct { - name string - role string - }{ - {name: "agent", role: "agent"}, - {name: "notifier", role: "notifier"}, - } { - t.Run(tc.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "bots.json") - raw := `{"bots":[{"id":"bot-1","name":"alice","role":"` + tc.role + `","channel":"csgclaw"}]}` - if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { - t.Fatalf("os.WriteFile() error = %v", err) - } - if _, err := NewStore(path); err == nil || !strings.Contains(err.Error(), "role must be one of") { - t.Fatalf("NewStore() error = %v, want role validation error", err) - } - }) - } -} diff --git a/internal/channel/codexbridge/bridge_test.go b/internal/channel/codexbridge/bridge_test.go index acccd4e5..c581498b 100644 --- a/internal/channel/codexbridge/bridge_test.go +++ b/internal/channel/codexbridge/bridge_test.go @@ -1112,7 +1112,10 @@ func TestHTTPClientStreamEventsMentionOnly(t *testing.T) { BaseURL: "http://example.test", MentionOnly: true, HTTPClient: &http.Client{ - Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got, want := req.URL.Path, "/api/v1/channels/csgclaw/participants/u-codex/events"; got != want { + t.Fatalf("event stream path = %q, want %q", got, want) + } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), @@ -1159,6 +1162,44 @@ func TestHTTPClientStreamEventsMentionOnly(t *testing.T) { } } +func TestHTTPClientSendMessageUsesParticipantRoute(t *testing.T) { + t.Parallel() + + client := &HTTPClient{ + BaseURL: "http://example.test", + Token: "secret", + HTTPClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got, want := req.Method, http.MethodPost; got != want { + t.Fatalf("method = %q, want %q", got, want) + } + if got, want := req.URL.Path, "/api/v1/channels/csgclaw/participants/u-codex/messages"; got != want { + t.Fatalf("send message path = %q, want %q", got, want) + } + if got, want := req.Header.Get("Authorization"), "Bearer secret"; got != want { + t.Fatalf("authorization = %q, want %q", got, want) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"message_id":"m-1"}`)), + }, nil + }), + }, + } + + got, err := client.SendMessage(context.Background(), "u-codex", SendMessageRequest{ + RoomID: "room-1", + Text: "hello", + }) + if err != nil { + t.Fatalf("SendMessage() error = %v", err) + } + if got.MessageID != "m-1" { + t.Fatalf("MessageID = %q, want %q", got.MessageID, "m-1") + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/channel/codexbridge/sse_client.go b/internal/channel/codexbridge/sse_client.go index b9505c83..e24d5d98 100644 --- a/internal/channel/codexbridge/sse_client.go +++ b/internal/channel/codexbridge/sse_client.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -73,7 +74,7 @@ func (c *HTTPClient) StreamEvents(ctx context.Context, botID, lastEventID string defer close(events) defer close(errs) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.BaseURL, "/")+"/api/bots/"+strings.TrimSpace(botID)+"/events", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.participantBridgeURL(botID, "/events"), nil) if err != nil { errs <- err return @@ -117,7 +118,7 @@ func (c *HTTPClient) SendMessage(ctx context.Context, botID string, req SendMess if err != nil { return SendMessageResponse{}, fmt.Errorf("marshal send message request: %w", err) } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/api/bots/"+strings.TrimSpace(botID)+"/messages/send", bytes.NewReader(payload)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.participantBridgeURL(botID, "/messages"), bytes.NewReader(payload)) if err != nil { return SendMessageResponse{}, err } @@ -142,6 +143,14 @@ func (c *HTTPClient) SendMessage(ctx context.Context, botID string, req SendMess return sendResp, nil } +func (c *HTTPClient) participantBridgeURL(participantID, suffix string) string { + baseURL := "" + if c != nil { + baseURL = c.BaseURL + } + return strings.TrimRight(baseURL, "/") + "/api/v1/channels/csgclaw/participants/" + url.PathEscape(strings.TrimSpace(participantID)) + suffix +} + func (c *HTTPClient) httpClient() *http.Client { if c != nil && c.HTTPClient != nil { return c.HTTPClient diff --git a/internal/channel/csgclaw/notification_bot/api_deliver.go b/internal/channel/csgclaw/notification/api_deliver.go similarity index 98% rename from internal/channel/csgclaw/notification_bot/api_deliver.go rename to internal/channel/csgclaw/notification/api_deliver.go index 08771e16..fd45706b 100644 --- a/internal/channel/csgclaw/notification_bot/api_deliver.go +++ b/internal/channel/csgclaw/notification/api_deliver.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "bytes" diff --git a/internal/channel/csgclaw/notification_bot/config.go b/internal/channel/csgclaw/notification/config.go similarity index 97% rename from internal/channel/csgclaw/notification_bot/config.go rename to internal/channel/csgclaw/notification/config.go index 60464572..5ed8939a 100644 --- a/internal/channel/csgclaw/notification_bot/config.go +++ b/internal/channel/csgclaw/notification/config.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "crypto/subtle" @@ -14,7 +14,7 @@ const ( DeliveryBoth = "both" ) -// Config is parsed from flat delivery settings stored on bot.runtime_options. +// Config is parsed from flat delivery settings stored on participant metadata. type Config struct { DeliveryMode string WebhookToken string diff --git a/internal/channel/csgclaw/notification_bot/deliver.go b/internal/channel/csgclaw/notification/deliver.go similarity index 97% rename from internal/channel/csgclaw/notification_bot/deliver.go rename to internal/channel/csgclaw/notification/deliver.go index 66cdfbaf..4284560e 100644 --- a/internal/channel/csgclaw/notification_bot/deliver.go +++ b/internal/channel/csgclaw/notification/deliver.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "strings" diff --git a/internal/channel/csgclaw/notification_bot/http_push.go b/internal/channel/csgclaw/notification/http_push.go similarity index 66% rename from internal/channel/csgclaw/notification_bot/http_push.go rename to internal/channel/csgclaw/notification/http_push.go index d4437d71..118377cc 100644 --- a/internal/channel/csgclaw/notification_bot/http_push.go +++ b/internal/channel/csgclaw/notification/http_push.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "io" @@ -8,12 +8,12 @@ import ( const maxNotificationWebhookBody = 4 << 20 -// PushHTTPDeps supplies bot lookup and room delivery for inbound push notifications. +// PushHTTPDeps supplies participant lookup and room delivery for inbound push notifications. type PushHTTPDeps struct { Reload func() error - // LookupNotificationBot returns runtime_options and user_id for webhook auth and delivery. - LookupNotificationBot func(botID string) (runtimeOptions map[string]any, userID string, ok bool) - Deliver Fanouter + // LookupNotificationParticipant returns metadata and user_id for webhook auth and delivery. + LookupNotificationParticipant func(participantID string) (metadata map[string]any, userID string, ok bool) + Deliver Fanouter } // BearerTokenFromRequest returns the bearer value from Authorization, or empty when absent. @@ -25,14 +25,14 @@ func BearerTokenFromRequest(r *http.Request) string { return "" } -// ServeNotificationPush handles POST push delivery for a notification bot. -func ServeNotificationPush(w http.ResponseWriter, r *http.Request, botID string, deps PushHTTPDeps) { - if deps.Reload == nil || deps.LookupNotificationBot == nil { +// ServeNotificationPush handles POST push delivery for a notification participant. +func ServeNotificationPush(w http.ResponseWriter, r *http.Request, participantID string, deps PushHTTPDeps) { + if deps.Reload == nil || deps.LookupNotificationParticipant == nil { http.Error(w, "service not configured", http.StatusServiceUnavailable) return } - botID = strings.TrimSpace(botID) - if botID == "" { + participantID = strings.TrimSpace(participantID) + if participantID == "" { http.NotFound(w, r) return } @@ -44,14 +44,14 @@ func ServeNotificationPush(w http.ResponseWriter, r *http.Request, botID string, http.Error(w, err.Error(), http.StatusInternalServerError) return } - runtimeOptions, userID, ok := deps.LookupNotificationBot(botID) + metadata, userID, ok := deps.LookupNotificationParticipant(participantID) if !ok { - http.Error(w, "notification bot not found", http.StatusNotFound) + http.Error(w, "notification participant not found", http.StatusNotFound) return } - cfg := ConfigFromBotRuntimeOptions(runtimeOptions) + cfg := ConfigFromMetadata(metadata) if !cfg.AllowsWebhook() { - http.Error(w, "webhook delivery not enabled for this bot", http.StatusForbidden) + http.Error(w, "webhook delivery not enabled for this participant", http.StatusForbidden) return } got := BearerTokenFromRequest(r) @@ -65,7 +65,7 @@ func ServeNotificationPush(w http.ResponseWriter, r *http.Request, botID string, } userID = strings.TrimSpace(userID) if userID == "" { - userID = botID + userID = participantID } body, err := io.ReadAll(io.LimitReader(r.Body, maxNotificationWebhookBody)) diff --git a/internal/channel/csgclaw/notification_bot/notify_card.go b/internal/channel/csgclaw/notification/notify_card.go similarity index 99% rename from internal/channel/csgclaw/notification_bot/notify_card.go rename to internal/channel/csgclaw/notification/notify_card.go index 88d37e31..3023debf 100644 --- a/internal/channel/csgclaw/notification_bot/notify_card.go +++ b/internal/channel/csgclaw/notification/notify_card.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "bytes" diff --git a/internal/channel/csgclaw/notification_bot/notify_card_test.go b/internal/channel/csgclaw/notification/notify_card_test.go similarity index 99% rename from internal/channel/csgclaw/notification_bot/notify_card_test.go rename to internal/channel/csgclaw/notification/notify_card_test.go index a7d9605c..d706f2e2 100644 --- a/internal/channel/csgclaw/notification_bot/notify_card_test.go +++ b/internal/channel/csgclaw/notification/notify_card_test.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "encoding/json" diff --git a/internal/channel/csgclaw/notification_bot/notify_webhooks.go b/internal/channel/csgclaw/notification/notify_webhooks.go similarity index 99% rename from internal/channel/csgclaw/notification_bot/notify_webhooks.go rename to internal/channel/csgclaw/notification/notify_webhooks.go index 333429ff..b9fac9b6 100644 --- a/internal/channel/csgclaw/notification_bot/notify_webhooks.go +++ b/internal/channel/csgclaw/notification/notify_webhooks.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "encoding/json" diff --git a/internal/channel/csgclaw/notification_bot/profile.go b/internal/channel/csgclaw/notification/profile.go similarity index 62% rename from internal/channel/csgclaw/notification_bot/profile.go rename to internal/channel/csgclaw/notification/profile.go index f31ffe30..bf8a767e 100644 --- a/internal/channel/csgclaw/notification_bot/profile.go +++ b/internal/channel/csgclaw/notification/profile.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "strings" @@ -6,8 +6,8 @@ import ( "csgclaw/internal/utils" ) -// RuntimeOptionKeyNotificationProfile is the runtime_options key for derived API summary. -const RuntimeOptionKeyNotificationProfile = "notification_profile" +// MetadataKeyNotificationProfile is the metadata key for derived API summary. +const MetadataKeyNotificationProfile = "notification_profile" // ProfileViewSummary is view-only API state derived from stored configuration. type ProfileViewSummary struct { @@ -32,9 +32,9 @@ func ProfileDeliveryComplete(flat map[string]any) bool { } } -// ProfileViewSummaryForRuntimeOptions returns nil when no configuration is present. -func ProfileViewSummaryForRuntimeOptions(runtimeOptions map[string]any) *ProfileViewSummary { - flat := FlatFromRuntimeOptionsMap(runtimeOptions) +// ProfileViewSummaryForMetadata returns nil when no configuration is present. +func ProfileViewSummaryForMetadata(metadata map[string]any) *ProfileViewSummary { + flat := FlatFromMetadataMap(metadata) if len(flat) == 0 { return nil } @@ -69,16 +69,16 @@ func profileViewSummaryToMap(s *ProfileViewSummary) map[string]any { return m } -// RedactRuntimeOptionsForAPI returns runtime_options safe for JSON responses. -func RedactRuntimeOptionsForAPI(runtimeOptions map[string]any) map[string]any { - if len(runtimeOptions) == 0 { +// RedactMetadataForAPI returns metadata safe for JSON responses. +func RedactMetadataForAPI(metadata map[string]any) map[string]any { + if len(metadata) == 0 { return nil } - out := utils.CloneAnyMap(runtimeOptions) + out := utils.CloneAnyMap(metadata) if out == nil { return nil } - delete(out, RuntimeOptionKeyNotificationProfile) + delete(out, MetadataKeyNotificationProfile) if len(copyStorageKeysFromMap(out)) > 0 { redRoot := RedactDetailsForAPI(copyStorageKeysFromMap(out)) for _, k := range StorageKeys { @@ -94,18 +94,18 @@ func RedactRuntimeOptionsForAPI(runtimeOptions map[string]any) map[string]any { return out } -// ViewRuntimeOptionsForAPI returns redacted runtime_options plus notification_profile summary. -func ViewRuntimeOptionsForAPI(runtimeOptions map[string]any) map[string]any { - base := RedactRuntimeOptionsForAPI(runtimeOptions) - summaryMap := profileViewSummaryToMap(ProfileViewSummaryForRuntimeOptions(runtimeOptions)) +// ViewMetadataForAPI returns redacted metadata plus notification_profile summary. +func ViewMetadataForAPI(metadata map[string]any) map[string]any { + base := RedactMetadataForAPI(metadata) + summaryMap := profileViewSummaryToMap(ProfileViewSummaryForMetadata(metadata)) out := utils.CloneAnyMap(base) if out == nil { out = make(map[string]any) } if summaryMap != nil { - out[RuntimeOptionKeyNotificationProfile] = summaryMap + out[MetadataKeyNotificationProfile] = summaryMap } - flat := FlatFromRuntimeOptionsMap(runtimeOptions) + flat := FlatFromMetadataMap(metadata) remoteURL := strings.TrimSpace(ParseNotifierDetails(flat).RemoteURL) if remoteURL != "" { if msg, ack, ingress, err := ResolveRelayRoutes(remoteURL); err == nil { @@ -120,9 +120,9 @@ func ViewRuntimeOptionsForAPI(runtimeOptions map[string]any) map[string]any { return out } -// MergeRuntimeOptionsPatch merges patch runtime_options onto stored options. -func MergeRuntimeOptionsPatch(baseRuntimeOptions, patchRuntimeOptions map[string]any) map[string]any { - base := utils.CloneAnyMap(baseRuntimeOptions) - incoming := StripViewOnlyRuntimeOptionKeys(patchRuntimeOptions) +// MergeMetadataPatch merges patch metadata onto stored options. +func MergeMetadataPatch(baseMetadata, patchMetadata map[string]any) map[string]any { + base := utils.CloneAnyMap(baseMetadata) + incoming := StripViewOnlyMetadataKeys(patchMetadata) return MergeFlatPatchKeys(base, incoming) } diff --git a/internal/channel/csgclaw/notification_bot/profile_test.go b/internal/channel/csgclaw/notification/profile_test.go similarity index 98% rename from internal/channel/csgclaw/notification_bot/profile_test.go rename to internal/channel/csgclaw/notification/profile_test.go index fb80a163..e846dc83 100644 --- a/internal/channel/csgclaw/notification_bot/profile_test.go +++ b/internal/channel/csgclaw/notification/profile_test.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import "testing" diff --git a/internal/channel/csgclaw/notification/pull/supervisor.go b/internal/channel/csgclaw/notification/pull/supervisor.go new file mode 100644 index 00000000..9367e684 --- /dev/null +++ b/internal/channel/csgclaw/notification/pull/supervisor.go @@ -0,0 +1,263 @@ +package pull + +import ( + "context" + "log/slog" + "strings" + "sync" + "time" + + "csgclaw/internal/channel/csgclaw/notification" +) + +const csgclawChannel = "csgclaw" + +// NotificationParticipant is the minimal participant view needed for pull delivery. +type NotificationParticipant struct { + ID string + UserID string +} + +// ParticipantLister lists notification participants eligible for pull delivery. +type ParticipantLister interface { + Reload() error + ListNotificationParticipants(channel string) ([]NotificationParticipant, error) + // LookupNotificationParticipantForDelivery returns stored metadata with secrets, not API-redacted view. + LookupNotificationParticipantForDelivery(channel, id string) (metadata map[string]any, userID string, ok bool) +} + +// Supervisor reconciles per-participant pull loops for notification participants with pull delivery enabled. +type Supervisor struct { + Participants ParticipantLister + Deliver notification.Fanouter + Relay *notification.RelayClient + Log *slog.Logger + + reloadMu sync.Mutex + lastReload time.Time + reloadPeriod time.Duration + + mu sync.Mutex + loopStop map[string]context.CancelFunc +} + +// NewSupervisor wires notification pull delivery over participant metadata and IM fanout. +func NewSupervisor(participants ParticipantLister, d notification.Fanouter) *Supervisor { + return &Supervisor{ + Participants: participants, + Deliver: d, + Relay: ¬ification.RelayClient{}, + Log: slog.Default(), + loopStop: make(map[string]context.CancelFunc), + reloadPeriod: 10 * time.Second, + } +} + +// Run blocks until ctx is cancelled. +func (s *Supervisor) Run(ctx context.Context) { + if s == nil || s.Participants == nil || s.Deliver == nil { + return + } + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + defer s.stopAllLoops() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.maybeReloadFromDisk() + s.syncLoops(ctx) + } + } +} + +func (s *Supervisor) stopAllLoops() { + s.mu.Lock() + cancels := make([]context.CancelFunc, 0, len(s.loopStop)) + for _, c := range s.loopStop { + cancels = append(cancels, c) + } + s.loopStop = make(map[string]context.CancelFunc) + s.mu.Unlock() + for _, c := range cancels { + c() + } +} + +func (s *Supervisor) maybeReloadFromDisk() { + period := s.reloadPeriod + if period <= 0 { + period = 10 * time.Second + } + now := time.Now() + s.reloadMu.Lock() + if !s.lastReload.IsZero() && now.Sub(s.lastReload) < period { + s.reloadMu.Unlock() + return + } + s.lastReload = now + s.reloadMu.Unlock() + if err := s.Participants.Reload(); err != nil && s.Log != nil { + s.Log.Debug("notification pull: participant reload", "error", err) + } +} + +func (s *Supervisor) desiredPullParticipantIDs() map[string]struct{} { + out := make(map[string]struct{}) + channel := csgclawChannel + items, err := s.Participants.ListNotificationParticipants(channel) + if err != nil { + if s.Log != nil { + s.Log.Warn("notification pull: list participants", "error", err) + } + return out + } + for _, item := range items { + // ListNotificationParticipants returns a public list view. Pull eligibility + // must use stored metadata via LookupNotificationParticipantForDelivery. + // Pull eligibility must use stored secrets via LookupNotificationParticipantForDelivery. + flat, _, ok := s.Participants.LookupNotificationParticipantForDelivery(channel, item.ID) + if !ok { + continue + } + cfg := notification.ConfigFromMetadata(flat) + if !cfg.PullDeliveryComplete() { + if s.Log != nil && cfg.AllowsPull() { + s.Log.Debug("notification pull: participant not ready", "participant_id", item.ID, "has_token", strings.TrimSpace(cfg.RemoteToken) != "") + } + continue + } + out[item.ID] = struct{}{} + } + return out +} + +func (s *Supervisor) syncLoops(parentCtx context.Context) { + desired := s.desiredPullParticipantIDs() + + s.mu.Lock() + for id, cancel := range s.loopStop { + if _, ok := desired[id]; !ok { + cancel() + delete(s.loopStop, id) + } + } + type loopStart struct { + ctx context.Context + id string + } + var starts []loopStart + for id := range desired { + if _, ok := s.loopStop[id]; ok { + continue + } + loopCtx, cancel := context.WithCancel(parentCtx) + s.loopStop[id] = cancel + starts = append(starts, loopStart{ctx: loopCtx, id: id}) + } + s.mu.Unlock() + for _, st := range starts { + if s.Log != nil { + s.Log.Info("notification pull loop started", "participant_id", st.id) + } + go s.participantPullLoop(st.ctx, st.id) + } +} + +func (s *Supervisor) lookupParticipant(participantID string) (NotificationParticipant, notification.Config, bool) { + id := strings.TrimSpace(participantID) + if id == "" { + return NotificationParticipant{}, notification.Config{}, false + } + flat, userID, ok := s.Participants.LookupNotificationParticipantForDelivery(csgclawChannel, id) + if !ok || len(flat) == 0 { + return NotificationParticipant{}, notification.Config{}, false + } + cfg := notification.ConfigFromMetadata(flat) + item := NotificationParticipant{ID: id, UserID: userID} + return item, cfg, true +} + +func (s *Supervisor) participantPullLoop(ctx context.Context, participantID string) { + for { + select { + case <-ctx.Done(): + return + default: + } + item, cfg, ok := s.lookupParticipant(participantID) + if !ok { + return + } + if !cfg.PullDeliveryComplete() { + return + } + if err := s.pullParticipant(ctx, item, cfg); err != nil { + if s.Log != nil { + s.Log.Info("notification pull failed", "participant_id", item.ID, "error", err) + } + } + interval := cfg.PollIntervalDuration() + if interval <= 0 { + interval = 5 * time.Second + } + t := time.NewTimer(interval) + select { + case <-ctx.Done(): + if !t.Stop() { + <-t.C + } + return + case <-t.C: + } + } +} + +func (s *Supervisor) pullParticipant(ctx context.Context, item NotificationParticipant, cfg notification.Config) error { + msgs, _, err := s.Relay.FetchInbox(ctx, cfg, 50, "") + if err != nil { + return err + } + memberID := strings.TrimSpace(item.UserID) + if memberID == "" { + memberID = strings.TrimSpace(item.ID) + } + var ackIDs []string + var deliverErr error + delivered := 0 + for _, m := range msgs { + raw, ct, err := notification.DecodePayload(m) + if err != nil { + if s.Log != nil { + s.Log.Warn("notification inbox decode skipped", "participant_id", item.ID, "msg_id", m.ID, "error", err) + } + continue + } + content := notification.FormatPayloadAsChatContent(raw, ct, nil) + if err := s.Deliver.DeliverFanout(memberID, content); err != nil { + deliverErr = err + break + } + delivered++ + if id := strings.TrimSpace(m.ID); id != "" { + ackIDs = append(ackIDs, id) + } + } + if len(ackIDs) > 0 { + if err := s.Relay.Ack(ctx, cfg, ackIDs); err != nil { + return err + } + } + if s.Log != nil { + switch { + case len(msgs) == 0: + s.Log.Debug("notification pull ok", "participant_id", item.ID, "messages", 0) + case delivered > 0: + s.Log.Info("notification pull delivered", "participant_id", item.ID, "messages", delivered, "acked", len(ackIDs)) + case len(msgs) > 0 && delivered == 0: + s.Log.Warn("notification pull: inbox had messages but none delivered", "participant_id", item.ID, "inbox", len(msgs)) + } + } + return deliverErr +} diff --git a/internal/channel/csgclaw/notification_bot/pull/supervisor_test.go b/internal/channel/csgclaw/notification/pull/supervisor_test.go similarity index 62% rename from internal/channel/csgclaw/notification_bot/pull/supervisor_test.go rename to internal/channel/csgclaw/notification/pull/supervisor_test.go index ec2343fd..26cd91da 100644 --- a/internal/channel/csgclaw/notification_bot/pull/supervisor_test.go +++ b/internal/channel/csgclaw/notification/pull/supervisor_test.go @@ -6,37 +6,27 @@ import ( "net/http/httptest" "testing" - "csgclaw/internal/bot" - "csgclaw/internal/channel/csgclaw/notification_bot" + "csgclaw/internal/channel/csgclaw/notification" ) -type stubBotLister struct { +type stubParticipantLister struct { flat map[string]any } -func (s *stubBotLister) Reload() error { return nil } - -func (s *stubBotLister) ListNotificationBots(string) ([]bot.Bot, error) { - // API list view: secrets redacted (matches presentNotificationBot). - return []bot.Bot{{ - ID: "u-test", - Type: bot.BotTypeNotification, - RuntimeOptions: map[string]any{ - "delivery_mode": "remote_pull", - "remote_url": s.flat["remote_url"], - "remote_token_set": true, - }, - }}, nil +func (s *stubParticipantLister) Reload() error { return nil } + +func (s *stubParticipantLister) ListNotificationParticipants(string) ([]NotificationParticipant, error) { + return []NotificationParticipant{{ID: "u-test", UserID: "u-test"}}, nil } -func (s *stubBotLister) LookupNotificationBotForDelivery(string, string) (map[string]any, string, bool) { +func (s *stubParticipantLister) LookupNotificationParticipantForDelivery(string, string) (map[string]any, string, bool) { return s.flat, "u-test", true } -func TestDesiredPullBotIDsUsesStoredTokenNotAPIView(t *testing.T) { +func TestDesiredPullParticipantIDsUsesStoredTokenNotAPIView(t *testing.T) { t.Parallel() sup := &Supervisor{ - Bots: &stubBotLister{ + Participants: &stubParticipantLister{ flat: map[string]any{ "delivery_mode": "remote_pull", "remote_url": "https://relay.example.com", @@ -44,25 +34,25 @@ func TestDesiredPullBotIDsUsesStoredTokenNotAPIView(t *testing.T) { }, }, } - got := sup.desiredPullBotIDs() + got := sup.desiredPullParticipantIDs() if len(got) != 1 { - t.Fatalf("desiredPullBotIDs() = %v, want one bot", got) + t.Fatalf("desiredPullParticipantIDs() = %v, want one participant", got) } if _, ok := got["u-test"]; !ok { - t.Fatalf("desiredPullBotIDs() missing u-test, got %v", got) + t.Fatalf("desiredPullParticipantIDs() missing u-test, got %v", got) } } -func TestDesiredPullBotIDsSkipsWhenStoredTokenMissing(t *testing.T) { +func TestDesiredPullParticipantIDsSkipsWhenStoredTokenMissing(t *testing.T) { t.Parallel() sup := &Supervisor{ - Bots: &stubBotLister{flat: map[string]any{ + Participants: &stubParticipantLister{flat: map[string]any{ "delivery_mode": "remote_pull", "remote_url": "https://relay.example.com", }}, } - if len(sup.desiredPullBotIDs()) != 0 { - t.Fatal("desiredPullBotIDs() should be empty without stored remote_token") + if len(sup.desiredPullParticipantIDs()) != 0 { + t.Fatal("desiredPullParticipantIDs() should be empty without stored remote_token") } } @@ -76,9 +66,9 @@ func TestSupervisorPullUsesStoredTokenNotAPIView(t *testing.T) { })) defer srv.Close() - relay := ¬ification_bot.RelayClient{HTTP: srv.Client()} + relay := ¬ification.RelayClient{HTTP: srv.Client()} sup := &Supervisor{ - Bots: &stubBotLister{ + Participants: &stubParticipantLister{ flat: map[string]any{ "delivery_mode": "remote_pull", "remote_url": srv.URL, @@ -89,12 +79,12 @@ func TestSupervisorPullUsesStoredTokenNotAPIView(t *testing.T) { }, Relay: relay, } - b, cfg, ok := sup.lookupBot("u-test") + item, cfg, ok := sup.lookupParticipant("u-test") if !ok { - t.Fatal("lookupBot() = false") + t.Fatal("lookupParticipant() = false") } - if err := sup.pullBot(context.Background(), b, cfg); err != nil { - t.Fatalf("pullBot() error = %v", err) + if err := sup.pullParticipant(context.Background(), item, cfg); err != nil { + t.Fatalf("pullParticipant() error = %v", err) } if gotAuth != "Bearer secret-token" { t.Fatalf("Authorization = %q, want Bearer secret-token", gotAuth) @@ -120,7 +110,7 @@ type deliverFailError struct{} func (e *deliverFailError) Error() string { return "deliver failed" } -func TestPullBotAcksDeliveredBeforeFailure(t *testing.T) { +func TestPullParticipantAcksDeliveredBeforeFailure(t *testing.T) { t.Parallel() var acked []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -137,10 +127,10 @@ func TestPullBotAcksDeliveredBeforeFailure(t *testing.T) { })) defer srv.Close() - relay := ¬ification_bot.RelayClient{HTTP: srv.Client()} + relay := ¬ification.RelayClient{HTTP: srv.Client()} deliver := &stubFanouter{failFrom: 2} sup := &Supervisor{ - Bots: &stubBotLister{ + Participants: &stubParticipantLister{ flat: map[string]any{ "delivery_mode": "remote_pull", "remote_url": srv.URL, @@ -151,13 +141,13 @@ func TestPullBotAcksDeliveredBeforeFailure(t *testing.T) { Relay: relay, Deliver: deliver, } - b, cfg, ok := sup.lookupBot("u-test") + item, cfg, ok := sup.lookupParticipant("u-test") if !ok { - t.Fatal("lookupBot() = false") + t.Fatal("lookupParticipant() = false") } - err := sup.pullBot(context.Background(), b, cfg) + err := sup.pullParticipant(context.Background(), item, cfg) if err == nil { - t.Fatal("pullBot() error = nil, want deliver failure") + t.Fatal("pullParticipant() error = nil, want deliver failure") } if deliver.calls != 2 { t.Fatalf("DeliverFanout calls = %d, want 2", deliver.calls) diff --git a/internal/channel/csgclaw/notification_bot/relay.go b/internal/channel/csgclaw/notification/relay.go similarity index 99% rename from internal/channel/csgclaw/notification_bot/relay.go rename to internal/channel/csgclaw/notification/relay.go index 287c1c66..8c2d00ea 100644 --- a/internal/channel/csgclaw/notification_bot/relay.go +++ b/internal/channel/csgclaw/notification/relay.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "context" diff --git a/internal/channel/csgclaw/notification_bot/relay_resolve_test.go b/internal/channel/csgclaw/notification/relay_resolve_test.go similarity index 99% rename from internal/channel/csgclaw/notification_bot/relay_resolve_test.go rename to internal/channel/csgclaw/notification/relay_resolve_test.go index 00863a88..0176fce8 100644 --- a/internal/channel/csgclaw/notification_bot/relay_resolve_test.go +++ b/internal/channel/csgclaw/notification/relay_resolve_test.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import "testing" diff --git a/internal/channel/csgclaw/notification_bot/storage.go b/internal/channel/csgclaw/notification/storage.go similarity index 75% rename from internal/channel/csgclaw/notification_bot/storage.go rename to internal/channel/csgclaw/notification/storage.go index 075e9be5..e8365707 100644 --- a/internal/channel/csgclaw/notification_bot/storage.go +++ b/internal/channel/csgclaw/notification/storage.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import ( "fmt" @@ -7,7 +7,7 @@ import ( "csgclaw/internal/utils" ) -// StorageKeys lists flat keys for notification delivery on bot.runtime_options. +// StorageKeys lists flat keys for notification delivery on participant metadata. var StorageKeys = []string{ "delivery_mode", "webhook_token", @@ -27,9 +27,9 @@ func ConfigFromStored(storedFlat map[string]any) Config { return ParseNotifierDetails(storedFlat) } -// ConfigFromBotRuntimeOptions parses Config from bot.runtime_options. -func ConfigFromBotRuntimeOptions(runtimeOptions map[string]any) Config { - return ConfigFromStored(FlatFromRuntimeOptionsMap(runtimeOptions)) +// ConfigFromMetadata parses Config from participant metadata. +func ConfigFromMetadata(metadata map[string]any) Config { + return ConfigFromStored(FlatFromMetadataMap(metadata)) } func isEmptySecret(v any) bool { @@ -95,8 +95,8 @@ func normalizeSecretForStorage(v any) string { return s } -// NormalizeFlatForStorage canonicalizes flat runtime_options before persisting to disk. -func NormalizeFlatForStorage(flat map[string]any) map[string]any { +// NormalizeMetadataForStorage canonicalizes flat metadata before persisting to disk. +func NormalizeMetadataForStorage(flat map[string]any) map[string]any { if len(flat) == 0 { return nil } @@ -127,16 +127,16 @@ func NormalizeFlatForStorage(flat map[string]any) map[string]any { return out } -// FlatFromRuntimeOptionsMap returns delivery flat keys from runtime_options. -func FlatFromRuntimeOptionsMap(runtimeOptions map[string]any) map[string]any { - if len(runtimeOptions) == 0 { +// FlatFromMetadataMap returns delivery flat keys from metadata. +func FlatFromMetadataMap(metadata map[string]any) map[string]any { + if len(metadata) == 0 { return nil } - runtimeOptions = StripViewOnlyRuntimeOptionKeys(runtimeOptions) - if len(runtimeOptions) == 0 { + metadata = StripViewOnlyMetadataKeys(metadata) + if len(metadata) == 0 { return nil } - if flat := copyStorageKeysFromMap(runtimeOptions); len(flat) > 0 { + if flat := copyStorageKeysFromMap(metadata); len(flat) > 0 { return utils.CloneAnyMap(flat) } return nil diff --git a/internal/channel/csgclaw/notification_bot/storage_test.go b/internal/channel/csgclaw/notification/storage_test.go similarity index 98% rename from internal/channel/csgclaw/notification_bot/storage_test.go rename to internal/channel/csgclaw/notification/storage_test.go index 5cd95ea8..0b795de7 100644 --- a/internal/channel/csgclaw/notification_bot/storage_test.go +++ b/internal/channel/csgclaw/notification/storage_test.go @@ -1,4 +1,4 @@ -package notification_bot +package notification import "testing" diff --git a/internal/channel/csgclaw/notification_bot/view.go b/internal/channel/csgclaw/notification/view.go similarity index 64% rename from internal/channel/csgclaw/notification_bot/view.go rename to internal/channel/csgclaw/notification/view.go index 86bf1c94..36826b63 100644 --- a/internal/channel/csgclaw/notification_bot/view.go +++ b/internal/channel/csgclaw/notification/view.go @@ -1,21 +1,21 @@ -package notification_bot +package notification import "csgclaw/internal/utils" -var viewOnlyRuntimeOptionRootKeys = []string{ - RuntimeOptionKeyNotificationProfile, +var viewOnlyMetadataRootKeys = []string{ + MetadataKeyNotificationProfile, "relay_pull_messages_url", "relay_pull_ack_url", "relay_webhook_ingress_url", } -// StripViewOnlyRuntimeOptionKeys removes API-only keys that must never be persisted. -func StripViewOnlyRuntimeOptionKeys(ext map[string]any) map[string]any { +// StripViewOnlyMetadataKeys removes API-only keys that must never be persisted. +func StripViewOnlyMetadataKeys(ext map[string]any) map[string]any { if len(ext) == 0 { return nil } needsCopy := false - for _, k := range viewOnlyRuntimeOptionRootKeys { + for _, k := range viewOnlyMetadataRootKeys { if _, ok := ext[k]; ok { needsCopy = true break @@ -25,7 +25,7 @@ func StripViewOnlyRuntimeOptionKeys(ext map[string]any) map[string]any { return ext } out := utils.CloneAnyMap(ext) - for _, k := range viewOnlyRuntimeOptionRootKeys { + for _, k := range viewOnlyMetadataRootKeys { delete(out, k) } if len(out) == 0 { diff --git a/internal/channel/csgclaw/notification/view_test.go b/internal/channel/csgclaw/notification/view_test.go new file mode 100644 index 00000000..0bd20e43 --- /dev/null +++ b/internal/channel/csgclaw/notification/view_test.go @@ -0,0 +1,40 @@ +package notification + +import "testing" + +func TestStripViewOnlyMetadataKeys_nilEmpty(t *testing.T) { + t.Parallel() + if got := StripViewOnlyMetadataKeys(nil); got != nil { + t.Fatalf("nil: got %#v, want nil", got) + } + if got := StripViewOnlyMetadataKeys(map[string]any{}); got != nil { + t.Fatalf("empty: got %#v, want nil", got) + } +} + +func TestStripViewOnlyMetadataKeys_noViewKeys(t *testing.T) { + t.Parallel() + ext := map[string]any{"delivery_mode": "webhook"} + got := StripViewOnlyMetadataKeys(ext) + if got["delivery_mode"] != "webhook" || len(got) != 1 { + t.Fatalf("want delivery preserved, got %#v", got) + } +} + +func TestStripViewOnlyMetadataKeys_stripsNotificationProfile(t *testing.T) { + t.Parallel() + ext := map[string]any{ + "delivery_mode": "webhook", + MetadataKeyNotificationProfile: map[string]any{"delivery_complete": true}, + } + got := StripViewOnlyMetadataKeys(ext) + if _, ok := got[MetadataKeyNotificationProfile]; ok { + t.Fatalf("notification_profile should be removed, got %#v", got) + } + if got["delivery_mode"] != "webhook" { + t.Fatalf("delivery_mode: got %#v", got["delivery_mode"]) + } + if _, ok := ext[MetadataKeyNotificationProfile]; !ok { + t.Fatal("original map must be unchanged") + } +} diff --git a/internal/channel/csgclaw/notification_bot/pull/supervisor.go b/internal/channel/csgclaw/notification_bot/pull/supervisor.go deleted file mode 100644 index 4391dcd5..00000000 --- a/internal/channel/csgclaw/notification_bot/pull/supervisor.go +++ /dev/null @@ -1,255 +0,0 @@ -package pull - -import ( - "context" - "log/slog" - "strings" - "sync" - "time" - - "csgclaw/internal/bot" - "csgclaw/internal/channel/csgclaw/notification_bot" -) - -// BotLister lists notification bots eligible for pull delivery. -type BotLister interface { - Reload() error - ListNotificationBots(channel string) ([]bot.Bot, error) - // LookupNotificationBotForDelivery returns stored runtime_options (with secrets), not API-redacted view. - LookupNotificationBotForDelivery(channel, id string) (runtimeOptions map[string]any, userID string, ok bool) -} - -// Supervisor reconciles per-bot pull loops for notification bots with pull delivery enabled. -type Supervisor struct { - Bots BotLister - Deliver notification_bot.Fanouter - Relay *notification_bot.RelayClient - Log *slog.Logger - - reloadMu sync.Mutex - lastReload time.Time - reloadPeriod time.Duration - - mu sync.Mutex - loopStop map[string]context.CancelFunc -} - -// NewSupervisor wires notification pull delivery over the bot store and IM fanout. -func NewSupervisor(bots BotLister, d notification_bot.Fanouter) *Supervisor { - return &Supervisor{ - Bots: bots, - Deliver: d, - Relay: ¬ification_bot.RelayClient{}, - Log: slog.Default(), - loopStop: make(map[string]context.CancelFunc), - reloadPeriod: 10 * time.Second, - } -} - -// Run blocks until ctx is cancelled. -func (s *Supervisor) Run(ctx context.Context) { - if s == nil || s.Bots == nil || s.Deliver == nil { - return - } - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - defer s.stopAllLoops() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - s.maybeReloadFromDisk() - s.syncLoops(ctx) - } - } -} - -func (s *Supervisor) stopAllLoops() { - s.mu.Lock() - cancels := make([]context.CancelFunc, 0, len(s.loopStop)) - for _, c := range s.loopStop { - cancels = append(cancels, c) - } - s.loopStop = make(map[string]context.CancelFunc) - s.mu.Unlock() - for _, c := range cancels { - c() - } -} - -func (s *Supervisor) maybeReloadFromDisk() { - period := s.reloadPeriod - if period <= 0 { - period = 10 * time.Second - } - now := time.Now() - s.reloadMu.Lock() - if !s.lastReload.IsZero() && now.Sub(s.lastReload) < period { - s.reloadMu.Unlock() - return - } - s.lastReload = now - s.reloadMu.Unlock() - if err := s.Bots.Reload(); err != nil && s.Log != nil { - s.Log.Debug("notification pull: bot reload", "error", err) - } -} - -func (s *Supervisor) desiredPullBotIDs() map[string]struct{} { - out := make(map[string]struct{}) - channel := string(bot.ChannelCSGClaw) - bots, err := s.Bots.ListNotificationBots(channel) - if err != nil { - if s.Log != nil { - s.Log.Warn("notification pull: list bots", "error", err) - } - return out - } - for _, b := range bots { - // ListNotificationBots returns API-redacted runtime_options (no remote_token). - // Pull eligibility must use stored secrets via LookupNotificationBotForDelivery. - flat, _, ok := s.Bots.LookupNotificationBotForDelivery(channel, b.ID) - if !ok { - continue - } - cfg := notification_bot.ConfigFromBotRuntimeOptions(flat) - if !cfg.PullDeliveryComplete() { - if s.Log != nil && cfg.AllowsPull() { - s.Log.Debug("notification pull: bot not ready", "bot_id", b.ID, "has_token", strings.TrimSpace(cfg.RemoteToken) != "") - } - continue - } - out[b.ID] = struct{}{} - } - return out -} - -func (s *Supervisor) syncLoops(parentCtx context.Context) { - desired := s.desiredPullBotIDs() - - s.mu.Lock() - for id, cancel := range s.loopStop { - if _, ok := desired[id]; !ok { - cancel() - delete(s.loopStop, id) - } - } - type loopStart struct { - ctx context.Context - id string - } - var starts []loopStart - for id := range desired { - if _, ok := s.loopStop[id]; ok { - continue - } - loopCtx, cancel := context.WithCancel(parentCtx) - s.loopStop[id] = cancel - starts = append(starts, loopStart{ctx: loopCtx, id: id}) - } - s.mu.Unlock() - for _, st := range starts { - if s.Log != nil { - s.Log.Info("notification pull loop started", "bot_id", st.id) - } - go s.botPullLoop(st.ctx, st.id) - } -} - -func (s *Supervisor) lookupBot(botID string) (bot.Bot, notification_bot.Config, bool) { - id := strings.TrimSpace(botID) - if id == "" { - return bot.Bot{}, notification_bot.Config{}, false - } - flat, userID, ok := s.Bots.LookupNotificationBotForDelivery(string(bot.ChannelCSGClaw), id) - if !ok || len(flat) == 0 { - return bot.Bot{}, notification_bot.Config{}, false - } - cfg := notification_bot.ConfigFromBotRuntimeOptions(flat) - b := bot.Bot{ID: id, UserID: userID, Channel: string(bot.ChannelCSGClaw)} - return b, cfg, true -} - -func (s *Supervisor) botPullLoop(ctx context.Context, botID string) { - for { - select { - case <-ctx.Done(): - return - default: - } - b, cfg, ok := s.lookupBot(botID) - if !ok { - return - } - if !cfg.PullDeliveryComplete() { - return - } - if err := s.pullBot(ctx, b, cfg); err != nil { - if s.Log != nil { - s.Log.Info("notification pull failed", "bot_id", b.ID, "error", err) - } - } - interval := cfg.PollIntervalDuration() - if interval <= 0 { - interval = 5 * time.Second - } - t := time.NewTimer(interval) - select { - case <-ctx.Done(): - if !t.Stop() { - <-t.C - } - return - case <-t.C: - } - } -} - -func (s *Supervisor) pullBot(ctx context.Context, b bot.Bot, cfg notification_bot.Config) error { - msgs, _, err := s.Relay.FetchInbox(ctx, cfg, 50, "") - if err != nil { - return err - } - memberID := strings.TrimSpace(b.UserID) - if memberID == "" { - memberID = strings.TrimSpace(b.ID) - } - var ackIDs []string - var deliverErr error - delivered := 0 - for _, m := range msgs { - raw, ct, err := notification_bot.DecodePayload(m) - if err != nil { - if s.Log != nil { - s.Log.Warn("notification inbox decode skipped", "bot_id", b.ID, "msg_id", m.ID, "error", err) - } - continue - } - content := notification_bot.FormatPayloadAsChatContent(raw, ct, nil) - if err := s.Deliver.DeliverFanout(memberID, content); err != nil { - deliverErr = err - break - } - delivered++ - if id := strings.TrimSpace(m.ID); id != "" { - ackIDs = append(ackIDs, id) - } - } - if len(ackIDs) > 0 { - if err := s.Relay.Ack(ctx, cfg, ackIDs); err != nil { - return err - } - } - if s.Log != nil { - switch { - case len(msgs) == 0: - s.Log.Debug("notification pull ok", "bot_id", b.ID, "messages", 0) - case delivered > 0: - s.Log.Info("notification pull delivered", "bot_id", b.ID, "messages", delivered, "acked", len(ackIDs)) - case len(msgs) > 0 && delivered == 0: - s.Log.Warn("notification pull: inbox had messages but none delivered", "bot_id", b.ID, "inbox", len(msgs)) - } - } - return deliverErr -} diff --git a/internal/channel/csgclaw/notification_bot/view_test.go b/internal/channel/csgclaw/notification_bot/view_test.go deleted file mode 100644 index e26f4317..00000000 --- a/internal/channel/csgclaw/notification_bot/view_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package notification_bot - -import "testing" - -func TestStripViewOnlyRuntimeOptionKeys_nilEmpty(t *testing.T) { - t.Parallel() - if got := StripViewOnlyRuntimeOptionKeys(nil); got != nil { - t.Fatalf("nil: got %#v, want nil", got) - } - if got := StripViewOnlyRuntimeOptionKeys(map[string]any{}); got != nil { - t.Fatalf("empty: got %#v, want nil", got) - } -} - -func TestStripViewOnlyRuntimeOptionKeys_noViewKeys(t *testing.T) { - t.Parallel() - ext := map[string]any{"delivery_mode": "webhook"} - got := StripViewOnlyRuntimeOptionKeys(ext) - if got["delivery_mode"] != "webhook" || len(got) != 1 { - t.Fatalf("want delivery preserved, got %#v", got) - } -} - -func TestStripViewOnlyRuntimeOptionKeys_stripsNotificationProfile(t *testing.T) { - t.Parallel() - ext := map[string]any{ - "delivery_mode": "webhook", - RuntimeOptionKeyNotificationProfile: map[string]any{"delivery_complete": true}, - } - got := StripViewOnlyRuntimeOptionKeys(ext) - if _, ok := got[RuntimeOptionKeyNotificationProfile]; ok { - t.Fatalf("notification_profile should be removed, got %#v", got) - } - if got["delivery_mode"] != "webhook" { - t.Fatalf("delivery_mode: got %#v", got["delivery_mode"]) - } - if _, ok := ext[RuntimeOptionKeyNotificationProfile]; !ok { - t.Fatal("original map must be unchanged") - } -} diff --git a/internal/channel/csgclaw/service_test.go b/internal/channel/csgclaw/service_test.go index e15f9ec6..9a22ab20 100644 --- a/internal/channel/csgclaw/service_test.go +++ b/internal/channel/csgclaw/service_test.go @@ -16,9 +16,9 @@ func TestNewServiceWithNilIMReturnsNil(t *testing.T) { func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-manager", + CurrentUserID: "manager", Users: []im.User{ - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}, {ID: "u-alice", Name: "alice", Handle: "alice", Role: "worker"}, {ID: "u-bob", Name: "bob", Handle: "bob", Role: "worker"}, }, @@ -27,7 +27,7 @@ func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { room, err := svc.CreateRoom(apitypes.CreateRoomRequest{ Title: "Ops", - CreatorID: " u-manager ", + CreatorID: " manager ", MemberIDs: []string{ " u-alice ", }, @@ -35,11 +35,11 @@ func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { if err != nil { t.Fatalf("CreateRoom() error = %v", err) } - assertMembers(t, room.Members, "u-manager", "u-alice") + assertMembers(t, room.Members, "manager", "u-alice") room, err = svc.AddRoomMembers(apitypes.AddRoomMembersRequest{ RoomID: room.ID, - InviterID: " u-manager ", + InviterID: " manager ", UserIDs: []string{ " u-bob ", }, @@ -47,19 +47,19 @@ func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { if err != nil { t.Fatalf("AddRoomMembers() error = %v", err) } - assertMembers(t, room.Members, "u-manager", "u-alice", "u-bob") + assertMembers(t, room.Members, "manager", "u-alice", "u-bob") message, err := svc.SendMessage(apitypes.CreateMessageRequest{ RoomID: room.ID, - SenderID: " u-manager ", + SenderID: " manager ", MentionID: " u-alice ", Content: "hello", }) if err != nil { t.Fatalf("SendMessage() error = %v", err) } - if message.SenderID != "u-manager" { - t.Fatalf("SenderID = %q, want %q", message.SenderID, "u-manager") + if message.SenderID != "manager" { + t.Fatalf("SenderID = %q, want %q", message.SenderID, "manager") } if !strings.Contains(message.Content, "u-alice") { t.Fatalf("Content = %q, want mention tag for u-alice", message.Content) @@ -91,18 +91,18 @@ func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { func TestServiceNormalizesCanonicalSlashCommand(t *testing.T) { imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-manager", + CurrentUserID: "manager", Users: []im.User{ - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}, {ID: "u-alice", Name: "alice", Handle: "alice", Role: "worker"}, }, - Rooms: []im.Room{{ID: "room-1", Title: "Direct", Members: []string{"u-manager", "u-alice"}}}, + Rooms: []im.Room{{ID: "room-1", Title: "Direct", Members: []string{"manager", "u-alice"}}}, }) svc := NewService(imSvc) message, err := svc.SendMessage(apitypes.CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: ` create one `, }) if err != nil { @@ -116,15 +116,15 @@ func TestServiceNormalizesCanonicalSlashCommand(t *testing.T) { func TestServiceKeepsLegacySlashTextAsPlainContent(t *testing.T) { imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-manager", - Users: []im.User{{ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}}, - Rooms: []im.Room{{ID: "room-1", Title: "Direct", Members: []string{"u-manager"}}}, + CurrentUserID: "manager", + Users: []im.User{{ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}}, + Rooms: []im.Room{{ID: "room-1", Title: "Direct", Members: []string{"manager"}}}, }) svc := NewService(imSvc) message, err := svc.SendMessage(apitypes.CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: `/skill-creator create one`, }) if err != nil { diff --git a/internal/im/bot_bridge.go b/internal/im/bot_bridge.go deleted file mode 100644 index 61a2be60..00000000 --- a/internal/im/bot_bridge.go +++ /dev/null @@ -1,565 +0,0 @@ -package im - -import ( - "encoding/json" - "fmt" - "strings" - "sync" -) - -type BotBridge struct { - mu sync.Mutex - subscribers map[string]map[chan BotEvent]struct{} - pending map[string][]BotEvent - inflight map[string]map[string]BotEvent - seen map[string]map[string]struct{} -} - -const maxPendingBotEventsPerBot = 64 - -type BotEvent struct { - MessageID string `json:"message_id"` - RoomID string `json:"room_id"` - Channel string `json:"channel,omitempty"` - ChatID string `json:"chat_id,omitempty"` - ChatType string `json:"chat_type"` - Sender BotSender `json:"sender"` - SenderID string `json:"sender_id,omitempty"` - Text string `json:"text"` - Timestamp string `json:"timestamp"` - Mentions []string `json:"mentions,omitempty"` - ThreadRootID string `json:"thread_root_id,omitempty"` - ThreadContext *BotThreadContext `json:"thread_context,omitempty"` - Context BotMessageContext `json:"context,omitempty"` -} - -type BotSender struct { - ID string `json:"id"` - Username string `json:"username,omitempty"` - DisplayName string `json:"display_name,omitempty"` -} - -type BotMessageContext struct { - Channel string `json:"channel,omitempty"` - Account string `json:"account,omitempty"` - ChatID string `json:"chat_id,omitempty"` - ChatType string `json:"chat_type,omitempty"` - TopicID string `json:"topic_id,omitempty"` - SpaceID string `json:"space_id,omitempty"` - SpaceType string `json:"space_type,omitempty"` - SenderID string `json:"sender_id,omitempty"` - MessageID string `json:"message_id,omitempty"` - Mentioned bool `json:"mentioned,omitempty"` - ReplyToMessageID string `json:"reply_to_message_id,omitempty"` - ReplyToSenderID string `json:"reply_to_sender_id,omitempty"` - ReplyHandles map[string]string `json:"reply_handles,omitempty"` - Raw map[string]string `json:"raw,omitempty"` -} - -type BotThreadContext struct { - RootMessageID string `json:"root_message_id"` - Context []Message `json:"context,omitempty"` - Summary ThreadContextSummary `json:"summary"` -} - -type BotSendMessageRequest struct { - RoomID string `json:"room_id"` - ChatID string `json:"chat_id,omitempty"` - Text string `json:"text"` - Content string `json:"content,omitempty"` - MessageID string `json:"message_id,omitempty"` - ThreadRootID string `json:"thread_root_id,omitempty"` - TopicID string `json:"topic_id,omitempty"` - Context *BotMessageContext `json:"context,omitempty"` -} - -func (r BotSendMessageRequest) ResolvedRoomID() string { - if roomID := strings.TrimSpace(r.RoomID); roomID != "" { - return roomID - } - if chatID := strings.TrimSpace(r.ChatID); chatID != "" { - return chatID - } - if r.Context != nil { - return strings.TrimSpace(r.Context.ChatID) - } - return "" -} - -func (r BotSendMessageRequest) ResolvedText() string { - if text := strings.TrimSpace(r.Text); text != "" { - return r.Text - } - if content := strings.TrimSpace(r.Content); content != "" { - return r.Content - } - return "" -} - -func (r BotSendMessageRequest) ResolvedThreadRootID() string { - if rootID := strings.TrimSpace(r.ThreadRootID); rootID != "" { - return rootID - } - if topicID := strings.TrimSpace(r.TopicID); topicID != "" { - return topicID - } - if r.Context != nil { - return strings.TrimSpace(r.Context.TopicID) - } - return "" -} - -func NewBotBridge(string) *BotBridge { - return &BotBridge{ - subscribers: make(map[string]map[chan BotEvent]struct{}), - pending: make(map[string][]BotEvent), - inflight: make(map[string]map[string]BotEvent), - seen: make(map[string]map[string]struct{}), - } -} - -func (b *BotBridge) Subscribe(botID string) (<-chan BotEvent, func()) { - ch := make(chan BotEvent, 16) - - b.mu.Lock() - if b.subscribers[botID] == nil { - b.subscribers[botID] = make(map[chan BotEvent]struct{}) - } - b.subscribers[botID][ch] = struct{}{} - pending := append([]BotEvent(nil), b.pending[botID]...) - delete(b.pending, botID) - - for _, evt := range pending { - select { - case ch <- evt: - b.markInflightLocked(botID, evt) - default: - b.addPendingLocked(botID, evt) - } - } - b.mu.Unlock() - - var cancelOnce sync.Once - cancel := func() { - cancelOnce.Do(func() { - b.mu.Lock() - if subs, ok := b.subscribers[botID]; ok { - delete(subs, ch) - if len(subs) == 0 { - delete(b.subscribers, botID) - } - } - b.mu.Unlock() - close(ch) - }) - } - return ch, cancel -} - -func (b *BotBridge) SubscriberCount(botID string) int { - b.mu.Lock() - defer b.mu.Unlock() - return len(b.subscribers[botID]) -} - -func (b *BotBridge) PublishMessageEvent(room Room, sender User, message Message) []string { - var missed []string - for _, botID := range room.Members { - if !shouldNotifyBot(room, message, botID) { - continue - } - if !b.EnqueueMessageEvent(room, sender, message, botID) { - missed = append(missed, botID) - } - } - return missed -} - -func (b *BotBridge) EnqueueMessageEvent(room Room, sender User, message Message, botID string) bool { - if !shouldNotifyBot(room, message, botID) { - return true - } - return b.enqueue(botID, messageEventForBot(room, sender, message, botID)) -} - -func (b *BotBridge) EnqueueMessageEventWithText(room Room, sender User, message Message, botID string, text string) bool { - if !shouldNotifyBot(room, message, botID) { - return true - } - evt := messageEventForBot(room, sender, message, botID) - evt.Text = botActionTextForEvent(message, botID, text) - if messageMentionsBot(message, botID) { - ensureBotMentioned(&evt, botID) - } - return b.enqueue(botID, evt) -} - -func (b *BotBridge) enqueue(botID string, evt BotEvent) bool { - b.mu.Lock() - defer b.mu.Unlock() - if b.hasSeenOrInflightLocked(botID, evt.MessageID) { - return true - } - subs := b.subscribers[botID] - if len(subs) == 0 { - b.addPendingLocked(botID, evt) - return false - } - - sent := false - for ch := range subs { - select { - case ch <- evt: - sent = true - default: - } - } - if sent { - b.markInflightLocked(botID, evt) - return true - } - b.addPendingLocked(botID, evt) - return false -} - -func (b *BotBridge) Ack(botID, messageID string) { - botID = strings.TrimSpace(botID) - messageID = strings.TrimSpace(messageID) - if botID == "" || messageID == "" { - return - } - b.mu.Lock() - defer b.mu.Unlock() - if inflight := b.inflight[botID]; inflight != nil { - delete(inflight, messageID) - if len(inflight) == 0 { - delete(b.inflight, botID) - } - } - b.removePendingLocked(botID, messageID) - b.markSeenLocked(botID, messageID) -} - -func (b *BotBridge) Requeue(botID string, evt BotEvent) { - botID = strings.TrimSpace(botID) - if botID == "" { - return - } - b.mu.Lock() - defer b.mu.Unlock() - if messageID := strings.TrimSpace(evt.MessageID); messageID != "" { - if inflight := b.inflight[botID]; inflight != nil { - delete(inflight, messageID) - if len(inflight) == 0 { - delete(b.inflight, botID) - } - } - } - b.addPendingLocked(botID, evt) -} - -func (b *BotBridge) addPendingLocked(botID string, evt BotEvent) { - if b.hasSeenOrInflightLocked(botID, evt.MessageID) || b.hasPendingLocked(botID, evt.MessageID) { - return - } - pending := append(b.pending[botID], evt) - if len(pending) > maxPendingBotEventsPerBot { - pending = pending[len(pending)-maxPendingBotEventsPerBot:] - } - b.pending[botID] = pending -} - -func (b *BotBridge) markInflightLocked(botID string, evt BotEvent) { - messageID := strings.TrimSpace(evt.MessageID) - if messageID == "" || b.hasSeenLocked(botID, messageID) { - return - } - if b.inflight[botID] == nil { - b.inflight[botID] = make(map[string]BotEvent) - } - b.inflight[botID][messageID] = evt - b.removePendingLocked(botID, messageID) -} - -func (b *BotBridge) hasSeenOrInflightLocked(botID, messageID string) bool { - messageID = strings.TrimSpace(messageID) - if messageID == "" { - return false - } - if b.hasSeenLocked(botID, messageID) { - return true - } - _, ok := b.inflight[botID][messageID] - return ok -} - -func (b *BotBridge) hasPendingLocked(botID, messageID string) bool { - messageID = strings.TrimSpace(messageID) - if messageID == "" { - return false - } - for _, evt := range b.pending[botID] { - if strings.TrimSpace(evt.MessageID) == messageID { - return true - } - } - return false -} - -func (b *BotBridge) removePendingLocked(botID, messageID string) { - messageID = strings.TrimSpace(messageID) - if messageID == "" { - return - } - pending := b.pending[botID] - for idx := 0; idx < len(pending); { - if strings.TrimSpace(pending[idx].MessageID) == messageID { - pending = append(pending[:idx], pending[idx+1:]...) - continue - } - idx++ - } - if len(pending) == 0 { - delete(b.pending, botID) - return - } - b.pending[botID] = pending -} - -func (b *BotBridge) hasSeenLocked(botID, messageID string) bool { - if messageID == "" { - return false - } - _, ok := b.seen[botID][messageID] - return ok -} - -func (b *BotBridge) markSeenLocked(botID, messageID string) { - if messageID == "" { - return - } - if b.seen[botID] == nil { - b.seen[botID] = make(map[string]struct{}) - } - b.seen[botID][messageID] = struct{}{} -} - -func messageEventForBot(room Room, sender User, message Message, botID string) BotEvent { - threadRootID := threadRootID(message) - chatType := chatTypeForRoom(room) - mentions := mentionsForBot(message.Mentions, botID) - text := textForBotEvent(message, botID) - return BotEvent{ - MessageID: message.ID, - RoomID: room.ID, - Channel: "csgclaw", - ChatID: room.ID, - ChatType: chatType, - ThreadRootID: threadRootID, - Sender: BotSender{ - ID: sender.ID, - Username: sender.Handle, - DisplayName: sender.Name, - }, - SenderID: sender.ID, - Text: text, - Timestamp: fmt.Sprintf("%d", message.CreatedAt.UnixMilli()), - Mentions: mentions, - ThreadContext: botThreadContext(room, threadRootID), - Context: BotMessageContext{ - Channel: "csgclaw", - Account: strings.TrimSpace(botID), - ChatID: room.ID, - ChatType: chatType, - TopicID: threadRootID, - SenderID: sender.ID, - MessageID: message.ID, - Mentioned: len(mentions) > 0, - Raw: map[string]string{ - "room_id": room.ID, - "thread_root_id": threadRootID, - }, - }, - } -} - -func textForBotEvent(message Message, botID string) string { - content := message.Content - botID = strings.TrimSpace(botID) - if content == "" || botID == "" || HasMentionTagForUser(content, botID) { - return content - } - for _, mention := range message.Mentions { - if strings.TrimSpace(mention.ID) == botID { - return replaceMentionHandleWithTag(content, mention) - } - } - return content -} - -func botActionTextForEvent(message Message, botID, text string) string { - text = strings.TrimSpace(text) - botID = strings.TrimSpace(botID) - if text == "" || botID == "" || HasMentionTagForUser(text, botID) || !messageMentionsBot(message, botID) { - return text - } - return text + " " + mentionTagForBot(message, botID) -} - -func messageMentionsBot(message Message, botID string) bool { - botID = strings.TrimSpace(botID) - if botID == "" { - return false - } - for _, mention := range message.Mentions { - if strings.TrimSpace(mention.ID) == botID { - return true - } - } - return HasMentionTagForUser(message.Content, botID) -} - -func ensureBotMentioned(evt *BotEvent, botID string) { - if evt == nil { - return - } - botID = strings.TrimSpace(botID) - if botID == "" { - return - } - for _, mention := range evt.Mentions { - if strings.TrimSpace(mention) == botID { - evt.Context.Mentioned = true - return - } - } - evt.Mentions = append(evt.Mentions, botID) - evt.Context.Mentioned = true -} - -func mentionTagForBot(message Message, botID string) string { - for _, mention := range message.Mentions { - if strings.TrimSpace(mention.ID) == strings.TrimSpace(botID) { - return fmt.Sprintf(`%s`, strings.TrimSpace(botID), mentionDisplayName(mention)) - } - } - return fmt.Sprintf(`%s`, strings.TrimSpace(botID), strings.TrimSpace(botID)) -} - -func replaceMentionHandleWithTag(content string, mention Mention) string { - candidates := mentionHandleCandidates(mention) - if len(candidates) == 0 { - return content - } - tag := fmt.Sprintf(`%s`, strings.TrimSpace(mention.ID), mentionDisplayName(mention)) - matches := mentionPattern.FindAllStringSubmatchIndex(content, -1) - if len(matches) == 0 { - return content - } - - var out strings.Builder - last := 0 - replaced := false - for _, match := range matches { - if len(match) < 6 || match[4] < 0 || match[5] < 0 { - continue - } - handle := strings.ToLower(strings.TrimSpace(content[match[4]:match[5]])) - if _, ok := candidates[handle]; !ok { - continue - } - replaced = true - out.WriteString(content[last:match[0]]) - if match[2] >= 0 && match[3] >= 0 { - out.WriteString(content[match[2]:match[3]]) - } - out.WriteString(tag) - last = match[1] - } - if !replaced { - return content - } - out.WriteString(content[last:]) - return out.String() -} - -func mentionHandleCandidates(mention Mention) map[string]struct{} { - candidates := make(map[string]struct{}, 2) - if name := normalizeMentionHandle(mention.Name); name != "" { - candidates[name] = struct{}{} - } - if idHandle := strings.TrimPrefix(strings.TrimSpace(mention.ID), "u-"); idHandle != "" { - candidates[strings.ToLower(idHandle)] = struct{}{} - } - return candidates -} - -func normalizeMentionHandle(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - value = strings.TrimPrefix(value, "@") - return value -} - -func mentionDisplayName(mention Mention) string { - if name := strings.TrimSpace(mention.Name); name != "" { - return name - } - if id := strings.TrimSpace(mention.ID); id != "" { - return id - } - return "user" -} - -func botThreadContext(room Room, rootMessageID string) *BotThreadContext { - rootMessageID = strings.TrimSpace(rootMessageID) - if rootMessageID == "" { - return nil - } - state, ok := threadStateByRoot(room.Threads, rootMessageID) - if !ok { - return nil - } - return &BotThreadContext{ - RootMessageID: rootMessageID, - Context: cloneMessages(state.Context), - Summary: state.Summary, - } -} - -func (e BotEvent) MarshalJSONLine() ([]byte, error) { - data, err := json.Marshal(e) - if err != nil { - return nil, err - } - return data, nil -} - -func shouldNotifyBot(room Room, message Message, botID string) bool { - if message.SenderID == botID { - return false - } - if !containsUserIDInRoom(room, botID) { - return false - } - return true -} - -func mentionsForBot(mentions []Mention, botID string) []string { - if len(mentions) == 0 { - return nil - } - result := make([]string, 0, len(mentions)) - for _, mention := range mentions { - if mention.ID == botID { - result = append(result, mention.ID) - } - } - return result -} - -func chatTypeForRoom(room Room) string { - if room.IsDirect { - return "direct" - } - return "group" -} diff --git a/internal/im/deliver_message_bus_test.go b/internal/im/deliver_message_bus_test.go index 04606c8a..af4d18e7 100644 --- a/internal/im/deliver_message_bus_test.go +++ b/internal/im/deliver_message_bus_test.go @@ -10,13 +10,13 @@ func TestDeliverMessagePublishesMessageCreatedEvent(t *testing.T) { svc := NewServiceFromBootstrapWithBus(Bootstrap{ CurrentUserID: "u-admin", Users: []User{ - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}, {ID: "u-p-w-0604", Name: "worker", Handle: "p-w-0604", Role: "worker"}, }, Rooms: []Room{{ ID: "room-1", Title: "task room", - Members: []string{"u-manager", "u-p-w-0604"}, + Members: []string{"manager", "u-p-w-0604"}, }}, }, bus) @@ -25,7 +25,7 @@ func TestDeliverMessagePublishesMessageCreatedEvent(t *testing.T) { _, err := svc.DeliverMessage(DeliverMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", MentionID: "u-p-w-0604", Content: "[team] Task task-17 is ready for you", }) diff --git a/internal/im/participant_bridge.go b/internal/im/participant_bridge.go new file mode 100644 index 00000000..f93e8dd1 --- /dev/null +++ b/internal/im/participant_bridge.go @@ -0,0 +1,565 @@ +package im + +import ( + "encoding/json" + "fmt" + "strings" + "sync" +) + +type ParticipantBridge struct { + mu sync.Mutex + subscribers map[string]map[chan ParticipantEvent]struct{} + pending map[string][]ParticipantEvent + inflight map[string]map[string]ParticipantEvent + seen map[string]map[string]struct{} +} + +const maxPendingParticipantEventsPerParticipant = 64 + +type ParticipantEvent struct { + MessageID string `json:"message_id"` + RoomID string `json:"room_id"` + Channel string `json:"channel,omitempty"` + ChatID string `json:"chat_id,omitempty"` + ChatType string `json:"chat_type"` + Sender ParticipantSender `json:"sender"` + SenderID string `json:"sender_id,omitempty"` + Text string `json:"text"` + Timestamp string `json:"timestamp"` + Mentions []string `json:"mentions,omitempty"` + ThreadRootID string `json:"thread_root_id,omitempty"` + ThreadContext *ParticipantThreadContext `json:"thread_context,omitempty"` + Context ParticipantMessageContext `json:"context,omitempty"` +} + +type ParticipantSender struct { + ID string `json:"id"` + Username string `json:"username,omitempty"` + DisplayName string `json:"display_name,omitempty"` +} + +type ParticipantMessageContext struct { + Channel string `json:"channel,omitempty"` + Account string `json:"account,omitempty"` + ChatID string `json:"chat_id,omitempty"` + ChatType string `json:"chat_type,omitempty"` + TopicID string `json:"topic_id,omitempty"` + SpaceID string `json:"space_id,omitempty"` + SpaceType string `json:"space_type,omitempty"` + SenderID string `json:"sender_id,omitempty"` + MessageID string `json:"message_id,omitempty"` + Mentioned bool `json:"mentioned,omitempty"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ReplyToSenderID string `json:"reply_to_sender_id,omitempty"` + ReplyHandles map[string]string `json:"reply_handles,omitempty"` + Raw map[string]string `json:"raw,omitempty"` +} + +type ParticipantThreadContext struct { + RootMessageID string `json:"root_message_id"` + Context []Message `json:"context,omitempty"` + Summary ThreadContextSummary `json:"summary"` +} + +type ParticipantSendMessageRequest struct { + RoomID string `json:"room_id"` + ChatID string `json:"chat_id,omitempty"` + Text string `json:"text"` + Content string `json:"content,omitempty"` + MessageID string `json:"message_id,omitempty"` + ThreadRootID string `json:"thread_root_id,omitempty"` + TopicID string `json:"topic_id,omitempty"` + Context *ParticipantMessageContext `json:"context,omitempty"` +} + +func (r ParticipantSendMessageRequest) ResolvedRoomID() string { + if roomID := strings.TrimSpace(r.RoomID); roomID != "" { + return roomID + } + if chatID := strings.TrimSpace(r.ChatID); chatID != "" { + return chatID + } + if r.Context != nil { + return strings.TrimSpace(r.Context.ChatID) + } + return "" +} + +func (r ParticipantSendMessageRequest) ResolvedText() string { + if text := strings.TrimSpace(r.Text); text != "" { + return r.Text + } + if content := strings.TrimSpace(r.Content); content != "" { + return r.Content + } + return "" +} + +func (r ParticipantSendMessageRequest) ResolvedThreadRootID() string { + if rootID := strings.TrimSpace(r.ThreadRootID); rootID != "" { + return rootID + } + if topicID := strings.TrimSpace(r.TopicID); topicID != "" { + return topicID + } + if r.Context != nil { + return strings.TrimSpace(r.Context.TopicID) + } + return "" +} + +func NewParticipantBridge(string) *ParticipantBridge { + return &ParticipantBridge{ + subscribers: make(map[string]map[chan ParticipantEvent]struct{}), + pending: make(map[string][]ParticipantEvent), + inflight: make(map[string]map[string]ParticipantEvent), + seen: make(map[string]map[string]struct{}), + } +} + +func (b *ParticipantBridge) Subscribe(participantID string) (<-chan ParticipantEvent, func()) { + ch := make(chan ParticipantEvent, 16) + + b.mu.Lock() + if b.subscribers[participantID] == nil { + b.subscribers[participantID] = make(map[chan ParticipantEvent]struct{}) + } + b.subscribers[participantID][ch] = struct{}{} + pending := append([]ParticipantEvent(nil), b.pending[participantID]...) + delete(b.pending, participantID) + + for _, evt := range pending { + select { + case ch <- evt: + b.markInflightLocked(participantID, evt) + default: + b.addPendingLocked(participantID, evt) + } + } + b.mu.Unlock() + + var cancelOnce sync.Once + cancel := func() { + cancelOnce.Do(func() { + b.mu.Lock() + if subs, ok := b.subscribers[participantID]; ok { + delete(subs, ch) + if len(subs) == 0 { + delete(b.subscribers, participantID) + } + } + b.mu.Unlock() + close(ch) + }) + } + return ch, cancel +} + +func (b *ParticipantBridge) SubscriberCount(participantID string) int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.subscribers[participantID]) +} + +func (b *ParticipantBridge) PublishMessageEvent(room Room, sender User, message Message) []string { + var missed []string + for _, participantID := range room.Members { + if !shouldNotifyParticipant(room, message, participantID) { + continue + } + if !b.EnqueueMessageEvent(room, sender, message, participantID) { + missed = append(missed, participantID) + } + } + return missed +} + +func (b *ParticipantBridge) EnqueueMessageEvent(room Room, sender User, message Message, participantID string) bool { + if !shouldNotifyParticipant(room, message, participantID) { + return true + } + return b.enqueue(participantID, messageEventForParticipant(room, sender, message, participantID)) +} + +func (b *ParticipantBridge) EnqueueMessageEventWithText(room Room, sender User, message Message, participantID string, text string) bool { + if !shouldNotifyParticipant(room, message, participantID) { + return true + } + evt := messageEventForParticipant(room, sender, message, participantID) + evt.Text = participantActionTextForEvent(message, participantID, text) + if messageMentionsParticipant(message, participantID) { + ensureParticipantMentioned(&evt, participantID) + } + return b.enqueue(participantID, evt) +} + +func (b *ParticipantBridge) enqueue(participantID string, evt ParticipantEvent) bool { + b.mu.Lock() + defer b.mu.Unlock() + if b.hasSeenOrInflightLocked(participantID, evt.MessageID) { + return true + } + subs := b.subscribers[participantID] + if len(subs) == 0 { + b.addPendingLocked(participantID, evt) + return false + } + + sent := false + for ch := range subs { + select { + case ch <- evt: + sent = true + default: + } + } + if sent { + b.markInflightLocked(participantID, evt) + return true + } + b.addPendingLocked(participantID, evt) + return false +} + +func (b *ParticipantBridge) Ack(participantID, messageID string) { + participantID = strings.TrimSpace(participantID) + messageID = strings.TrimSpace(messageID) + if participantID == "" || messageID == "" { + return + } + b.mu.Lock() + defer b.mu.Unlock() + if inflight := b.inflight[participantID]; inflight != nil { + delete(inflight, messageID) + if len(inflight) == 0 { + delete(b.inflight, participantID) + } + } + b.removePendingLocked(participantID, messageID) + b.markSeenLocked(participantID, messageID) +} + +func (b *ParticipantBridge) Requeue(participantID string, evt ParticipantEvent) { + participantID = strings.TrimSpace(participantID) + if participantID == "" { + return + } + b.mu.Lock() + defer b.mu.Unlock() + if messageID := strings.TrimSpace(evt.MessageID); messageID != "" { + if inflight := b.inflight[participantID]; inflight != nil { + delete(inflight, messageID) + if len(inflight) == 0 { + delete(b.inflight, participantID) + } + } + } + b.addPendingLocked(participantID, evt) +} + +func (b *ParticipantBridge) addPendingLocked(participantID string, evt ParticipantEvent) { + if b.hasSeenOrInflightLocked(participantID, evt.MessageID) || b.hasPendingLocked(participantID, evt.MessageID) { + return + } + pending := append(b.pending[participantID], evt) + if len(pending) > maxPendingParticipantEventsPerParticipant { + pending = pending[len(pending)-maxPendingParticipantEventsPerParticipant:] + } + b.pending[participantID] = pending +} + +func (b *ParticipantBridge) markInflightLocked(participantID string, evt ParticipantEvent) { + messageID := strings.TrimSpace(evt.MessageID) + if messageID == "" || b.hasSeenLocked(participantID, messageID) { + return + } + if b.inflight[participantID] == nil { + b.inflight[participantID] = make(map[string]ParticipantEvent) + } + b.inflight[participantID][messageID] = evt + b.removePendingLocked(participantID, messageID) +} + +func (b *ParticipantBridge) hasSeenOrInflightLocked(participantID, messageID string) bool { + messageID = strings.TrimSpace(messageID) + if messageID == "" { + return false + } + if b.hasSeenLocked(participantID, messageID) { + return true + } + _, ok := b.inflight[participantID][messageID] + return ok +} + +func (b *ParticipantBridge) hasPendingLocked(participantID, messageID string) bool { + messageID = strings.TrimSpace(messageID) + if messageID == "" { + return false + } + for _, evt := range b.pending[participantID] { + if strings.TrimSpace(evt.MessageID) == messageID { + return true + } + } + return false +} + +func (b *ParticipantBridge) removePendingLocked(participantID, messageID string) { + messageID = strings.TrimSpace(messageID) + if messageID == "" { + return + } + pending := b.pending[participantID] + for idx := 0; idx < len(pending); { + if strings.TrimSpace(pending[idx].MessageID) == messageID { + pending = append(pending[:idx], pending[idx+1:]...) + continue + } + idx++ + } + if len(pending) == 0 { + delete(b.pending, participantID) + return + } + b.pending[participantID] = pending +} + +func (b *ParticipantBridge) hasSeenLocked(participantID, messageID string) bool { + if messageID == "" { + return false + } + _, ok := b.seen[participantID][messageID] + return ok +} + +func (b *ParticipantBridge) markSeenLocked(participantID, messageID string) { + if messageID == "" { + return + } + if b.seen[participantID] == nil { + b.seen[participantID] = make(map[string]struct{}) + } + b.seen[participantID][messageID] = struct{}{} +} + +func messageEventForParticipant(room Room, sender User, message Message, participantID string) ParticipantEvent { + threadRootID := threadRootID(message) + chatType := chatTypeForRoom(room) + mentions := mentionsForParticipant(message.Mentions, participantID) + text := textForParticipantEvent(message, participantID) + return ParticipantEvent{ + MessageID: message.ID, + RoomID: room.ID, + Channel: "csgclaw", + ChatID: room.ID, + ChatType: chatType, + ThreadRootID: threadRootID, + Sender: ParticipantSender{ + ID: sender.ID, + Username: sender.Handle, + DisplayName: sender.Name, + }, + SenderID: sender.ID, + Text: text, + Timestamp: fmt.Sprintf("%d", message.CreatedAt.UnixMilli()), + Mentions: mentions, + ThreadContext: participantThreadContext(room, threadRootID), + Context: ParticipantMessageContext{ + Channel: "csgclaw", + Account: strings.TrimSpace(participantID), + ChatID: room.ID, + ChatType: chatType, + TopicID: threadRootID, + SenderID: sender.ID, + MessageID: message.ID, + Mentioned: len(mentions) > 0, + Raw: map[string]string{ + "room_id": room.ID, + "thread_root_id": threadRootID, + }, + }, + } +} + +func textForParticipantEvent(message Message, participantID string) string { + content := message.Content + participantID = strings.TrimSpace(participantID) + if content == "" || participantID == "" || HasMentionTagForUser(content, participantID) { + return content + } + for _, mention := range message.Mentions { + if strings.TrimSpace(mention.ID) == participantID { + return replaceMentionHandleWithTag(content, mention) + } + } + return content +} + +func participantActionTextForEvent(message Message, participantID, text string) string { + text = strings.TrimSpace(text) + participantID = strings.TrimSpace(participantID) + if text == "" || participantID == "" || HasMentionTagForUser(text, participantID) || !messageMentionsParticipant(message, participantID) { + return text + } + return text + " " + mentionTagForParticipant(message, participantID) +} + +func messageMentionsParticipant(message Message, participantID string) bool { + participantID = strings.TrimSpace(participantID) + if participantID == "" { + return false + } + for _, mention := range message.Mentions { + if strings.TrimSpace(mention.ID) == participantID { + return true + } + } + return HasMentionTagForUser(message.Content, participantID) +} + +func ensureParticipantMentioned(evt *ParticipantEvent, participantID string) { + if evt == nil { + return + } + participantID = strings.TrimSpace(participantID) + if participantID == "" { + return + } + for _, mention := range evt.Mentions { + if strings.TrimSpace(mention) == participantID { + evt.Context.Mentioned = true + return + } + } + evt.Mentions = append(evt.Mentions, participantID) + evt.Context.Mentioned = true +} + +func mentionTagForParticipant(message Message, participantID string) string { + for _, mention := range message.Mentions { + if strings.TrimSpace(mention.ID) == strings.TrimSpace(participantID) { + return fmt.Sprintf(`%s`, strings.TrimSpace(participantID), mentionDisplayName(mention)) + } + } + return fmt.Sprintf(`%s`, strings.TrimSpace(participantID), strings.TrimSpace(participantID)) +} + +func replaceMentionHandleWithTag(content string, mention Mention) string { + candidates := mentionHandleCandidates(mention) + if len(candidates) == 0 { + return content + } + tag := fmt.Sprintf(`%s`, strings.TrimSpace(mention.ID), mentionDisplayName(mention)) + matches := mentionPattern.FindAllStringSubmatchIndex(content, -1) + if len(matches) == 0 { + return content + } + + var out strings.Builder + last := 0 + replaced := false + for _, match := range matches { + if len(match) < 6 || match[4] < 0 || match[5] < 0 { + continue + } + handle := strings.ToLower(strings.TrimSpace(content[match[4]:match[5]])) + if _, ok := candidates[handle]; !ok { + continue + } + replaced = true + out.WriteString(content[last:match[0]]) + if match[2] >= 0 && match[3] >= 0 { + out.WriteString(content[match[2]:match[3]]) + } + out.WriteString(tag) + last = match[1] + } + if !replaced { + return content + } + out.WriteString(content[last:]) + return out.String() +} + +func mentionHandleCandidates(mention Mention) map[string]struct{} { + candidates := make(map[string]struct{}, 2) + if name := normalizeMentionHandle(mention.Name); name != "" { + candidates[name] = struct{}{} + } + if idHandle := strings.TrimPrefix(strings.TrimSpace(mention.ID), "u-"); idHandle != "" { + candidates[strings.ToLower(idHandle)] = struct{}{} + } + return candidates +} + +func normalizeMentionHandle(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.TrimPrefix(value, "@") + return value +} + +func mentionDisplayName(mention Mention) string { + if name := strings.TrimSpace(mention.Name); name != "" { + return name + } + if id := strings.TrimSpace(mention.ID); id != "" { + return id + } + return "user" +} + +func participantThreadContext(room Room, rootMessageID string) *ParticipantThreadContext { + rootMessageID = strings.TrimSpace(rootMessageID) + if rootMessageID == "" { + return nil + } + state, ok := threadStateByRoot(room.Threads, rootMessageID) + if !ok { + return nil + } + return &ParticipantThreadContext{ + RootMessageID: rootMessageID, + Context: cloneMessages(state.Context), + Summary: state.Summary, + } +} + +func (e ParticipantEvent) MarshalJSONLine() ([]byte, error) { + data, err := json.Marshal(e) + if err != nil { + return nil, err + } + return data, nil +} + +func shouldNotifyParticipant(room Room, message Message, participantID string) bool { + if message.SenderID == participantID { + return false + } + if !containsUserIDInRoom(room, participantID) { + return false + } + return true +} + +func mentionsForParticipant(mentions []Mention, participantID string) []string { + if len(mentions) == 0 { + return nil + } + result := make([]string, 0, len(mentions)) + for _, mention := range mentions { + if mention.ID == participantID { + result = append(result, mention.ID) + } + } + return result +} + +func chatTypeForRoom(room Room) string { + if room.IsDirect { + return "direct" + } + return "group" +} diff --git a/internal/im/bot_bridge_test.go b/internal/im/participant_bridge_test.go similarity index 92% rename from internal/im/bot_bridge_test.go rename to internal/im/participant_bridge_test.go index 39d65a25..0cca316b 100644 --- a/internal/im/bot_bridge_test.go +++ b/internal/im/participant_bridge_test.go @@ -40,7 +40,7 @@ func TestChatTypeForRoomRespectsIsDirect(t *testing.T) { } } -func TestShouldNotifyBotPushesForTwoMemberGroupWithoutMention(t *testing.T) { +func TestShouldNotifyParticipantPushesForTwoMemberGroupWithoutMention(t *testing.T) { room := Room{ ID: "room-group", IsDirect: false, @@ -54,13 +54,13 @@ func TestShouldNotifyBotPushesForTwoMemberGroupWithoutMention(t *testing.T) { CreatedAt: time.Now().UTC(), } - if !shouldNotifyBot(room, message, "u-bot") { - t.Fatal("shouldNotifyBot() = false, want true for room member without mention") + if !shouldNotifyParticipant(room, message, "u-bot") { + t.Fatal("shouldNotifyParticipant() = false, want true for room member without mention") } } func TestPublishMessageEventUsesGroupChatTypeForTwoMemberGroup(t *testing.T) { - bridge := NewBotBridge("") + bridge := NewParticipantBridge("") events, cancel := bridge.Subscribe("u-bot") defer cancel() @@ -90,7 +90,7 @@ func TestPublishMessageEventUsesGroupChatTypeForTwoMemberGroup(t *testing.T) { } func TestPublishMessageEventUsesSlashContentVerbatim(t *testing.T) { - bridge := NewBotBridge("") + bridge := NewParticipantBridge("") events, cancel := bridge.Subscribe("u-bot") defer cancel() @@ -120,7 +120,7 @@ func TestPublishMessageEventUsesSlashContentVerbatim(t *testing.T) { } func TestPublishMessageEventIncludesThreadRootAndContext(t *testing.T) { - bridge := NewBotBridge("") + bridge := NewParticipantBridge("") events, cancel := bridge.Subscribe("u-bot") defer cancel() @@ -187,7 +187,7 @@ func TestPublishMessageEventIncludesThreadRootAndContext(t *testing.T) { } func TestPublishMessageEventNormalizesPlainThreadMentionForPicoClaw(t *testing.T) { - bridge := NewBotBridge("") + bridge := NewParticipantBridge("") events, cancel := bridge.Subscribe("u-qa") defer cancel() @@ -239,7 +239,7 @@ func TestPublishMessageEventNormalizesPlainThreadMentionForPicoClaw(t *testing.T } func TestEnqueueMessageEventWithTextKeepsGroupMentionVisible(t *testing.T) { - bridge := NewBotBridge("") + bridge := NewParticipantBridge("") events, cancel := bridge.Subscribe("u-qa") defer cancel() @@ -258,7 +258,7 @@ func TestEnqueueMessageEventWithTextKeepsGroupMentionVisible(t *testing.T) { } if !bridge.EnqueueMessageEventWithText(room, sender, message, "u-qa", "/clear") { - t.Fatal("EnqueueMessageEventWithText() = false, want true for subscribed bot") + t.Fatal("EnqueueMessageEventWithText() = false, want true for subscribed participant") } select { @@ -274,8 +274,8 @@ func TestEnqueueMessageEventWithTextKeepsGroupMentionVisible(t *testing.T) { } } -func TestPublishMessageEventQueuesUntilBotSubscribes(t *testing.T) { - bridge := NewBotBridge("") +func TestPublishMessageEventQueuesUntilParticipantSubscribes(t *testing.T) { + bridge := NewParticipantBridge("") room := Room{ ID: "room-direct", IsDirect: true, @@ -307,8 +307,8 @@ func TestPublishMessageEventQueuesUntilBotSubscribes(t *testing.T) { } } -func TestBotBridgeAckPreventsDuplicateReplay(t *testing.T) { - bridge := NewBotBridge("") +func TestParticipantBridgeAckPreventsDuplicateReplay(t *testing.T) { + bridge := NewParticipantBridge("") events, cancel := bridge.Subscribe("u-bot") defer cancel() @@ -341,8 +341,8 @@ func TestBotBridgeAckPreventsDuplicateReplay(t *testing.T) { } } -func TestBotBridgeRequeueDeliversUnackedEventAgain(t *testing.T) { - bridge := NewBotBridge("") +func TestParticipantBridgeRequeueDeliversUnackedEventAgain(t *testing.T) { + bridge := NewParticipantBridge("") events, cancel := bridge.Subscribe("u-bot") room := Room{ @@ -359,7 +359,7 @@ func TestBotBridgeRequeueDeliversUnackedEventAgain(t *testing.T) { } bridge.PublishMessageEvent(room, sender, message) - var got BotEvent + var got ParticipantEvent select { case got = <-events: case <-time.After(time.Second): diff --git a/internal/im/service.go b/internal/im/service.go index bcc0aa40..6b637163 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -158,7 +158,12 @@ func HasMentionTagForUser(content, userID string) bool { return false } -const sessionsDirName = "sessions" +const ( + sessionsDirName = "sessions" + adminUserID = "u-admin" + managerParticipantUserID = "manager" + legacyManagerUserID = "u-manager" +) type persistedBootstrap struct { CurrentUserID string `json:"current_user_id"` @@ -509,10 +514,14 @@ func normalizeBootstrap(state Bootstrap) Bootstrap { if state.CurrentUserID == "" { state.CurrentUserID = DefaultBootstrap().CurrentUserID } + managerAliases := managerUserAliases(state.Users) state.Users = ensureUsers(state.Users) - state.Rooms = cloneRooms(state.Rooms) + state.Rooms = migrateLegacyManagerRoomRefs(cloneRooms(state.Rooms), managerAliases) if !containsUserID(state.Users, state.CurrentUserID) { - state.CurrentUserID = defaultCurrentUserID(state.Users) + state.CurrentUserID = migrateLegacyManagerID(state.CurrentUserID, managerAliases) + if !containsUserID(state.Users, state.CurrentUserID) { + state.CurrentUserID = defaultCurrentUserID(state.Users) + } } return state } @@ -524,7 +533,7 @@ func ensureUsers(users []User) []User { } if !hasUserHandle(result, "admin") { result = append(result, User{ - ID: "u-admin", + ID: adminUserID, Name: "admin", Handle: "admin", Role: "admin", @@ -542,7 +551,7 @@ func ensureUsers(users []User) []User { } if !hasUserHandle(result, "manager") { result = append(result, User{ - ID: "u-manager", + ID: managerParticipantUserID, Name: "manager", Handle: "manager", Role: "manager", @@ -553,14 +562,38 @@ func ensureUsers(users []User) []User { } else { for i := range result { if strings.EqualFold(strings.TrimSpace(result[i].Handle), "manager") { + result[i].ID = managerParticipantUserID result[i].Name = "manager" result[i].Role = "manager" } } } + result = dropLegacyManagerUserDuplicates(result) return result } +func dropLegacyManagerUserDuplicates(users []User) []User { + out := make([]User, 0, len(users)) + seen := make(map[string]struct{}, len(users)) + for _, user := range users { + id := strings.TrimSpace(user.ID) + if id == "" || id == legacyManagerUserID { + if strings.EqualFold(strings.TrimSpace(user.Handle), "manager") || + strings.EqualFold(strings.TrimSpace(user.Name), "manager") || + strings.EqualFold(strings.TrimSpace(user.Role), "manager") { + id = managerParticipantUserID + user.ID = managerParticipantUserID + } + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, user) + } + return out +} + func normalizeUser(user User) User { user.Name = strings.ToLower(strings.TrimSpace(user.Name)) user.Handle = strings.ToLower(strings.TrimSpace(user.Handle)) @@ -587,7 +620,7 @@ func containsUserID(users []User, userID string) bool { } func defaultCurrentUserID(users []User) string { - for _, preferred := range []string{"u-admin", "u-manager"} { + for _, preferred := range []string{adminUserID, managerParticipantUserID} { if containsUserID(users, preferred) { return preferred } @@ -598,6 +631,25 @@ func defaultCurrentUserID(users []User) string { return "" } +func managerUserAliases(users []User) map[string]struct{} { + aliases := map[string]struct{}{ + legacyManagerUserID: {}, + managerParticipantUserID: {}, + } + for _, user := range users { + id := strings.TrimSpace(user.ID) + if id == "" { + continue + } + if strings.EqualFold(strings.TrimSpace(user.Handle), "manager") || + strings.EqualFold(strings.TrimSpace(user.Name), "manager") || + strings.EqualFold(strings.TrimSpace(user.Role), "manager") { + aliases[id] = struct{}{} + } + } + return aliases +} + func cloneRooms(rooms []Room) []Room { cloned := make([]Room, 0, len(rooms)) for _, room := range rooms { @@ -606,9 +658,61 @@ func cloneRooms(rooms []Room) []Room { return cloned } +func migrateLegacyManagerRoomRefs(rooms []Room, managerAliases map[string]struct{}) []Room { + for i := range rooms { + rooms[i].Members = migrateLegacyManagerIDs(rooms[i].Members, managerAliases) + for j := range rooms[i].Messages { + rooms[i].Messages[j].SenderID = migrateLegacyManagerID(rooms[i].Messages[j].SenderID, managerAliases) + rooms[i].Messages[j].Content = migrateLegacyManagerMentionTags(rooms[i].Messages[j].Content, managerAliases) + for k := range rooms[i].Messages[j].Mentions { + rooms[i].Messages[j].Mentions[k].ID = migrateLegacyManagerID(rooms[i].Messages[j].Mentions[k].ID, managerAliases) + } + } + } + return rooms +} + +func migrateLegacyManagerIDs(ids []string, managerAliases map[string]struct{}) []string { + if len(ids) == 0 { + return nil + } + out := make([]string, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = migrateLegacyManagerID(id, managerAliases) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func migrateLegacyManagerID(id string, managerAliases map[string]struct{}) string { + id = strings.TrimSpace(id) + if _, ok := managerAliases[id]; ok { + return managerParticipantUserID + } + return id +} + +func migrateLegacyManagerMentionTags(content string, managerAliases map[string]struct{}) string { + for id := range managerAliases { + if id == managerParticipantUserID { + continue + } + content = strings.ReplaceAll(content, `user_id="`+id+`"`, `user_id="`+managerParticipantUserID+`"`) + } + return content +} + func ensureAdminManagerRoom(rooms []Room) []Room { for _, room := range rooms { - if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(room, "u-admin") && containsUserIDInRoom(room, "u-manager") { + if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(room, adminUserID) && containsUserIDInRoom(room, managerParticipantUserID) { normalized := room if normalized.Title == "Admin & Manager" { normalized.Title = "admin & manager" @@ -638,11 +742,11 @@ func ensureAdminManagerRoom(rooms []Room) []Room { Subtitle: formatConversationSubtitle(2), Description: "Bootstrap room for admin and manager.", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{adminUserID, managerParticipantUserID}, Messages: []Message{ { ID: fmt.Sprintf("msg-%d", now.UnixNano()+1), - SenderID: "u-manager", + SenderID: managerParticipantUserID, Content: "Bootstrap room created for admin and manager.", CreatedAt: now, }, diff --git a/internal/im/service_test.go b/internal/im/service_test.go index b7bf992b..be69655f 100644 --- a/internal/im/service_test.go +++ b/internal/im/service_test.go @@ -93,7 +93,7 @@ func TestAddAgentToRoomSupportsRoomID(t *testing.T) { room, err := svc.CreateRoom(CreateRoomRequest{ Title: "Ops", CreatorID: "u-admin", - MemberIDs: []string{"u-manager"}, + MemberIDs: []string{"manager"}, }) if err != nil { t.Fatalf("CreateRoom() error = %v", err) @@ -128,7 +128,7 @@ func TestCreateRoomStoresStructuredEvent(t *testing.T) { room, err := svc.CreateRoom(CreateRoomRequest{ Title: "Ops", CreatorID: "u-admin", - MemberIDs: []string{"u-manager"}, + MemberIDs: []string{"manager"}, Locale: "en", }) if err != nil { @@ -155,10 +155,10 @@ func TestCreateMessagePrefixesMentionTag(t *testing.T) { Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, {ID: "u-dev", Name: "dev", Handle: "dev"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []Room{ - {ID: "room-1", Title: "Ops", Members: []string{"u-admin", "u-dev", "u-manager"}}, + {ID: "room-1", Title: "Ops", Members: []string{"u-admin", "u-dev", "manager"}}, }, }) @@ -233,17 +233,17 @@ func TestCreateMessageWithMissingMentionIDFails(t *testing.T) { func TestDeliverMessageReplacesExistingMessageWithSameIDAndSender(t *testing.T) { svc := NewServiceFromBootstrap(Bootstrap{ CurrentUserID: "u-admin", - Users: []User{{ID: "u-manager", Name: "manager", Handle: "manager"}}, + Users: []User{{ID: "manager", Name: "manager", Handle: "manager"}}, Rooms: []Room{{ ID: "room-1", Title: "Ops", - Members: []string{"u-manager"}, + Members: []string{"manager"}, }}, }) first, err := svc.DeliverMessage(DeliverMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", MessageID: "act-1", Content: "pending", }) @@ -252,7 +252,7 @@ func TestDeliverMessageReplacesExistingMessageWithSameIDAndSender(t *testing.T) } second, err := svc.DeliverMessage(DeliverMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", MessageID: "act-1", Content: "allowed", }) @@ -333,7 +333,7 @@ func TestDeleteRoomRemovesRoom(t *testing.T) { svc := NewServiceFromBootstrap(Bootstrap{ CurrentUserID: "u-admin", Rooms: []Room{ - {ID: "room-1", Title: "Room One", Members: []string{"u-admin", "u-manager"}}, + {ID: "room-1", Title: "Room One", Members: []string{"u-admin", "manager"}}, }, }) @@ -642,13 +642,13 @@ func TestSaveBootstrapSplitsRoomMessagesIntoSessionFiles(t *testing.T) { CurrentUserID: "u-admin", Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []Room{ { ID: "room-1775709078753586000", Title: "0409-1231", - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []Message{ { ID: "msg-1775709078753589000", @@ -718,14 +718,14 @@ func TestLoadBootstrapSupportsExternalSessionFiles(t *testing.T) { "current_user_id": "u-admin", "users": [ {"id": "u-admin", "name": "admin", "handle": "admin"}, - {"id": "u-manager", "name": "manager", "handle": "manager"} + {"id": "manager", "name": "manager", "handle": "manager"} ], "rooms": [ { "id": "room-1", "title": "alpha", "subtitle": "", - "members": ["u-admin", "u-manager"], + "members": ["u-admin", "manager"], "messages": "sessions/room-1.jsonl" } ] @@ -734,7 +734,7 @@ func TestLoadBootstrapSupportsExternalSessionFiles(t *testing.T) { t.Fatalf("WriteFile(state.json) error = %v", err) } - sessionLine := `{"id":"msg-1","sender_id":"u-admin","kind":"message","content":"hello","created_at":"2026-04-09T04:31:18.753589Z","mentions":["u-manager"]}` + "\n" + sessionLine := `{"id":"msg-1","sender_id":"u-admin","kind":"message","content":"hello","created_at":"2026-04-09T04:31:18.753589Z","mentions":["manager"]}` + "\n" if err := os.MkdirAll(filepath.Join(dir, "sessions"), 0o755); err != nil { t.Fatalf("MkdirAll(sessions) error = %v", err) } @@ -762,14 +762,14 @@ func TestLoadBootstrapRejectsLegacyInlineMessages(t *testing.T) { "current_user_id": "u-admin", "users": [ {"id": "u-admin", "name": "admin", "handle": "admin"}, - {"id": "u-manager", "name": "manager", "handle": "manager"} + {"id": "manager", "name": "manager", "handle": "manager"} ], "rooms": [ { "id": "room-1", "title": "alpha", "subtitle": "", - "members": ["u-admin", "u-manager"], + "members": ["u-admin", "manager"], "messages": [ {"id":"msg-1","sender_id":"u-admin","kind":"message","content":"hello","created_at":"2026-04-09T04:31:18.753589Z","mentions":null} ] @@ -797,7 +797,7 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing CurrentUserID: "u-admin", Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, {ID: "u-alice", Name: "alice", Handle: "alice"}, }, Rooms: []Room{ @@ -806,7 +806,7 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing Title: "ops", IsDirect: false, Description: "group room", - Members: []string{"u-admin", "u-manager", "u-alice"}, + Members: []string{"u-admin", "manager", "u-alice"}, }, }, } @@ -830,7 +830,7 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing var dm *Room for i := range loaded.Rooms { room := &loaded.Rooms[i] - if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(*room, "u-admin") && containsUserIDInRoom(*room, "u-manager") { + if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(*room, "u-admin") && containsUserIDInRoom(*room, "manager") { dm = room break } @@ -843,6 +843,62 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing } } +func TestEnsureBootstrapStateMigratesMisspelledManagerReferences(t *testing.T) { + dir := t.TempDir() + statePath := filepath.Join(dir, "state.json") + legacyID := "man" + "ger" + + state := Bootstrap{ + CurrentUserID: legacyID, + Users: []User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: legacyID, Name: "manager", Handle: "manager", Role: "manager"}, + }, + Rooms: []Room{{ + ID: "room-dm", + Title: "admin & manager", + IsDirect: true, + Members: []string{"u-admin", legacyID}, + Messages: []Message{{ + ID: "msg-1", + SenderID: legacyID, + Content: `manager hello`, + CreatedAt: time.Now().UTC(), + Mentions: []Mention{{ID: legacyID, Name: "manager"}}, + }}, + }}, + } + if err := SaveBootstrap(statePath, state); err != nil { + t.Fatalf("SaveBootstrap() error = %v", err) + } + + if err := EnsureBootstrapState(statePath); err != nil { + t.Fatalf("EnsureBootstrapState() error = %v", err) + } + + loaded, err := LoadBootstrap(statePath) + if err != nil { + t.Fatalf("LoadBootstrap() error = %v", err) + } + if loaded.CurrentUserID != "manager" { + t.Fatalf("CurrentUserID = %q, want manager", loaded.CurrentUserID) + } + if _, ok := NewServiceFromBootstrap(loaded).User(legacyID); ok { + t.Fatalf("legacy manager user %q still exists", legacyID) + } + room := loaded.Rooms[0] + if !containsUserIDInRoom(room, "manager") || containsUserIDInRoom(room, legacyID) { + t.Fatalf("room.Members = %+v, want manager only", room.Members) + } + got := room.Messages[0] + if got.SenderID != "manager" || len(got.Mentions) != 1 || got.Mentions[0].ID != "manager" { + t.Fatalf("message = %+v, want manager sender and mention", got) + } + if !strings.Contains(got.Content, `user_id="manager"`) || strings.Contains(got.Content, legacyID) { + t.Fatalf("message.Content = %q, want manager mention tag", got.Content) + } +} + func TestReloadRefreshesRoomsFromStateFile(t *testing.T) { dir := t.TempDir() statePath := filepath.Join(dir, "state.json") @@ -850,7 +906,7 @@ func TestReloadRefreshesRoomsFromStateFile(t *testing.T) { CurrentUserID: "u-admin", Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin", Role: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}, }, } if err := SaveBootstrap(statePath, initial); err != nil { @@ -873,7 +929,7 @@ func TestReloadRefreshesRoomsFromStateFile(t *testing.T) { ID: "room-1", Title: "admin & manager", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, }, }, } diff --git a/internal/im/service_thread_test.go b/internal/im/service_thread_test.go index 9b0656af..7225a279 100644 --- a/internal/im/service_thread_test.go +++ b/internal/im/service_thread_test.go @@ -98,7 +98,7 @@ func TestCreateThreadReplyHidesFromMainTimelineAndUpdatesSummary(t *testing.T) { reply, err := svc.CreateMessage(CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: "reply inside thread", RelatesTo: &MessageRelation{ RelType: RelationTypeThread, @@ -181,7 +181,7 @@ func TestStartThreadRejectsMissingAndNestedRoots(t *testing.T) { } reply, err := svc.CreateMessage(CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: "nested candidate", RelatesTo: &MessageRelation{ RelType: RelationTypeThread, @@ -211,7 +211,7 @@ func TestThreadStatePersistsAcrossReload(t *testing.T) { } if _, err := svc.CreateMessage(CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: "persisted reply", RelatesTo: &MessageRelation{ RelType: RelationTypeThread, @@ -320,10 +320,10 @@ func threadTestBootstrap() Bootstrap { CurrentUserID: "u-admin", Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []Room{ - {ID: "room-1", Title: "Room One", Members: []string{"u-admin", "u-manager"}, Messages: messages}, + {ID: "room-1", Title: "Room One", Members: []string{"u-admin", "manager"}, Messages: messages}, }, } } diff --git a/internal/onboard/detect.go b/internal/onboard/detect.go index 4499a921..2b58fcf0 100644 --- a/internal/onboard/detect.go +++ b/internal/onboard/detect.go @@ -9,16 +9,16 @@ import ( "csgclaw/internal/agent" "csgclaw/internal/app/runtimewiring" - "csgclaw/internal/bot" "csgclaw/internal/config" "csgclaw/internal/hub" "csgclaw/internal/im" + "csgclaw/internal/participant" ) var ( - loadIMBootstrap = im.LoadBootstrap - openBotStore = bot.NewStore - openAgentState = func(cfg config.Config, path, managerImage string) (agentStateReader, error) { + loadIMBootstrap = im.LoadBootstrap + openParticipantStore = participant.NewStore + openAgentState = func(cfg config.Config, path, managerImage string) (agentStateReader, error) { return agent.NewServiceWithLLM( effectiveLLMConfig(cfg), cfg.Server, @@ -41,13 +41,13 @@ type DetectStateOptions struct { } type DetectStateResult struct { - ConfigPath string - Config config.Config - ConfigExists bool - ConfigComplete bool - IMBootstrapComplete bool - ManagerAgentComplete bool - ManagerBotComplete bool + ConfigPath string + Config config.Config + ConfigExists bool + ConfigComplete bool + IMBootstrapComplete bool + ManagerAgentComplete bool + ManagerParticipantComplete bool } func (r DetectStateResult) Complete() bool { @@ -55,7 +55,7 @@ func (r DetectStateResult) Complete() bool { r.ConfigComplete && r.IMBootstrapComplete && r.ManagerAgentComplete && - r.ManagerBotComplete + r.ManagerParticipantComplete } func DetectState(opts DetectStateOptions) (DetectStateResult, error) { @@ -111,11 +111,11 @@ func DetectState(opts DetectStateOptions) (DetectStateResult, error) { } result.ManagerAgentComplete = managerAgentComplete(agentState) - store, err := openBotStore(filepath.Join(filepath.Dir(imStatePath), "bots.json")) + store, err := openParticipantStore(filepath.Join(filepath.Dir(imStatePath), "participants.json")) if err != nil { return DetectStateResult{}, err } - result.ManagerBotComplete = managerBotComplete(store.List()) + result.ManagerParticipantComplete = managerParticipantComplete(store.List(participant.ListOptions{Channel: participant.ChannelCSGClaw})) return result, nil } @@ -128,14 +128,14 @@ func imBootstrapComplete(state im.Bootstrap) bool { if !hasIMUser(state.Users, "u-admin", "admin", "admin") { return false } - if !hasIMUser(state.Users, "u-manager", "manager", "manager") { + if !hasIMUser(state.Users, agent.ManagerParticipantID, "manager", "manager") { return false } for _, room := range state.Rooms { if room.IsDirect && len(room.Members) == 2 && containsMember(room.Members, "u-admin") && - containsMember(room.Members, "u-manager") { + containsMember(room.Members, agent.ManagerParticipantID) { return true } } @@ -184,21 +184,21 @@ func managerAgentComplete(state agentStateReader) bool { return strings.EqualFold(strings.TrimSpace(managerAgent.Role), agent.RoleManager) } -func managerBotComplete(bots []bot.Bot) bool { - for _, b := range bots { - if strings.TrimSpace(b.Channel) != string(bot.ChannelCSGClaw) { +func managerParticipantComplete(items []participant.Participant) bool { + for _, item := range items { + if strings.TrimSpace(item.Channel) != participant.ChannelCSGClaw { continue } - if strings.TrimSpace(b.ID) != agent.ManagerUserID { + if strings.TrimSpace(item.ID) != agent.ManagerParticipantID { continue } - if !strings.EqualFold(strings.TrimSpace(b.Role), string(bot.RoleManager)) { + if !strings.EqualFold(strings.TrimSpace(item.Type), participant.TypeAgent) { return false } - if strings.TrimSpace(b.AgentID) != agent.ManagerUserID { + if strings.TrimSpace(item.AgentID) != agent.ManagerUserID { return false } - return strings.TrimSpace(b.UserID) != "" + return strings.TrimSpace(item.ChannelUserRef) != "" } return false } diff --git a/internal/onboard/detect_test.go b/internal/onboard/detect_test.go index f738a698..44b5894b 100644 --- a/internal/onboard/detect_test.go +++ b/internal/onboard/detect_test.go @@ -8,9 +8,10 @@ import ( "time" "csgclaw/internal/agent" - "csgclaw/internal/bot" + "csgclaw/internal/apitypes" "csgclaw/internal/config" "csgclaw/internal/im" + "csgclaw/internal/participant" ) func TestDetectStateFreshHomeReportsIncompleteBootstrap(t *testing.T) { @@ -33,8 +34,8 @@ func TestDetectStateFreshHomeReportsIncompleteBootstrap(t *testing.T) { if result.ManagerAgentComplete { t.Fatal("ManagerAgentComplete = true, want false") } - if result.ManagerBotComplete { - t.Fatal("ManagerBotComplete = true, want false") + if result.ManagerParticipantComplete { + t.Fatal("ManagerParticipantComplete = true, want false") } if result.Complete() { t.Fatal("Complete() = true, want false") @@ -60,14 +61,14 @@ func TestDetectStateCompleteBootstrapReportsComplete(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin", Role: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager", Role: "manager"}, }, Rooms: []im.Room{ { ID: "room-bootstrap", Title: "admin & manager", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", agent.ManagerParticipantID}, }, }, }); err != nil { @@ -77,11 +78,11 @@ func TestDetectStateCompleteBootstrapReportsComplete(t *testing.T) { if err := writeManagerAgentState(t); err != nil { t.Fatalf("writeManagerAgentState() error = %v", err) } - if err := writeManagerBotState(t, bot.Bot{ + if err := writeManagerBotState(t, apitypes.LegacyBot{ ID: agent.ManagerUserID, Name: "manager", - Role: string(bot.RoleManager), - Channel: string(bot.ChannelCSGClaw), + Role: agent.RoleManager, + Channel: participant.ChannelCSGClaw, AgentID: agent.ManagerUserID, UserID: agent.ManagerUserID, Available: true, @@ -95,12 +96,13 @@ func TestDetectStateCompleteBootstrapReportsComplete(t *testing.T) { t.Fatalf("DetectState() error = %v", err) } - if !result.ConfigExists || !result.ConfigComplete || !result.IMBootstrapComplete || !result.ManagerAgentComplete || !result.ManagerBotComplete { + if !result.ConfigExists || !result.ConfigComplete || !result.IMBootstrapComplete || !result.ManagerAgentComplete || !result.ManagerParticipantComplete { t.Fatalf("DetectState() completeness = %+v, want all true", result) } if !result.Complete() { t.Fatal("Complete() = false, want true") } + assertLegacyBotsMigrated(t) } func TestDetectStateFlagsMissingManagerBotWhenOtherBootstrapStateExists(t *testing.T) { @@ -133,8 +135,8 @@ func TestDetectStateFlagsMissingManagerBotWhenOtherBootstrapStateExists(t *testi if !result.ConfigExists || !result.ConfigComplete || !result.IMBootstrapComplete || !result.ManagerAgentComplete { t.Fatalf("DetectState() = %+v, want config/im/agent complete", result) } - if result.ManagerBotComplete { - t.Fatal("ManagerBotComplete = true, want false") + if result.ManagerParticipantComplete { + t.Fatal("ManagerParticipantComplete = true, want false") } if result.Complete() { t.Fatal("Complete() = true, want false") @@ -172,7 +174,7 @@ func writeManagerAgentState(t *testing.T) error { return os.WriteFile(agentsPath, append(data, '\n'), 0o600) } -func writeManagerBotState(t *testing.T, manager bot.Bot) error { +func writeManagerBotState(t *testing.T, manager apitypes.LegacyBot) error { t.Helper() imStatePath, err := config.DefaultIMStatePath() @@ -185,10 +187,38 @@ func writeManagerBotState(t *testing.T, manager bot.Bot) error { } data, err := json.MarshalIndent(map[string]any{ - "bots": []bot.Bot{manager}, + "bots": []apitypes.LegacyBot{manager}, }, "", " ") if err != nil { return err } return os.WriteFile(path, append(data, '\n'), 0o600) } + +func assertLegacyBotsMigrated(t *testing.T) { + t.Helper() + + imStatePath, err := config.DefaultIMStatePath() + if err != nil { + t.Fatalf("DefaultIMStatePath() error = %v", err) + } + botsPath := filepath.Join(filepath.Dir(imStatePath), "bots.json") + if _, err := os.Stat(botsPath); !os.IsNotExist(err) { + t.Fatalf("bots.json still exists after participant migration; stat err=%v", err) + } + + store, err := participant.NewStore(filepath.Join(filepath.Dir(imStatePath), "participants.json")) + if err != nil { + t.Fatalf("participant.NewStore() error = %v", err) + } + got, ok := store.Get(participant.ChannelCSGClaw, agent.ManagerParticipantID) + if !ok { + t.Fatal("manager participant was not created from legacy bots.json") + } + if got.AgentID != agent.ManagerUserID || got.ChannelUserRef != agent.ManagerParticipantID { + t.Fatalf("manager participant = %+v, want agent %q and channel user %q", got, agent.ManagerUserID, agent.ManagerParticipantID) + } + if _, ok := store.Get(participant.ChannelCSGClaw, agent.ManagerUserID); ok { + t.Fatalf("manager participant was migrated under old agent id %q", agent.ManagerUserID) + } +} diff --git a/internal/onboard/onboard.go b/internal/onboard/onboard.go index 777067f2..0e97a55c 100644 --- a/internal/onboard/onboard.go +++ b/internal/onboard/onboard.go @@ -9,18 +9,18 @@ import ( "csgclaw/internal/agent" "csgclaw/internal/app/runtimewiring" - "csgclaw/internal/bot" "csgclaw/internal/config" "csgclaw/internal/hub" "csgclaw/internal/im" + "csgclaw/internal/participant" "csgclaw/internal/sandboxproviders" ) var ( - CreateManagerBot = createManagerBot - EnsureIMBootstrapState = im.EnsureBootstrapState - defaultAgentsPath = config.DefaultAgentsPath - defaultIMStatePath = config.DefaultIMStatePath + CreateManagerParticipant = createManagerParticipant + EnsureIMBootstrapState = im.EnsureBootstrapState + defaultAgentsPath = config.DefaultAgentsPath + defaultIMStatePath = config.DefaultIMStatePath ) type EnsureStateOptions struct { @@ -91,7 +91,7 @@ func ensureBootstrapState(ctx context.Context, cfg config.Config) error { if err := EnsureIMBootstrapState(imStatePath); err != nil { return err } - if _, err := CreateManagerBot(ctx, agentsPath, imStatePath, cfg); err != nil { + if _, err := CreateManagerParticipant(ctx, agentsPath, imStatePath, cfg); err != nil { return err } return nil @@ -109,18 +109,18 @@ func bootstrapPaths() (agentsPath, imStatePath string, err error) { return agentsPath, imStatePath, nil } -func createManagerBot(ctx context.Context, agentsPath, imStatePath string, cfg config.Config) (bot.Bot, error) { +func createManagerParticipant(ctx context.Context, agentsPath, imStatePath string, cfg config.Config) (participant.Participant, error) { hubSvc, err := hub.NewService(cfg.Hub, hub.DefaultStoreFactory) if err != nil { - return bot.Bot{}, err + return participant.Participant{}, err } bootstrapDefaults, err := hub.ResolveBootstrapDefaults(ctx, cfg.Bootstrap, hubSvc) if err != nil { - return bot.Bot{}, err + return participant.Participant{}, err } opts, err := sandboxproviders.ServiceOptions(cfg.Sandbox) if err != nil { - return bot.Bot{}, err + return participant.Participant{}, err } opts = append(opts, runtimewiring.WithPicoClawSandboxRuntime(nil), @@ -131,7 +131,7 @@ func createManagerBot(ctx context.Context, agentsPath, imStatePath string, cfg c ) agentSvc, err := agent.NewServiceWithLLM(effectiveLLMConfig(cfg), cfg.Server, bootstrapDefaults.ManagerImage, agentsPath, opts...) if err != nil { - return bot.Bot{}, err + return participant.Participant{}, err } defer func() { _ = agentSvc.Close() @@ -139,21 +139,22 @@ func createManagerBot(ctx context.Context, agentsPath, imStatePath string, cfg c imSvc, err := im.NewServiceFromPath(imStatePath) if err != nil { - return bot.Bot{}, err + return participant.Participant{}, err } - store, err := bot.NewStore(filepath.Join(filepath.Dir(imStatePath), "bots.json")) + store, err := participant.NewStore(filepath.Join(filepath.Dir(imStatePath), "participants.json")) if err != nil { - return bot.Bot{}, err + return participant.Participant{}, err } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) + participantSvc := participant.NewService( + store, + participant.WithAgentService(agentSvc), + participant.WithIMService(imSvc), + ) + created, err := participantSvc.EnsureBootstrapManager(ctx) if err != nil { - return bot.Bot{}, err + return participant.Participant{}, err } - return botSvc.CreateManager(ctx, bot.CreateRequest{ - Name: agent.ManagerName, - Role: string(bot.RoleManager), - Channel: string(bot.ChannelCSGClaw), - }, false) + return created, nil } func defaultConfig() config.Config { diff --git a/internal/onboard/onboard_test.go b/internal/onboard/onboard_test.go index 5e3a9bdd..a547610f 100644 --- a/internal/onboard/onboard_test.go +++ b/internal/onboard/onboard_test.go @@ -8,8 +8,8 @@ import ( "testing" "csgclaw/internal/agent" - "csgclaw/internal/bot" "csgclaw/internal/config" + "csgclaw/internal/participant" ) func TestEnsureStateCreatesConfigAndBootstrapsManagerState(t *testing.T) { @@ -35,11 +35,11 @@ func TestEnsureStateCreatesConfigAndBootstrapsManagerState(t *testing.T) { gotIMStatePath = path return nil } - CreateManagerBot = func(_ context.Context, agentsPath, imStatePath string, cfg config.Config) (bot.Bot, error) { + CreateManagerParticipant = func(_ context.Context, agentsPath, imStatePath string, cfg config.Config) (participant.Participant, error) { gotAgentsPath = agentsPath gotManagerIMStatePath = imStatePath gotCfg = cfg - return bot.Bot{ID: agent.ManagerUserID}, nil + return participant.Participant{ID: agent.ManagerParticipantID}, nil } result, err := EnsureState(context.Background(), EnsureStateOptions{ConfigPath: configPath}) @@ -51,10 +51,10 @@ func TestEnsureStateCreatesConfigAndBootstrapsManagerState(t *testing.T) { t.Fatalf("EnsureIMBootstrapState path = %q, want %q", gotIMStatePath, wantIMStatePath) } if gotAgentsPath != wantAgentsPath { - t.Fatalf("CreateManagerBot agentsPath = %q, want %q", gotAgentsPath, wantAgentsPath) + t.Fatalf("CreateManagerParticipant agentsPath = %q, want %q", gotAgentsPath, wantAgentsPath) } if gotManagerIMStatePath != wantIMStatePath { - t.Fatalf("CreateManagerBot imStatePath = %q, want %q", gotManagerIMStatePath, wantIMStatePath) + t.Fatalf("CreateManagerParticipant imStatePath = %q, want %q", gotManagerIMStatePath, wantIMStatePath) } if result.ConfigPath != configPath { t.Fatalf("result.ConfigPath = %q, want %q", result.ConfigPath, configPath) @@ -143,9 +143,9 @@ models = ["gpt-test"] restore := stubEnsureStateDeps(t) defer restore() EnsureIMBootstrapState = func(string) error { return nil } - CreateManagerBot = func(_ context.Context, _, _ string, cfg config.Config) (bot.Bot, error) { + CreateManagerParticipant = func(_ context.Context, _, _ string, cfg config.Config) (participant.Participant, error) { gotCfg = cfg - return bot.Bot{}, nil + return participant.Participant{}, nil } if _, err := EnsureState(context.Background(), EnsureStateOptions{ConfigPath: configPath}); err != nil { @@ -217,11 +217,11 @@ models = ["gpt-test"] restore := stubEnsureStateDeps(t) defer restore() EnsureIMBootstrapState = func(string) error { return nil } - CreateManagerBot = func(_ context.Context, _, _ string, cfg config.Config) (bot.Bot, error) { + CreateManagerParticipant = func(_ context.Context, _, _ string, cfg config.Config) (participant.Participant, error) { if got, want := cfg.Server.ListenAddr, "127.0.0.1:19090"; got != want { t.Fatalf("cfg.Server.ListenAddr = %q, want %q", got, want) } - return bot.Bot{}, nil + return participant.Participant{}, nil } if _, err := EnsureState(context.Background(), EnsureStateOptions{ConfigPath: configPath}); err != nil { @@ -287,11 +287,11 @@ models = ["gpt-test"] restore := stubEnsureStateDeps(t) defer restore() EnsureIMBootstrapState = func(string) error { return nil } - CreateManagerBot = func(_ context.Context, _, _ string, cfg config.Config) (bot.Bot, error) { + CreateManagerParticipant = func(_ context.Context, _, _ string, cfg config.Config) (participant.Participant, error) { if got, want := cfg.Sandbox.Provider, config.BoxLiteProvider; got != want { t.Fatalf("cfg.Sandbox.Provider = %q, want %q", got, want) } - return bot.Bot{}, nil + return participant.Participant{}, nil } if _, err := EnsureState(context.Background(), EnsureStateOptions{ConfigPath: configPath}); err != nil { @@ -340,14 +340,14 @@ models = ["gpt-test"] restore := stubEnsureStateDeps(t) defer restore() EnsureIMBootstrapState = func(string) error { return nil } - CreateManagerBot = func(_ context.Context, _, _ string, cfg config.Config) (bot.Bot, error) { + CreateManagerParticipant = func(_ context.Context, _, _ string, cfg config.Config) (participant.Participant, error) { if got, want := cfg.Bootstrap.DefaultManagerTemplate, "builtin.picoclaw-manager"; got != want { t.Fatalf("cfg.Bootstrap.DefaultManagerTemplate = %q, want %q", got, want) } if got, want := cfg.Bootstrap.DefaultWorkerTemplate, "local.review-worker"; got != want { t.Fatalf("cfg.Bootstrap.DefaultWorkerTemplate = %q, want %q", got, want) } - return bot.Bot{}, nil + return participant.Participant{}, nil } if _, err := EnsureState(context.Background(), EnsureStateOptions{ConfigPath: configPath}); err != nil { @@ -385,11 +385,11 @@ access_token = "your_access_token" restore := stubEnsureStateDeps(t) defer restore() EnsureIMBootstrapState = func(string) error { return nil } - CreateManagerBot = func(_ context.Context, _, _ string, cfg config.Config) (bot.Bot, error) { + CreateManagerParticipant = func(_ context.Context, _, _ string, cfg config.Config) (participant.Participant, error) { if got, want := cfg.Sandbox.Resolved().Provider, config.DockerProvider; got != want { t.Fatalf("cfg.Sandbox.Provider = %q, want %q", got, want) } - return bot.Bot{}, nil + return participant.Participant{}, nil } if _, err := EnsureState(context.Background(), EnsureStateOptions{ConfigPath: configPath}); err != nil { @@ -456,14 +456,14 @@ models = ["gpt-test"] restore := stubEnsureStateDeps(t) defer restore() EnsureIMBootstrapState = func(string) error { return nil } - CreateManagerBot = func(_ context.Context, _, _ string, cfg config.Config) (bot.Bot, error) { + CreateManagerParticipant = func(_ context.Context, _, _ string, cfg config.Config) (participant.Participant, error) { if got, want := cfg.Hub.DefaultRegistry, "builtin"; got != want { t.Fatalf("cfg.Hub.DefaultRegistry = %q, want %q", got, want) } if got, want := cfg.Hub.DefaultPublishRegistry, "local"; got != want { t.Fatalf("cfg.Hub.DefaultPublishRegistry = %q, want %q", got, want) } - return bot.Bot{}, nil + return participant.Participant{}, nil } if _, err := EnsureState(context.Background(), EnsureStateOptions{ConfigPath: configPath}); err != nil { @@ -530,11 +530,11 @@ models = ["gpt-test"] restore := stubEnsureStateDeps(t) defer restore() EnsureIMBootstrapState = func(string) error { return nil } - CreateManagerBot = func(_ context.Context, _, _ string, cfg config.Config) (bot.Bot, error) { + CreateManagerParticipant = func(_ context.Context, _, _ string, cfg config.Config) (participant.Participant, error) { if got, want := len(cfg.Hub.Registries), 3; got != want { t.Fatalf("len(cfg.Hub.Registries) = %d, want %d", got, want) } - return bot.Bot{}, nil + return participant.Participant{}, nil } if _, err := EnsureState(context.Background(), EnsureStateOptions{ConfigPath: configPath}); err != nil { @@ -564,10 +564,10 @@ models = ["gpt-test"] func stubEnsureStateDeps(t *testing.T) func() { t.Helper() - origCreateManager := CreateManagerBot + origCreateManager := CreateManagerParticipant origEnsureIMBootstrapState := EnsureIMBootstrapState return func() { - CreateManagerBot = origCreateManager + CreateManagerParticipant = origCreateManager EnsureIMBootstrapState = origEnsureIMBootstrapState } } diff --git a/internal/participant/model.go b/internal/participant/model.go new file mode 100644 index 00000000..92fbfcec --- /dev/null +++ b/internal/participant/model.go @@ -0,0 +1,68 @@ +package participant + +import ( + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" +) + +const ( + ChannelCSGClaw = "csgclaw" + ChannelFeishu = "feishu" + + TypeHuman = "human" + TypeAgent = "agent" + TypeNotification = "notification" + + ChannelUserKindLocalUserID = "local_user_id" + ChannelUserKindOpenID = "open_id" + + BindingModeCreate = "create" + BindingModeReuse = "reuse" + BindingModeNone = "none" + + LifecycleStatusActive = "active" +) + +type Participant = apitypes.Participant + +type ChannelUserSpec struct { + Ref string `json:"ref,omitempty"` + Kind string `json:"kind,omitempty"` +} + +type AgentBindingSpec struct { + Mode string `json:"mode,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Agent *agent.CreateAgentSpec `json:"agent,omitempty"` +} + +type CreateRequest struct { + ID string `json:"id,omitempty"` + Channel string `json:"channel,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + ChannelAppRef string `json:"channel_app_ref,omitempty"` + ChannelUser ChannelUserSpec `json:"channel_user,omitempty"` + AgentBinding AgentBindingSpec `json:"agent_binding,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type UpdateRequest struct { + Name *string `json:"name,omitempty"` + Avatar *string `json:"avatar,omitempty"` + Mentionable *bool `json:"mentionable,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type ListOptions struct { + Channel string + Type string + AgentID string +} + +type DeleteOptions struct { + DeleteAgent string +} + +const DeleteAgentIfUnreferenced = "if_unreferenced" diff --git a/internal/participant/service.go b/internal/participant/service.go new file mode 100644 index 00000000..f77eac12 --- /dev/null +++ b/internal/participant/service.go @@ -0,0 +1,569 @@ +package participant + +import ( + "context" + "crypto/rand" + "encoding/base32" + "fmt" + "strings" + "time" + "unicode" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" + "csgclaw/internal/im" +) + +type Service struct { + store *Store + agents *agent.Service + im *im.Service +} + +type Option func(*Service) + +func NewService(store *Store, opts ...Option) *Service { + if store == nil { + store = NewMemoryStore(nil) + } + s := &Service{store: store} + for _, opt := range opts { + if opt != nil { + opt(s) + } + } + return s +} + +func WithAgentService(agentSvc *agent.Service) Option { + return func(s *Service) { + s.agents = agentSvc + } +} + +func WithIMService(imSvc *im.Service) Option { + return func(s *Service) { + s.im = imSvc + } +} + +func (s *Service) List(opts ListOptions) []apitypes.Participant { + if s == nil || s.store == nil { + return nil + } + return s.store.List(opts) +} + +func (s *Service) Get(channel, id string) (apitypes.Participant, bool) { + if s == nil || s.store == nil { + return apitypes.Participant{}, false + } + return s.store.Get(channel, id) +} + +func (s *Service) Create(ctx context.Context, req CreateRequest) (apitypes.Participant, error) { + if s == nil || s.store == nil { + return apitypes.Participant{}, fmt.Errorf("participant store is required") + } + normalized, err := s.normalizeCreateRequest(req) + if err != nil { + return apitypes.Participant{}, err + } + if _, ok := s.store.Get(normalized.Channel, normalized.ID); ok { + return apitypes.Participant{}, fmt.Errorf("participant %s:%s already exists", normalized.Channel, normalized.ID) + } + + if normalized.Type == TypeAgent { + agentID, err := s.ensureAgentBinding(ctx, normalized) + if err != nil { + return apitypes.Participant{}, err + } + normalized.AgentID = agentID + } + + if err := s.ensureChannelIdentity(ctx, normalized); err != nil { + return apitypes.Participant{}, err + } + + now := time.Now().UTC() + created := apitypes.Participant{ + ID: normalized.ID, + Channel: normalized.Channel, + Type: normalized.Type, + Name: normalized.Name, + Avatar: normalized.Avatar, + ChannelUserRef: normalized.ChannelUser.Ref, + ChannelUserKind: normalized.ChannelUser.Kind, + ChannelAppRef: normalized.ChannelAppRef, + AgentID: normalized.AgentID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: cloneMetadata(normalized.Metadata), + CreatedAt: now, + UpdatedAt: now, + } + if err := s.store.Save(created); err != nil { + return apitypes.Participant{}, err + } + return created, nil +} + +func (s *Service) EnsureBootstrapManager(ctx context.Context) (apitypes.Participant, error) { + if s == nil || s.store == nil { + return apitypes.Participant{}, fmt.Errorf("participant store is required") + } + if s.agents == nil { + return apitypes.Participant{}, fmt.Errorf("agent service is required") + } + manager, err := s.agents.EnsureManager(ctx, false) + if err != nil { + return apitypes.Participant{}, err + } + now := time.Now().UTC() + createdAt := manager.CreatedAt.UTC() + if createdAt.IsZero() { + createdAt = now + } + existing, ok := s.store.Get(ChannelCSGClaw, agent.ManagerParticipantID) + legacyExisting, legacyOK := s.store.Get(ChannelCSGClaw, agent.ManagerUserID) + legacyItems := s.legacyManagerParticipants() + source := existing + if !ok && legacyOK && isLegacyManagerParticipant(legacyExisting) { + source = legacyExisting + } else if !ok && len(legacyItems) > 0 { + source = legacyItems[0] + } + hasLegacySource := legacyOK && isLegacyManagerParticipant(legacyExisting) || len(legacyItems) > 0 + if (ok || hasLegacySource) && !source.CreatedAt.IsZero() { + createdAt = source.CreatedAt.UTC() + } + + name := strings.TrimSpace(manager.Name) + if name == "" { + name = agent.ManagerName + } + avatar := strings.TrimSpace(manager.Avatar) + metadata := map[string]any(nil) + if ok || hasLegacySource { + metadata = cloneMetadata(source.Metadata) + if avatar == "" { + avatar = strings.TrimSpace(source.Avatar) + } + } + if s.im != nil { + if _, _, err := s.im.EnsureAgentUser(im.EnsureAgentUserRequest{ + ID: agent.ManagerParticipantID, + Name: name, + Handle: "manager", + Role: agent.RoleManager, + Avatar: avatar, + }); err != nil { + return apitypes.Participant{}, err + } + } + + item := apitypes.Participant{ + ID: agent.ManagerParticipantID, + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: name, + Avatar: avatar, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: manager.ID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: now, + } + if err := s.store.Save(item); err != nil { + return apitypes.Participant{}, err + } + if legacyOK && isLegacyManagerParticipant(legacyExisting) { + if _, _, err := s.store.Delete(ChannelCSGClaw, agent.ManagerUserID); err != nil { + return apitypes.Participant{}, err + } + } + for _, legacy := range legacyItems { + if _, _, err := s.store.Delete(ChannelCSGClaw, legacy.ID); err != nil { + return apitypes.Participant{}, err + } + } + return item, nil +} + +func (s *Service) legacyManagerParticipants() []apitypes.Participant { + if s == nil || s.store == nil { + return nil + } + var out []apitypes.Participant + for _, item := range s.store.List(ListOptions{Channel: ChannelCSGClaw, Type: TypeAgent}) { + if strings.TrimSpace(item.ID) == agent.ManagerParticipantID || strings.TrimSpace(item.ID) == agent.ManagerUserID { + continue + } + if strings.TrimSpace(item.AgentID) == agent.ManagerUserID || + strings.TrimSpace(item.ChannelUserRef) == agent.ManagerUserID || + strings.EqualFold(strings.TrimSpace(item.Name), agent.ManagerName) { + out = append(out, item) + } + } + return out +} + +func isLegacyManagerParticipant(item apitypes.Participant) bool { + if strings.TrimSpace(item.ID) != agent.ManagerUserID { + return false + } + if strings.TrimSpace(item.Channel) != ChannelCSGClaw { + return false + } + if strings.TrimSpace(item.AgentID) == agent.ManagerUserID { + return true + } + if strings.TrimSpace(item.ChannelUserRef) == agent.ManagerUserID { + return true + } + return strings.EqualFold(strings.TrimSpace(item.Name), agent.ManagerName) +} + +func (s *Service) Update(_ context.Context, channel, id string, req UpdateRequest) (apitypes.Participant, bool, error) { + if s == nil || s.store == nil { + return apitypes.Participant{}, false, fmt.Errorf("participant store is required") + } + channel = normalizeChannel(channel) + id = strings.TrimSpace(id) + if channel == "" || id == "" { + return apitypes.Participant{}, false, fmt.Errorf("channel and id are required") + } + item, ok := s.store.Get(channel, id) + if !ok { + return apitypes.Participant{}, false, nil + } + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return apitypes.Participant{}, false, fmt.Errorf("name is required") + } + item.Name = name + } + if req.Avatar != nil { + item.Avatar = strings.TrimSpace(*req.Avatar) + } + if req.Mentionable != nil { + item.Mentionable = *req.Mentionable + } + if req.Metadata != nil { + item.Metadata = cloneMetadata(req.Metadata) + } + item.UpdatedAt = time.Now().UTC() + if err := s.store.Save(item); err != nil { + return apitypes.Participant{}, false, err + } + return item, true, nil +} + +func (s *Service) Delete(ctx context.Context, channel, id string, opts DeleteOptions) (apitypes.Participant, bool, error) { + if s == nil || s.store == nil { + return apitypes.Participant{}, false, fmt.Errorf("participant store is required") + } + channel = normalizeChannel(channel) + id = strings.TrimSpace(id) + if channel == "" || id == "" { + return apitypes.Participant{}, false, fmt.Errorf("channel and id are required") + } + + existing, ok := s.store.Get(channel, id) + if !ok { + return apitypes.Participant{}, false, nil + } + deleteAgentMode := strings.TrimSpace(opts.DeleteAgent) + if deleteAgentMode != "" && deleteAgentMode != DeleteAgentIfUnreferenced { + return apitypes.Participant{}, false, fmt.Errorf("delete_agent must be %q", DeleteAgentIfUnreferenced) + } + if deleteAgentMode == DeleteAgentIfUnreferenced && strings.TrimSpace(existing.AgentID) != "" { + if s.agents == nil { + return apitypes.Participant{}, false, fmt.Errorf("agent service is required") + } + for _, item := range s.store.List(ListOptions{AgentID: existing.AgentID}) { + if item.Channel == existing.Channel && item.ID == existing.ID { + continue + } + return apitypes.Participant{}, false, fmt.Errorf("agent %q is still referenced by participant %s:%s", existing.AgentID, item.Channel, item.ID) + } + } + + deleted, ok, err := s.store.Delete(channel, id) + if err != nil || !ok { + return deleted, ok, err + } + if deleteAgentMode == DeleteAgentIfUnreferenced && strings.TrimSpace(deleted.AgentID) != "" { + if err := s.agents.Delete(ctx, deleted.AgentID); err != nil { + return deleted, true, err + } + } + return deleted, true, nil +} + +type normalizedCreateRequest struct { + ID string + Channel string + Type string + Name string + Avatar string + ChannelAppRef string + ChannelUser ChannelUserSpec + AgentBinding AgentBindingSpec + AgentID string + Metadata map[string]any +} + +func (s *Service) normalizeCreateRequest(req CreateRequest) (normalizedCreateRequest, error) { + channel := normalizeChannel(req.Channel) + if channel == "" { + return normalizedCreateRequest{}, fmt.Errorf("channel must be one of %q or %q", ChannelCSGClaw, ChannelFeishu) + } + typ := normalizeType(req.Type) + if typ == "" { + return normalizedCreateRequest{}, fmt.Errorf("type must be one of %q, %q, or %q", TypeHuman, TypeAgent, TypeNotification) + } + name := strings.TrimSpace(req.Name) + if name == "" { + return normalizedCreateRequest{}, fmt.Errorf("name is required") + } + + id, err := s.resolveParticipantID(channel, typ, req) + if err != nil { + return normalizedCreateRequest{}, err + } + + channelUser := ChannelUserSpec{ + Ref: strings.TrimSpace(req.ChannelUser.Ref), + Kind: strings.TrimSpace(req.ChannelUser.Kind), + } + if channelUser.Ref == "" && channel == ChannelCSGClaw { + if typ == TypeAgent { + channelUser.Ref = defaultAgentID(id) + } else { + channelUser.Ref = id + } + } + if channelUser.Kind == "" { + switch channel { + case ChannelCSGClaw: + channelUser.Kind = ChannelUserKindLocalUserID + case ChannelFeishu: + channelUser.Kind = ChannelUserKindOpenID + } + } + if channelUser.Ref == "" { + return normalizedCreateRequest{}, fmt.Errorf("channel_user.ref is required") + } + + binding := req.AgentBinding + binding.Mode = normalizeBindingMode(binding.Mode) + binding.AgentID = strings.TrimSpace(binding.AgentID) + if binding.Mode == "" { + binding.Mode = BindingModeNone + } + switch typ { + case TypeHuman, TypeNotification: + if binding.Mode == BindingModeCreate { + return normalizedCreateRequest{}, fmt.Errorf("%s participant cannot create an agent binding", typ) + } + case TypeAgent: + switch binding.Mode { + case BindingModeCreate: + case BindingModeReuse: + if binding.AgentID == "" { + return normalizedCreateRequest{}, fmt.Errorf("agent_binding.agent_id is required for reuse") + } + case BindingModeNone: + default: + return normalizedCreateRequest{}, fmt.Errorf("agent_binding.mode must be one of %q, %q, or %q", BindingModeCreate, BindingModeReuse, BindingModeNone) + } + } + + return normalizedCreateRequest{ + ID: id, + Channel: channel, + Type: typ, + Name: name, + Avatar: strings.TrimSpace(req.Avatar), + ChannelAppRef: strings.TrimSpace(req.ChannelAppRef), + ChannelUser: channelUser, + AgentBinding: binding, + Metadata: cloneMetadata(req.Metadata), + }, nil +} + +func (s *Service) resolveParticipantID(channel, typ string, req CreateRequest) (string, error) { + if id := slugify(req.ID); id != "" { + return id, nil + } + stable := strings.TrimSpace(req.ChannelUser.Ref) + if stable == "" { + stable = strings.TrimSpace(req.AgentBinding.AgentID) + } + if strings.HasPrefix(stable, "u-") && typ == TypeAgent { + stable = strings.TrimPrefix(stable, "u-") + } + if slug := slugify(stable); slug != "" { + if _, ok := s.store.Get(channel, slug); !ok { + return slug, nil + } + return slug + "-" + randomSuffix(), nil + } + return typ + "-" + randomSuffix(), nil +} + +func (s *Service) ensureAgentBinding(ctx context.Context, req normalizedCreateRequest) (string, error) { + switch req.AgentBinding.Mode { + case BindingModeNone: + return "", nil + case BindingModeReuse: + if s.agents == nil { + return "", fmt.Errorf("agent service is required") + } + if _, ok := s.agents.Agent(req.AgentBinding.AgentID); !ok { + return "", fmt.Errorf("agent %q not found", req.AgentBinding.AgentID) + } + return req.AgentBinding.AgentID, nil + case BindingModeCreate: + if s.agents == nil { + return "", fmt.Errorf("agent service is required") + } + agentID := req.AgentBinding.AgentID + if agentID == "" { + agentID = defaultAgentID(req.ID) + } + if existing, ok := s.agents.Agent(agentID); ok { + return existing.ID, nil + } + spec := agent.CreateAgentSpec{} + if req.AgentBinding.Agent != nil { + spec = *req.AgentBinding.Agent + } + spec.ID = agentID + if strings.TrimSpace(spec.Name) == "" { + spec.Name = req.Name + } + if strings.TrimSpace(spec.Role) == "" { + spec.Role = agent.RoleWorker + } + created, err := s.agents.Create(ctx, agent.CreateRequest{Spec: spec}) + if err != nil { + return "", err + } + return created.ID, nil + default: + return "", fmt.Errorf("agent_binding.mode must be one of %q, %q, or %q", BindingModeCreate, BindingModeReuse, BindingModeNone) + } +} + +func (s *Service) ensureChannelIdentity(_ context.Context, req normalizedCreateRequest) error { + if req.Channel != ChannelCSGClaw || s.im == nil { + return nil + } + role := "member" + if req.Type == TypeAgent { + role = agent.RoleWorker + } + _, _, err := s.im.EnsureAgentUser(im.EnsureAgentUserRequest{ + ID: req.ChannelUser.Ref, + Name: req.Name, + Handle: req.ID, + Role: role, + }) + return err +} + +func normalizeChannel(channel string) string { + switch strings.ToLower(strings.TrimSpace(channel)) { + case "", ChannelCSGClaw: + return ChannelCSGClaw + case ChannelFeishu: + return ChannelFeishu + default: + return "" + } +} + +func normalizeType(typ string) string { + switch strings.ToLower(strings.TrimSpace(typ)) { + case TypeHuman: + return TypeHuman + case TypeAgent: + return TypeAgent + case TypeNotification: + return TypeNotification + default: + return "" + } +} + +func normalizeBindingMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case BindingModeCreate: + return BindingModeCreate + case BindingModeReuse: + return BindingModeReuse + case "", BindingModeNone: + return BindingModeNone + default: + return strings.ToLower(strings.TrimSpace(mode)) + } +} + +func defaultAgentID(participantID string) string { + return "u-" + strings.TrimSpace(participantID) +} + +func slugify(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + var b strings.Builder + lastDash := false + for _, r := range raw { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastDash = false + continue + } + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(unicode.ToLower(r)) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if len(out) > 48 { + out = strings.Trim(out[:48], "-") + } + if len(out) < 2 { + return "" + } + return out +} + +func randomSuffix() string { + var raw [5]byte + if _, err := rand.Read(raw[:]); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return strings.ToLower(strings.TrimRight(base32.StdEncoding.EncodeToString(raw[:]), "="))[:6] +} + +func cloneMetadata(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} diff --git a/internal/participant/service_test.go b/internal/participant/service_test.go new file mode 100644 index 00000000..3b7ff973 --- /dev/null +++ b/internal/participant/service_test.go @@ -0,0 +1,416 @@ +package participant + +import ( + "context" + "strings" + "testing" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/config" + "csgclaw/internal/im" + agentruntime "csgclaw/internal/runtime" + "csgclaw/internal/sandbox" + "csgclaw/internal/sandbox/sandboxtest" +) + +func TestCreateAgentParticipantUsesStableParticipantIDForDefaultAgentID(t *testing.T) { + agentSvc := mustNewAgentService(t) + imSvc := im.NewService() + store := NewMemoryStore(nil) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + created, err := svc.Create(context.Background(), CreateRequest{ + ID: "qa", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "QA Display Name", + ChannelUser: ChannelUserSpec{ + Ref: "u-qa", + Kind: ChannelUserKindLocalUserID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeCreate, + Agent: &agent.CreateAgentSpec{ + Name: "QA Display Name", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }, + }, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if created.ID != "qa" { + t.Fatalf("participant ID = %q, want qa", created.ID) + } + if created.AgentID != "u-qa" { + t.Fatalf("agent ID = %q, want u-qa", created.AgentID) + } + if _, ok := agentSvc.Agent("u-qa"); !ok { + t.Fatal("agent u-qa was not created") + } + if _, ok := agentSvc.Agent("u-qa-display-name"); ok { + t.Fatal("agent ID was derived from editable display name") + } + if user, ok := imSvc.User("u-qa"); !ok || !strings.EqualFold(user.Name, "QA Display Name") { + t.Fatalf("channel user = %+v, ok=%v; want u-qa display user", user, ok) + } +} + +func TestCreateAgentParticipantCanReuseExistingAgentWithDifferentParticipantID(t *testing.T) { + agentSvc := mustNewAgentService(t) + imSvc := im.NewService() + store := NewMemoryStore(nil) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + if _, err := agentSvc.Create(context.Background(), agent.CreateRequest{ + Spec: agent.CreateAgentSpec{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }, + }); err != nil { + t.Fatalf("seed agent: %v", err) + } + + created, err := svc.Create(context.Background(), CreateRequest{ + ID: "test", + Channel: ChannelFeishu, + Type: TypeAgent, + Name: "QA Feishu", + ChannelAppRef: "cli_xxx", + ChannelUser: ChannelUserSpec{ + Ref: "ou_xxx", + Kind: ChannelUserKindOpenID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeReuse, + AgentID: "u-qa", + }, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if created.ID != "test" || created.AgentID != "u-qa" { + t.Fatalf("created participant = %+v, want id test bound to u-qa", created) + } + if created.ChannelUserRef != "ou_xxx" || created.ChannelAppRef != "cli_xxx" { + t.Fatalf("created participant channel identity = %+v, want Feishu app/open_id scope", created) + } +} + +func TestEnsureBootstrapManagerUsesDefaultParticipantIDSeparateFromAgentID(t *testing.T) { + agentSvc := mustNewManagerAgentService(t) + imSvc := im.NewService() + store := NewMemoryStore(nil) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + created, err := svc.EnsureBootstrapManager(context.Background()) + if err != nil { + t.Fatalf("EnsureBootstrapManager() error = %v", err) + } + + if created.ID != agent.ManagerParticipantID { + t.Fatalf("participant ID = %q, want %q", created.ID, agent.ManagerParticipantID) + } + if created.AgentID != agent.ManagerUserID { + t.Fatalf("agent ID = %q, want %q", created.AgentID, agent.ManagerUserID) + } + if created.ChannelUserRef != agent.ManagerParticipantID { + t.Fatalf("channel user ref = %q, want %q", created.ChannelUserRef, agent.ManagerParticipantID) + } + if user, ok := imSvc.User(agent.ManagerParticipantID); !ok || user.ID != agent.ManagerParticipantID { + t.Fatalf("manager channel user = %+v, ok=%v; want local user %q", user, ok, agent.ManagerParticipantID) + } + if _, ok := store.Get(ChannelCSGClaw, agent.ManagerParticipantID); !ok { + t.Fatalf("store missing manager participant %q", agent.ManagerParticipantID) + } + if _, ok := store.Get(ChannelCSGClaw, agent.ManagerUserID); ok { + t.Fatalf("store still has manager participant under agent ID %q", agent.ManagerUserID) + } +} + +func TestEnsureBootstrapManagerRenamesLegacyManagerParticipant(t *testing.T) { + agentSvc := mustNewManagerAgentService(t) + imSvc := im.NewService() + createdAt := time.Date(2026, 6, 4, 14, 0, 7, 0, time.UTC) + store := NewMemoryStore([]Participant{{ + ID: agent.ManagerUserID, + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "manager", + Avatar: "avatar.png", + ChannelUserRef: agent.ManagerUserID, + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: map[string]any{"legacy": "kept"}, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }}) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + created, err := svc.EnsureBootstrapManager(context.Background()) + if err != nil { + t.Fatalf("EnsureBootstrapManager() error = %v", err) + } + + if created.ID != agent.ManagerParticipantID || created.AgentID != agent.ManagerUserID { + t.Fatalf("manager participant = %+v, want id %q bound to agent %q", created, agent.ManagerParticipantID, agent.ManagerUserID) + } + if !created.CreatedAt.Equal(createdAt) || created.Avatar != "avatar.png" || created.Metadata["legacy"] != "kept" { + t.Fatalf("manager participant did not preserve legacy fields: %+v", created) + } + if _, ok := store.Get(ChannelCSGClaw, agent.ManagerUserID); ok { + t.Fatalf("legacy manager participant %q was not deleted", agent.ManagerUserID) + } +} + +func TestEnsureBootstrapManagerDeletesMisspelledManagerParticipant(t *testing.T) { + agentSvc := mustNewManagerAgentService(t) + imSvc := im.NewService() + createdAt := time.Date(2026, 6, 4, 14, 0, 7, 0, time.UTC) + legacyID := "man" + "ger" + store := NewMemoryStore([]Participant{{ + ID: legacyID, + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "manager", + Avatar: "avatar.png", + ChannelUserRef: legacyID, + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: map[string]any{"legacy": "kept"}, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }}) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + created, err := svc.EnsureBootstrapManager(context.Background()) + if err != nil { + t.Fatalf("EnsureBootstrapManager() error = %v", err) + } + + if created.ID != agent.ManagerParticipantID || created.ChannelUserRef != agent.ManagerParticipantID || created.AgentID != agent.ManagerUserID { + t.Fatalf("manager participant = %+v, want manager participant bound to %q", created, agent.ManagerUserID) + } + if !created.CreatedAt.Equal(createdAt) || created.Avatar != "avatar.png" || created.Metadata["legacy"] != "kept" { + t.Fatalf("manager participant did not preserve legacy fields: %+v", created) + } + if _, ok := store.Get(ChannelCSGClaw, legacyID); ok { + t.Fatalf("legacy manager participant %q was not deleted", legacyID) + } +} + +func TestDeleteParticipantDoesNotDeleteAgentByDefault(t *testing.T) { + agentSvc := mustNewAgentService(t) + svc := NewService(NewMemoryStore(nil), WithAgentService(agentSvc), WithIMService(im.NewService())) + if _, err := agentSvc.Create(context.Background(), agent.CreateRequest{ + Spec: agent.CreateAgentSpec{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }, + }); err != nil { + t.Fatalf("seed agent: %v", err) + } + if _, err := svc.Create(context.Background(), CreateRequest{ + ID: "qa", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "QA", + ChannelUser: ChannelUserSpec{ + Ref: "u-qa", + Kind: ChannelUserKindLocalUserID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeReuse, + AgentID: "u-qa", + }, + }); err != nil { + t.Fatalf("Create() error = %v", err) + } + + if _, ok, err := svc.Delete(context.Background(), ChannelCSGClaw, "qa", DeleteOptions{}); err != nil || !ok { + t.Fatalf("Delete() ok=%v error=%v, want ok", ok, err) + } + if _, ok := svc.Get(ChannelCSGClaw, "qa"); ok { + t.Fatal("participant csgclaw:qa still exists after delete") + } + if _, ok := agentSvc.Agent("u-qa"); !ok { + t.Fatal("agent u-qa was deleted by default participant delete") + } +} + +func TestDeleteParticipantRejectsAgentCleanupWhenStillReferenced(t *testing.T) { + agentSvc := mustNewAgentService(t) + svc := NewService(NewMemoryStore(nil), WithAgentService(agentSvc), WithIMService(im.NewService())) + if _, err := agentSvc.Create(context.Background(), agent.CreateRequest{ + Spec: agent.CreateAgentSpec{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }, + }); err != nil { + t.Fatalf("seed agent: %v", err) + } + for _, req := range []CreateRequest{ + { + ID: "qa", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "QA", + ChannelUser: ChannelUserSpec{ + Ref: "u-qa", + Kind: ChannelUserKindLocalUserID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeReuse, + AgentID: "u-qa", + }, + }, + { + ID: "test", + Channel: ChannelFeishu, + Type: TypeAgent, + Name: "QA Feishu", + ChannelAppRef: "cli_xxx", + ChannelUser: ChannelUserSpec{ + Ref: "ou_xxx", + Kind: ChannelUserKindOpenID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeReuse, + AgentID: "u-qa", + }, + }, + } { + if _, err := svc.Create(context.Background(), req); err != nil { + t.Fatalf("Create(%s:%s) error = %v", req.Channel, req.ID, err) + } + } + + _, ok, err := svc.Delete(context.Background(), ChannelCSGClaw, "qa", DeleteOptions{DeleteAgent: DeleteAgentIfUnreferenced}) + if err == nil { + t.Fatal("Delete(delete_agent=if_unreferenced) error = nil, want referenced-agent error") + } + if ok { + t.Fatal("Delete(delete_agent=if_unreferenced) ok = true, want false when cleanup is rejected") + } + if _, exists := svc.Get(ChannelCSGClaw, "qa"); !exists { + t.Fatal("participant csgclaw:qa was deleted despite referenced-agent rejection") + } + if _, exists := agentSvc.Agent("u-qa"); !exists { + t.Fatal("agent u-qa was deleted despite referenced-agent rejection") + } +} + +func TestCreateHumanParticipantRejectsCreateAgentBinding(t *testing.T) { + svc := NewService(NewMemoryStore(nil)) + + _, err := svc.Create(context.Background(), CreateRequest{ + ID: "alice", + Channel: ChannelCSGClaw, + Type: TypeHuman, + Name: "Alice", + AgentBinding: AgentBindingSpec{ + Mode: BindingModeCreate, + }, + }) + if err == nil { + t.Fatal("Create() error = nil, want validation error") + } +} + +func mustNewAgentService(t *testing.T) *agent.Service { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) + + svc, err := agent.NewService( + config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk-test", + ModelID: "model-1", + }, + config.ServerConfig{}, + "manager-image:test", + "", + agent.WithRuntime(testRuntime{kind: agent.RuntimeKindPicoClawSandbox}), + ) + if err != nil { + t.Fatalf("agent.NewService() error = %v", err) + } + return svc +} + +func mustNewManagerAgentService(t *testing.T) *agent.Service { + t.Helper() + svc := mustNewAgentService(t) + agent.SetTestHooks( + func(_ *agent.Service, _ string) (sandbox.Runtime, error) { + return sandboxtest.NewRuntime(), nil + }, + func(_ *agent.Service, _ context.Context, _ sandbox.Runtime, _, name, _ string, _ agent.AgentProfile) (sandbox.Instance, sandbox.Info, error) { + info := sandbox.Info{ + ID: "box-" + name, + Name: name, + State: sandbox.StateRunning, + CreatedAt: time.Date(2026, 6, 4, 14, 0, 7, 0, time.UTC), + } + return sandboxtest.NewInstance(info), info, nil + }, + ) + t.Cleanup(agent.ResetTestHooks) + return svc +} + +type testRuntime struct { + kind string +} + +func (r testRuntime) Kind() string { + return r.kind +} + +func (testRuntime) WorkspaceRoot(agentHome string) string { + return agentHome +} + +func (testRuntime) New(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { + return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: spec.AgentID}, nil +} + +func (testRuntime) Start(context.Context, agentruntime.Handle) (agentruntime.State, error) { + return agentruntime.StateRunning, nil +} + +func (testRuntime) Stop(context.Context, agentruntime.Handle) (agentruntime.State, error) { + return agentruntime.StateStopped, nil +} + +func (testRuntime) Delete(context.Context, agentruntime.Handle) error { + return nil +} + +func (testRuntime) State(context.Context, agentruntime.Handle) (agentruntime.State, error) { + return agentruntime.StateRunning, nil +} + +func (testRuntime) Info(context.Context, agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{State: agentruntime.StateRunning, CreatedAt: time.Now().UTC()}, nil +} diff --git a/internal/participant/store.go b/internal/participant/store.go new file mode 100644 index 00000000..063da6ae --- /dev/null +++ b/internal/participant/store.go @@ -0,0 +1,419 @@ +package participant + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" +) + +type Store struct { + mu sync.RWMutex + path string + items map[string]apitypes.Participant +} + +type persistedState struct { + Participants []apitypes.Participant `json:"participants"` +} + +type legacyBotState struct { + Bots []apitypes.LegacyBot `json:"bots"` +} + +func NewStore(path string) (*Store, error) { + s := &Store{ + path: path, + items: make(map[string]apitypes.Participant), + } + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +func NewMemoryStore(items []apitypes.Participant) *Store { + s := &Store{items: make(map[string]apitypes.Participant)} + for _, item := range items { + item = normalizeStoredParticipant(item) + if item.Channel == "" || item.ID == "" { + continue + } + s.items[storeKey(item.Channel, item.ID)] = item + } + return s +} + +func (s *Store) List(opts ListOptions) []apitypes.Participant { + if s == nil { + return nil + } + channel := strings.TrimSpace(opts.Channel) + typ := strings.TrimSpace(opts.Type) + agentID := strings.TrimSpace(opts.AgentID) + + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]apitypes.Participant, 0, len(s.items)) + for _, item := range s.items { + if channel != "" && item.Channel != channel { + continue + } + if typ != "" && item.Type != typ { + continue + } + if agentID != "" && item.AgentID != agentID { + continue + } + out = append(out, cloneParticipant(item)) + } + sortParticipants(out) + return out +} + +func (s *Store) Get(channel, id string) (apitypes.Participant, bool) { + if s == nil { + return apitypes.Participant{}, false + } + s.mu.RLock() + defer s.mu.RUnlock() + item, ok := s.items[storeKey(channel, id)] + if !ok { + return apitypes.Participant{}, false + } + return cloneParticipant(item), true +} + +func (s *Store) Save(item apitypes.Participant) error { + if s == nil { + return fmt.Errorf("participant store is required") + } + item = normalizeStoredParticipant(item) + if item.Channel == "" { + return fmt.Errorf("channel is required") + } + if item.ID == "" { + return fmt.Errorf("id is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + s.items[storeKey(item.Channel, item.ID)] = item + return s.saveLocked() +} + +func (s *Store) Delete(channel, id string) (apitypes.Participant, bool, error) { + if s == nil { + return apitypes.Participant{}, false, fmt.Errorf("participant store is required") + } + channel = strings.TrimSpace(channel) + id = strings.TrimSpace(id) + if channel == "" || id == "" { + return apitypes.Participant{}, false, nil + } + + s.mu.Lock() + defer s.mu.Unlock() + key := storeKey(channel, id) + item, ok := s.items[key] + if !ok { + return apitypes.Participant{}, false, nil + } + delete(s.items, key) + if err := s.saveLocked(); err != nil { + s.items[key] = item + return apitypes.Participant{}, false, err + } + return cloneParticipant(item), true, nil +} + +func (s *Store) load() error { + items, err := s.readState() + if err != nil { + return err + } + legacyPath, legacyExists, err := mergeLegacyBotState(s.path, items) + if err != nil { + return err + } + s.items = items + if legacyExists { + if err := s.saveLocked(); err != nil { + return fmt.Errorf("write migrated participant state: %w", err) + } + if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete legacy bot state after participant migration: %w", err) + } + } + return nil +} + +func (s *Store) readState() (map[string]apitypes.Participant, error) { + items := make(map[string]apitypes.Participant) + if s.path == "" { + return items, nil + } + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return items, nil + } + return nil, fmt.Errorf("read participant state: %w", err) + } + var state persistedState + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("decode participant state: %w", err) + } + for _, item := range state.Participants { + item = normalizeStoredParticipant(item) + if item.Channel == "" || item.ID == "" { + return nil, fmt.Errorf("decode participant state: channel and id are required") + } + items[storeKey(item.Channel, item.ID)] = item + } + return items, nil +} + +func (s *Store) saveLocked() error { + if s.path == "" { + return nil + } + items := make([]apitypes.Participant, 0, len(s.items)) + for _, item := range s.items { + items = append(items, cloneParticipant(item)) + } + sortParticipants(items) + data, err := json.MarshalIndent(persistedState{Participants: items}, "", " ") + if err != nil { + return fmt.Errorf("encode participant state: %w", err) + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("create participant state dir: %w", err) + } + if err := os.WriteFile(s.path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write participant state: %w", err) + } + return nil +} + +func normalizeStoredParticipant(item apitypes.Participant) apitypes.Participant { + item.ID = strings.TrimSpace(item.ID) + item.Channel = strings.TrimSpace(item.Channel) + item.Type = strings.TrimSpace(item.Type) + item.Name = strings.TrimSpace(item.Name) + item.ChannelUserRef = strings.TrimSpace(item.ChannelUserRef) + item.ChannelUserKind = strings.TrimSpace(item.ChannelUserKind) + item.ChannelAppRef = strings.TrimSpace(item.ChannelAppRef) + item.AgentID = strings.TrimSpace(item.AgentID) + item.LifecycleStatus = strings.TrimSpace(item.LifecycleStatus) + item.Presence = strings.TrimSpace(item.Presence) + return item +} + +func sortParticipants(items []apitypes.Participant) { + slices.SortFunc(items, func(a, b apitypes.Participant) int { + if a.CreatedAt.Equal(b.CreatedAt) { + if a.Channel != b.Channel { + if a.Channel < b.Channel { + return -1 + } + return 1 + } + if a.ID < b.ID { + return -1 + } + if a.ID > b.ID { + return 1 + } + return 0 + } + if a.CreatedAt.Before(b.CreatedAt) { + return -1 + } + return 1 + }) +} + +func cloneParticipant(item apitypes.Participant) apitypes.Participant { + if item.Metadata != nil { + cloned := make(map[string]any, len(item.Metadata)) + for key, value := range item.Metadata { + cloned[key] = value + } + item.Metadata = cloned + } + return item +} + +func storeKey(channel, id string) string { + return strings.TrimSpace(channel) + "\x00" + strings.TrimSpace(id) +} + +func mergeLegacyBotState(participantPath string, items map[string]apitypes.Participant) (string, bool, error) { + if strings.TrimSpace(participantPath) == "" { + return "", false, nil + } + legacyPath := filepath.Join(filepath.Dir(participantPath), "bots.json") + data, err := os.ReadFile(legacyPath) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return legacyPath, true, fmt.Errorf("read legacy bot state: %w", err) + } + + var state legacyBotState + if err := json.Unmarshal(data, &state); err != nil { + return legacyPath, true, fmt.Errorf("decode legacy bot state: %w", err) + } + now := time.Now().UTC() + for _, b := range state.Bots { + item, err := participantFromLegacyBot(b, now) + if err != nil { + return legacyPath, true, fmt.Errorf("decode legacy bot state: %w", err) + } + key := storeKey(item.Channel, item.ID) + if _, exists := items[key]; exists { + continue + } + items[key] = item + } + return legacyPath, true, nil +} + +func participantFromLegacyBot(b apitypes.LegacyBot, now time.Time) (apitypes.Participant, error) { + legacyID := strings.TrimSpace(b.ID) + if legacyID == "" { + return apitypes.Participant{}, fmt.Errorf("id is required") + } + channel := normalizeChannel(b.Channel) + if channel == "" { + return apitypes.Participant{}, fmt.Errorf("channel must be one of %q or %q", ChannelCSGClaw, ChannelFeishu) + } + typ := TypeAgent + if strings.EqualFold(strings.TrimSpace(b.Type), TypeNotification) { + typ = TypeNotification + } + name := strings.TrimSpace(b.Name) + if name == "" { + name = legacyID + } + channelUserRef := strings.TrimSpace(b.UserID) + if channelUserRef == "" { + channelUserRef = strings.TrimSpace(b.AgentID) + } + if channelUserRef == "" { + channelUserRef = legacyID + } + channelUserKind := ChannelUserKindLocalUserID + if channel == ChannelFeishu { + channelUserKind = ChannelUserKindOpenID + } + agentID := strings.TrimSpace(b.AgentID) + if typ == TypeAgent && agentID == "" && strings.HasPrefix(legacyID, "u-") { + agentID = legacyID + } + if typ == TypeNotification { + agentID = "" + } + id := legacyID + if isLegacyCSGClawManagerBot(b, typ, channel, agentID) { + id = agent.ManagerParticipantID + channelUserRef = agent.ManagerParticipantID + if agentID == "" { + agentID = agent.ManagerUserID + } + } + createdAt := b.CreatedAt.UTC() + if createdAt.IsZero() { + createdAt = now + } + + return apitypes.Participant{ + ID: id, + Channel: channel, + Type: typ, + Name: name, + Avatar: strings.TrimSpace(b.Avatar), + ChannelUserRef: channelUserRef, + ChannelUserKind: channelUserKind, + AgentID: agentID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: legacyBotMetadata(b), + CreatedAt: createdAt, + UpdatedAt: createdAt, + }, nil +} + +func isLegacyCSGClawManagerBot(b apitypes.LegacyBot, typ, channel, agentID string) bool { + if channel != ChannelCSGClaw || typ != TypeAgent { + return false + } + if strings.TrimSpace(agentID) == agent.ManagerUserID { + return true + } + if strings.TrimSpace(b.ID) == agent.ManagerUserID { + return true + } + return strings.EqualFold(strings.TrimSpace(b.Role), agent.RoleManager) +} + +func legacyBotMetadata(b apitypes.LegacyBot) map[string]any { + metadata := cloneAnyMap(b.RuntimeOptions) + putMetadataString(metadata, "description", b.Description) + putMetadataString(metadata, "legacy_bot_type", b.Type) + putMetadataString(metadata, "legacy_role", b.Role) + putMetadataString(metadata, "legacy_runtime_kind", b.RuntimeKind) + putMetadataString(metadata, "legacy_image", b.Image) + putMetadataString(metadata, "legacy_status", b.Status) + putMetadataString(metadata, "legacy_provider", b.Provider) + putMetadataString(metadata, "legacy_model_id", b.ModelID) + if strings.TrimSpace(b.AgentID) != "" && strings.EqualFold(strings.TrimSpace(b.Type), TypeNotification) { + putMetadataString(metadata, "legacy_agent_id", b.AgentID) + } + metadata["legacy_available"] = b.Available + if b.ProfileComplete { + metadata["legacy_profile_complete"] = true + } + if b.EnvRestartRequired { + metadata["legacy_env_restart_required"] = true + } + if b.ImageUpgradeRequired { + metadata["legacy_image_upgrade_required"] = true + } + if len(metadata) == 0 { + return nil + } + return metadata +} + +func cloneAnyMap(src map[string]any) map[string]any { + if len(src) == 0 { + return map[string]any{} + } + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + +func putMetadataString(metadata map[string]any, key, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if _, exists := metadata[key]; exists { + return + } + metadata[key] = value +} diff --git a/internal/participant/store_test.go b/internal/participant/store_test.go new file mode 100644 index 00000000..35298147 --- /dev/null +++ b/internal/participant/store_test.go @@ -0,0 +1,112 @@ +package participant + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" +) + +func TestNewStoreMigratesLegacyBotsAndDeletesSource(t *testing.T) { + dir := t.TempDir() + participantsPath := filepath.Join(dir, "participants.json") + botsPath := filepath.Join(dir, "bots.json") + + createdAt := time.Date(2026, 6, 4, 14, 0, 7, 0, time.UTC) + writeJSONFile(t, participantsPath, persistedState{Participants: []apitypes.Participant{ + { + ID: "dev", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "dev", + ChannelUserRef: "u-dev", + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: "u-dev", + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + CreatedAt: createdAt.Add(time.Minute), + UpdatedAt: createdAt.Add(time.Minute), + }, + }}) + writeJSONFile(t, botsPath, legacyBotState{Bots: []apitypes.LegacyBot{ + { + ID: "u-manager", + Name: "manager", + Type: "normal", + Role: "manager", + Channel: ChannelCSGClaw, + AgentID: "u-manager", + UserID: "u-manager", + Available: true, + CreatedAt: createdAt, + }, + { + ID: "n-alerts", + Name: "alerts", + Type: TypeNotification, + Role: "worker", + Channel: ChannelCSGClaw, + UserID: "n-alerts", + RuntimeOptions: map[string]any{ + "delivery_mode": "webhook", + "webhook_token": "secret-token", + }, + CreatedAt: createdAt.Add(2 * time.Minute), + }, + }}) + + store, err := NewStore(participantsPath) + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + + manager, ok := store.Get(ChannelCSGClaw, agent.ManagerParticipantID) + if !ok { + t.Fatal("manager participant was not migrated from legacy bots.json") + } + if manager.Type != TypeAgent || manager.AgentID != agent.ManagerUserID || manager.ChannelUserRef != agent.ManagerParticipantID { + t.Fatalf("manager participant = %+v, want agent %q and channel user %q", manager, agent.ManagerUserID, agent.ManagerParticipantID) + } + if _, ok := store.Get(ChannelCSGClaw, "u-manager"); ok { + t.Fatal("manager participant was migrated under agent ID u-manager") + } + if manager.ChannelUserKind != ChannelUserKindLocalUserID || !manager.Mentionable || manager.LifecycleStatus != LifecycleStatusActive { + t.Fatalf("manager identity fields = %+v, want active mentionable local user", manager) + } + + notify, ok := store.Get(ChannelCSGClaw, "n-alerts") + if !ok { + t.Fatal("notification participant was not migrated from legacy bots.json") + } + if notify.Type != TypeNotification || notify.ChannelUserRef != "n-alerts" { + t.Fatalf("notification participant = %+v, want dedicated notification identity", notify) + } + if notify.Metadata["delivery_mode"] != "webhook" || notify.Metadata["webhook_token"] != "secret-token" { + t.Fatalf("notification metadata = %#v, want legacy runtime_options preserved", notify.Metadata) + } + + if _, err := os.Stat(botsPath); !os.IsNotExist(err) { + t.Fatalf("bots.json still exists after successful migration; stat err=%v", err) + } + if _, ok := store.Get(ChannelCSGClaw, "dev"); !ok { + t.Fatal("existing participant was not preserved during migration") + } +} + +func writeJSONFile(t *testing.T, path string, value any) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("MarshalIndent() error = %v", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } +} diff --git a/internal/runtime/openclawsandbox/config.go b/internal/runtime/openclawsandbox/config.go index 06b5749b..2ea8c581 100644 --- a/internal/runtime/openclawsandbox/config.go +++ b/internal/runtime/openclawsandbox/config.go @@ -43,12 +43,12 @@ func HostGatewayLogPath(agentHome string) string { return filepath.Join(Root(agentHome), "gateway.log") } -func EnsureConfig(agentHome, botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) (string, error) { +func EnsureConfig(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) (string, error) { hostRoot := Root(agentHome) if err := os.MkdirAll(hostRoot, 0o755); err != nil { return "", fmt.Errorf("create openclaw config dir: %w", err) } - data, err := renderConfig(botID, server, model, resolveBaseURL, feishuProvider) + data, err := renderConfig(participantID, agentID, server, model, resolveBaseURL, feishuProvider) if err != nil { return "", err } @@ -114,18 +114,26 @@ func writeExecApprovalsAllowAll(hostRoot string) error { return nil } -func renderConfig(botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) ([]byte, error) { +func renderConfig(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) ([]byte, error) { + participantID = strings.TrimSpace(participantID) + agentID = strings.TrimSpace(agentID) + if participantID == "" { + participantID = agentID + } + if agentID == "" { + agentID = participantID + } var cfg map[string]any if err := json.Unmarshal(defaultOpenClawGatewayConfig, &cfg); err != nil { return nil, fmt.Errorf("decode embedded openclaw config: %w", err) } - if err := updateOpenClawModelProvider(cfg, botID, server, model, resolveBaseURL); err != nil { + if err := updateOpenClawModelProvider(cfg, agentID, server, model, resolveBaseURL); err != nil { return nil, err } - if err := updateOpenClawCsgclawChannel(cfg, botID, server, resolveBaseURL); err != nil { + if err := updateOpenClawCsgclawChannel(cfg, participantID, server, resolveBaseURL); err != nil { return nil, err } - if err := updateOpenClawFeishuChannel(cfg, botID, feishuProvider); err != nil { + if err := updateOpenClawFeishuChannel(cfg, agentID, feishuProvider); err != nil { return nil, err } if err := updateOpenClawGatewayAuth(cfg, server); err != nil { @@ -202,7 +210,7 @@ func updateOpenClawPrimaryModel(cfg map[string]any, providerID, modelID string) return nil } -func updateOpenClawCsgclawChannel(cfg map[string]any, botID string, server config.ServerConfig, resolveBaseURL BaseURLResolver) error { +func updateOpenClawCsgclawChannel(cfg map[string]any, participantID string, server config.ServerConfig, resolveBaseURL BaseURLResolver) error { channels, ok := cfg["channels"].(map[string]any) if !ok { return fmt.Errorf("embedded openclaw config is missing channels") @@ -217,7 +225,7 @@ func updateOpenClawCsgclawChannel(cfg map[string]any, botID string, server confi if server.AccessToken != "" { ch["accessToken"] = server.AccessToken } - ch["botId"] = botID + ch["botId"] = participantID ch["enabled"] = true return nil } @@ -290,9 +298,9 @@ func managerBaseURL(server config.ServerConfig, resolveBaseURL BaseURLResolver) return strings.TrimRight(strings.TrimSpace(resolveBaseURL(server)), "/") } -func llmBridgeBaseURL(managerBaseURL, botID string) string { +func llmBridgeBaseURL(managerBaseURL, agentID string) string { managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") - return managerBaseURL + "/api/bots/" + strings.TrimSpace(botID) + "/llm" + return managerBaseURL + "/api/v1/agents/" + strings.TrimSpace(agentID) + "/llm" } func updateOpenClawGatewayAuth(cfg map[string]any, server config.ServerConfig) error { diff --git a/internal/runtime/openclawsandbox/config_test.go b/internal/runtime/openclawsandbox/config_test.go index b87f6927..83384ab1 100644 --- a/internal/runtime/openclawsandbox/config_test.go +++ b/internal/runtime/openclawsandbox/config_test.go @@ -10,7 +10,7 @@ import ( ) func TestRenderAgentOpenClawConfigUsesOpenAICompatForMinimaxBaseURL(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "gateway-shared-token", @@ -56,7 +56,7 @@ func TestRenderAgentOpenClawConfigUsesOpenAICompatForMinimaxBaseURL(t *testing.T } func TestRenderAgentOpenClawConfigUsesOpenAICompatForInfiniMaaS(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "gateway-shared-token", @@ -105,7 +105,7 @@ func TestRenderAgentOpenClawConfigUsesOpenAICompatForInfiniMaaS(t *testing.T) { } func TestRenderAgentOpenClawConfigUsesBridgeWhenBaseURLEmpty(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", @@ -116,7 +116,7 @@ func TestRenderAgentOpenClawConfigUsesBridgeWhenBaseURLEmpty(t *testing.T) { t.Fatalf("renderAgentOpenClawConfig() error = %v", err) } text := string(data) - if !strings.Contains(text, `http://127.0.0.1:18080/api/bots/u-manager/llm`) { + if !strings.Contains(text, `http://127.0.0.1:18080/api/v1/agents/u-manager/llm`) { t.Fatalf("expected CSGClaw LLM bridge URL in config:\n%s", text) } for _, placeholder := range []string{ @@ -132,8 +132,33 @@ func TestRenderAgentOpenClawConfigUsesBridgeWhenBaseURLEmpty(t *testing.T) { } } +func TestRenderAgentOpenClawConfigSplitsParticipantAndAgentID(t *testing.T) { + data, err := renderConfig("manager", "u-manager", config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "MiniMax-M2.7", + }, testBaseURLResolver, nil) + if err != nil { + t.Fatalf("renderAgentOpenClawConfig() error = %v", err) + } + text := string(data) + for _, want := range []string{ + `"botId": "manager"`, + `"baseUrl": "http://127.0.0.1:18080/api/v1/agents/u-manager/llm"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("rendered OpenClaw config missing %q:\n%s", want, text) + } + } + if strings.Contains(text, `/api/v1/agents/manager/llm`) { + t.Fatalf("rendered OpenClaw config used participant ID for LLM bridge:\n%s", text) + } +} + func TestRenderAgentOpenClawConfigDisablesStartupUpdateCheck(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", @@ -154,7 +179,7 @@ func TestRenderAgentOpenClawConfigDisablesStartupUpdateCheck(t *testing.T) { } func TestRenderAgentOpenClawConfigDefaultsCsgclawGroupsToMentionOnly(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", @@ -185,7 +210,7 @@ func TestRenderAgentOpenClawConfigDefaultsCsgclawGroupsToMentionOnly(t *testing. } func TestRenderAgentOpenClawConfigAddsFeishuChannelWhenConfigured(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", @@ -237,7 +262,7 @@ func TestRenderAgentOpenClawConfigAddsFeishuChannelWhenConfigured(t *testing.T) } func TestRenderAgentOpenClawConfigPassesThroughDockerHostAlias(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "0.0.0.0:18080", AdvertiseBaseURL: "http://host.docker.internal:18080", AccessToken: "shared-token", diff --git a/internal/runtime/openclawsandbox/runtime.go b/internal/runtime/openclawsandbox/runtime.go index 5c564dbf..f1b96e9d 100644 --- a/internal/runtime/openclawsandbox/runtime.go +++ b/internal/runtime/openclawsandbox/runtime.go @@ -67,7 +67,11 @@ func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest if agentHome == "" { return fmt.Errorf("gateway agent home is required") } - if _, err := EnsureConfig(agentHome, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { + participantID := strings.TrimSpace(req.ParticipantID) + if participantID == "" { + participantID = strings.TrimSpace(req.AgentID) + } + if _, err := EnsureConfig(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { return err } workspaceRoot := r.WorkspaceRoot(agentHome) diff --git a/internal/runtime/picoclawsandbox/config.go b/internal/runtime/picoclawsandbox/config.go index 7a106eb2..4b32d636 100644 --- a/internal/runtime/picoclawsandbox/config.go +++ b/internal/runtime/picoclawsandbox/config.go @@ -47,13 +47,13 @@ func WorkspaceConfigRoot(agentHome string) string { return Root(agentHome) } -func EnsureConfig(agentHome, botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) (string, error) { +func EnsureConfig(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) (string, error) { hostRoot := Root(agentHome) if err := os.MkdirAll(hostRoot, 0o755); err != nil { return "", fmt.Errorf("create picoclaw config dir: %w", err) } - data, err := RenderConfig(botID, server, model, resolveBaseURL) + data, err := RenderConfig(participantID, agentID, server, model, resolveBaseURL) if err != nil { return "", err } @@ -69,16 +69,24 @@ func EnsureConfig(agentHome, botID string, server config.ServerConfig, model con return hostRoot, nil } -func RenderConfig(botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) ([]byte, error) { +func RenderConfig(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) ([]byte, error) { + participantID = strings.TrimSpace(participantID) + agentID = strings.TrimSpace(agentID) + if participantID == "" { + participantID = agentID + } + if agentID == "" { + agentID = participantID + } var cfg map[string]any if err := json.Unmarshal(defaultGatewayConfig, &cfg); err != nil { return nil, fmt.Errorf("decode embedded manager picoclaw config: %w", err) } - if err := updateModelList(cfg, botID, server, model, resolveBaseURL); err != nil { + if err := updateModelList(cfg, agentID, server, model, resolveBaseURL); err != nil { return nil, err } - if err := updateCSGClawChannel(cfg, botID, server, resolveBaseURL); err != nil { + if err := updateCSGClawChannel(cfg, participantID, server, resolveBaseURL); err != nil { return nil, err } @@ -89,7 +97,7 @@ func RenderConfig(botID string, server config.ServerConfig, model config.ModelCo return data, nil } -func updateModelList(cfg map[string]any, botID string, server config.ServerConfig, modelCfg config.ModelConfig, resolveBaseURL BaseURLResolver) error { +func updateModelList(cfg map[string]any, agentID string, server config.ServerConfig, modelCfg config.ModelConfig, resolveBaseURL BaseURLResolver) error { modelList, ok := cfg["model_list"].([]any) if !ok || len(modelList) == 0 { return fmt.Errorf("embedded manager picoclaw config is missing model_list[0]") @@ -111,7 +119,7 @@ func updateModelList(cfg map[string]any, botID string, server config.ServerConfi } if managerBaseURL := managerBaseURL(server, resolveBaseURL); managerBaseURL != "" { - model["api_base"] = llmBridgeBaseURL(managerBaseURL, botID) + model["api_base"] = llmBridgeBaseURL(managerBaseURL, agentID) } if server.AccessToken != "" { model["api_key"] = server.AccessToken @@ -133,7 +141,7 @@ func BridgeModelID(modelID string) string { return "openai/" + modelID } -func updateCSGClawChannel(cfg map[string]any, botID string, server config.ServerConfig, resolveBaseURL BaseURLResolver) error { +func updateCSGClawChannel(cfg map[string]any, participantID string, server config.ServerConfig, resolveBaseURL BaseURLResolver) error { channels, ok := cfg["channels"].(map[string]any) if !ok { return fmt.Errorf("embedded manager picoclaw config is missing channels") @@ -148,7 +156,8 @@ func updateCSGClawChannel(cfg map[string]any, botID string, server config.Server if server.AccessToken != "" { channel["access_token"] = server.AccessToken } - channel["bot_id"] = botID + delete(channel, "bot_id") + channel["participant_id"] = participantID channel["enabled"] = true return nil } @@ -175,7 +184,7 @@ func managerBaseURL(server config.ServerConfig, resolveBaseURL BaseURLResolver) return strings.TrimRight(strings.TrimSpace(resolveBaseURL(server)), "/") } -func llmBridgeBaseURL(managerBaseURL, botID string) string { +func llmBridgeBaseURL(managerBaseURL, agentID string) string { managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") - return managerBaseURL + "/api/bots/" + strings.TrimSpace(botID) + "/llm" + return managerBaseURL + "/api/v1/agents/" + strings.TrimSpace(agentID) + "/llm" } diff --git a/internal/runtime/picoclawsandbox/config_test.go b/internal/runtime/picoclawsandbox/config_test.go new file mode 100644 index 00000000..094ec9c1 --- /dev/null +++ b/internal/runtime/picoclawsandbox/config_test.go @@ -0,0 +1,39 @@ +package picoclawsandbox + +import ( + "encoding/json" + "testing" + + "csgclaw/internal/config" +) + +func TestRenderConfigDisablesUnconfiguredFeishuChannel(t *testing.T) { + data, err := RenderConfig("u-manager", "u-manager", config.ServerConfig{ + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "gpt-5.5", + }, fixedBaseURL("http://127.0.0.1:18080")) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + var rendered struct { + Channels map[string]map[string]any `json:"channels"` + } + if err := json.Unmarshal(data, &rendered); err != nil { + t.Fatalf("RenderConfig() produced invalid JSON: %v", err) + } + feishu, ok := rendered.Channels["feishu"] + if !ok { + t.Fatalf("RenderConfig() missing channels.feishu in:\n%s", data) + } + if got, want := feishu["enabled"], false; got != want { + t.Fatalf("channels.feishu.enabled = %v, want %v in:\n%s", got, want, data) + } + if got, want := feishu["app_id"], ""; got != want { + t.Fatalf("channels.feishu.app_id = %q, want empty in:\n%s", got, data) + } + if got, want := feishu["app_secret"], ""; got != want { + t.Fatalf("channels.feishu.app_secret = %q, want empty in:\n%s", got, data) + } +} diff --git a/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json b/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json index f67cad86..ec35c593 100644 --- a/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json +++ b/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json @@ -36,7 +36,7 @@ "csgclaw": { "enabled": true, "base_url": "http://127.0.0.1:18080", - "bot_id": "u-manager", + "participant_id": "u-manager", "access_token": "your_access_token", "allow_from": [], "group_trigger": { @@ -45,9 +45,9 @@ "reasoning_channel_id": "" }, "feishu": { - "enabled": true, - "app_id": "bot_app_id", - "app_secret": "bot_app_secret", + "enabled": false, + "app_id": "", + "app_secret": "", "encrypt_key": "", "group_trigger": { "mention_only": true diff --git a/internal/runtime/picoclawsandbox/provision_test.go b/internal/runtime/picoclawsandbox/provision_test.go index 0857e4e2..6bc0144a 100644 --- a/internal/runtime/picoclawsandbox/provision_test.go +++ b/internal/runtime/picoclawsandbox/provision_test.go @@ -66,7 +66,7 @@ func TestGatewayCreateSpecMountsPicoClawRuntimeRoot(t *testing.T) { agentHome := t.TempDir() projectsRoot := t.TempDir() rt := New(Dependencies{ - BuildRuntimeEnv: func(_, _, _, _, _ string, _ feishu.BotCredentialProvider) map[string]string { + BuildRuntimeEnv: func(_, _, _, _, _, _ string, _ feishu.BotCredentialProvider) map[string]string { return map[string]string{} }, AddProfileEnv: func(map[string]string, map[string]string) {}, diff --git a/internal/runtime/picoclawsandbox/runtime.go b/internal/runtime/picoclawsandbox/runtime.go index 0dc712d3..025d7d54 100644 --- a/internal/runtime/picoclawsandbox/runtime.go +++ b/internal/runtime/picoclawsandbox/runtime.go @@ -71,7 +71,11 @@ func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest if agentHome == "" { return fmt.Errorf("gateway agent home is required") } - if _, err := EnsureConfig(agentHome, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL)); err != nil { + participantID := strings.TrimSpace(req.ParticipantID) + if participantID == "" { + participantID = strings.TrimSpace(req.AgentID) + } + if _, err := EnsureConfig(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL)); err != nil { return err } workspaceRoot := r.WorkspaceRoot(agentHome) diff --git a/internal/runtime/picoclawsandbox/runtime_channels_test.go b/internal/runtime/picoclawsandbox/runtime_channels_test.go index 24c58c93..2fbfdab2 100644 --- a/internal/runtime/picoclawsandbox/runtime_channels_test.go +++ b/internal/runtime/picoclawsandbox/runtime_channels_test.go @@ -17,9 +17,9 @@ func TestRuntimeSetFeishuProviderUpdatesGatewayCreateSpecEnv(t *testing.T) { "u-dev": {AppID: "old-app", AppSecret: "old-secret"}, }, }, - BuildRuntimeEnv: func(_, _, botID, _, _ string, provider feishu.BotCredentialProvider) map[string]string { - env := map[string]string{} - if app, ok := provider.BotConfig(botID); ok { + BuildRuntimeEnv: func(_, _, participantID, agentID, _, _ string, provider feishu.BotCredentialProvider) map[string]string { + env := map[string]string{"PARTICIPANT_ID": participantID} + if app, ok := provider.BotConfig(agentID); ok { env["APP_ID"] = app.AppID env["APP_SECRET"] = app.AppSecret } @@ -28,9 +28,10 @@ func TestRuntimeSetFeishuProviderUpdatesGatewayCreateSpecEnv(t *testing.T) { AddProfileEnv: func(envVars map[string]string, profileEnv map[string]string) {}, }) if err := rt.Provision(context.Background(), agentruntime.ProvisionRequest{ - RuntimeID: "rt-u-dev", - AgentID: "u-dev", - AgentName: "dev", + RuntimeID: "rt-u-dev", + AgentID: "u-dev", + ParticipantID: "dev", + AgentName: "dev", Gateway: &agentruntime.GatewayProvision{ ModelFallback: "model-1", Server: config.ServerConfig{AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "token"}, @@ -59,6 +60,9 @@ func TestRuntimeSetFeishuProviderUpdatesGatewayCreateSpecEnv(t *testing.T) { if got, want := spec.Env["APP_SECRET"], "new-secret"; got != want { t.Fatalf("APP_SECRET = %q, want %q", got, want) } + if got, want := spec.Env["PARTICIPANT_ID"], "dev"; got != want { + t.Fatalf("PARTICIPANT_ID = %q, want %q", got, want) + } } type feishuProviderStub struct { diff --git a/internal/runtime/provision.go b/internal/runtime/provision.go index 24145319..a14f60d2 100644 --- a/internal/runtime/provision.go +++ b/internal/runtime/provision.go @@ -26,6 +26,7 @@ type Provisioner interface { type ProvisionRequest struct { RuntimeID string AgentID string + ParticipantID string AgentName string Profile Profile WorkspaceOverlay string diff --git a/internal/runtime/sandboxgateway/runtime.go b/internal/runtime/sandboxgateway/runtime.go index dce471c7..d513ba48 100644 --- a/internal/runtime/sandboxgateway/runtime.go +++ b/internal/runtime/sandboxgateway/runtime.go @@ -52,7 +52,7 @@ type Dependencies struct { ResolveAgent func(h agentruntime.Handle) (AgentRef, error) SyncHandle func(h agentruntime.Handle) error - BuildRuntimeEnv func(baseURL, accessToken, botID, llmBaseURL, modelID string, feishuProvider feishu.BotCredentialProvider) map[string]string + BuildRuntimeEnv func(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string, feishuProvider feishu.BotCredentialProvider) map[string]string AddProfileEnv func(envVars map[string]string, profileEnv map[string]string) HomeEnv string MountGuestPath string @@ -339,13 +339,21 @@ func (r *Runtime) GatewayCreateSpec(image, name, botID string, profile agentrunt if err != nil { return sandbox.CreateSpec{}, err } + agentID := strings.TrimSpace(prepared.AgentID) + if agentID == "" { + agentID = strings.TrimSpace(botID) + } + participantID := strings.TrimSpace(prepared.ParticipantID) + if participantID == "" { + participantID = agentID + } modelID := prepared.ModelID managerBaseURL := strings.TrimRight(strings.TrimSpace(prepared.ManagerBaseURL), "/") - llmBaseURL := llmBridgeBaseURL(managerBaseURL, botID) + llmBaseURL := llmBridgeBaseURL(managerBaseURL, agentID) profile = prepared.Profile workspaceLayout := prepared.WorkspaceLayout projectsRoot := prepared.ProjectsRoot - envVars := r.deps.BuildRuntimeEnv(managerBaseURL, prepared.Server.AccessToken, botID, llmBaseURL, modelID, r.CurrentFeishuProvider()) + envVars := r.deps.BuildRuntimeEnv(managerBaseURL, prepared.Server.AccessToken, participantID, agentID, llmBaseURL, modelID, r.CurrentFeishuProvider()) r.deps.AddProfileEnv(envVars, profile.Env) homeEnv := r.homeEnv() projectsGuestPath := r.projectsGuestPath() @@ -391,6 +399,8 @@ func (r *Runtime) GatewayCreateSpec(image, name, botID string, profile agentrunt } type PreparedGatewayProvision struct { + AgentID string + ParticipantID string ModelID string Profile agentruntime.Profile WorkspaceLayout WorkspaceLayout @@ -401,9 +411,14 @@ type PreparedGatewayProvision struct { func FinalizePreparedGatewayProvision(req agentruntime.ProvisionRequest, workspaceLayout WorkspaceLayout) (PreparedGatewayProvision, error) { name := strings.TrimSpace(req.AgentName) - if name == "" || strings.TrimSpace(req.AgentID) == "" { + agentID := strings.TrimSpace(req.AgentID) + if name == "" || agentID == "" { return PreparedGatewayProvision{}, fmt.Errorf("runtime agent name and id are required") } + participantID := strings.TrimSpace(req.ParticipantID) + if participantID == "" { + participantID = agentID + } gateway := req.Gateway if gateway == nil { return PreparedGatewayProvision{}, fmt.Errorf("gateway provisioning data is required") @@ -421,6 +436,8 @@ func FinalizePreparedGatewayProvision(req agentruntime.ProvisionRequest, workspa } } return PreparedGatewayProvision{ + AgentID: agentID, + ParticipantID: participantID, ModelID: modelID, Profile: profile, WorkspaceLayout: workspaceLayout, @@ -677,7 +694,7 @@ func stateFromSandboxState(state sandbox.State) agentruntime.State { } } -func llmBridgeBaseURL(managerBaseURL, botID string) string { +func llmBridgeBaseURL(managerBaseURL, agentID string) string { managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") - return managerBaseURL + "/api/bots/" + strings.TrimSpace(botID) + "/llm" + return managerBaseURL + "/api/v1/agents/" + strings.TrimSpace(agentID) + "/llm" } diff --git a/internal/runtime/sandboxgateway/runtime_test.go b/internal/runtime/sandboxgateway/runtime_test.go index f866c1ed..e4052a4c 100644 --- a/internal/runtime/sandboxgateway/runtime_test.go +++ b/internal/runtime/sandboxgateway/runtime_test.go @@ -177,7 +177,7 @@ func testGatewayDeps(providerName func() string, run func(context.Context, sandb SyncHandle: func(agentruntime.Handle) error { return nil }, - BuildRuntimeEnv: func(string, string, string, string, string, feishu.BotCredentialProvider) map[string]string { + BuildRuntimeEnv: func(string, string, string, string, string, string, feishu.BotCredentialProvider) map[string]string { return map[string]string{} }, AddProfileEnv: func(map[string]string, map[string]string) {}, diff --git a/internal/server/accesslog_test.go b/internal/server/accesslog_test.go index e0eea99f..1f261615 100644 --- a/internal/server/accesslog_test.go +++ b/internal/server/accesslog_test.go @@ -19,7 +19,7 @@ func TestAccessLogCapturesImplicitOK(t *testing.T) { _, _ = w.Write([]byte("ok")) })) - req := httptest.NewRequest(http.MethodGet, "/api/v1/users?ready=1", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/users?ready=1", nil) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("User-Agent", "test-agent") rec := httptest.NewRecorder() @@ -33,7 +33,7 @@ func TestAccessLogCapturesImplicitOK(t *testing.T) { if !strings.Contains(logLine, "method=GET") { t.Fatalf("expected method in log, got %q", logLine) } - if !strings.Contains(logLine, "uri=\"/api/v1/users?ready=1\"") { + if !strings.Contains(logLine, "uri=\"/api/v1/channels/csgclaw/users?ready=1\"") { t.Fatalf("expected uri in log, got %q", logLine) } if !strings.Contains(logLine, "status=200") { @@ -52,7 +52,7 @@ func TestAccessLogCapturesExplicitStatus(t *testing.T) { })) rec := httptest.NewRecorder() - handler.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/v1/users/u-1", nil)) + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/users/u-1", nil)) logLine := buf.String() if !strings.Contains(logLine, "status=204") { diff --git a/internal/server/http.go b/internal/server/http.go index d71e182d..f1d1785c 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -11,38 +11,39 @@ import ( "csgclaw/internal/agent" "csgclaw/internal/api" - "csgclaw/internal/bot" "csgclaw/internal/channel/feishu" "csgclaw/internal/hub" "csgclaw/internal/im" "csgclaw/internal/llm" + "csgclaw/internal/participant" "csgclaw/internal/team" "csgclaw/internal/upgrade" ) type Options struct { - ListenAddr string - Service *agent.Service - Hub *hub.Service - Bot *bot.Service - IM *im.Service - IMBus *im.Bus - BotBridge *im.BotBridge - Feishu *feishu.Service - LLM *llm.Service - Team *team.Service - TeamAdapter team.TeamChannelAdapter - Upgrade *upgrade.Manager - ActivityDecider api.ActivityDecider - ConfigPath string - AccessToken string - NoAuth bool - Context context.Context - OnReady func(h *api.Handler, router chi.Router) + ListenAddr string + Service *agent.Service + Hub *hub.Service + Participant *participant.Service + IM *im.Service + IMBus *im.Bus + ParticipantBridge *im.ParticipantBridge + Feishu *feishu.Service + LLM *llm.Service + Team *team.Service + TeamAdapter team.TeamChannelAdapter + Upgrade *upgrade.Manager + ActivityDecider api.ActivityDecider + ConfigPath string + AccessToken string + NoAuth bool + Context context.Context + OnReady func(h *api.Handler, router chi.Router) } func newHandler(opts Options) *api.Handler { - handler := api.NewHandlerWithBotAndAuth(opts.Service, opts.Bot, opts.IM, opts.IMBus, opts.BotBridge, opts.Feishu, opts.LLM, opts.AccessToken, opts.NoAuth) + handler := api.NewHandlerWithAuth(opts.Service, opts.IM, opts.IMBus, opts.ParticipantBridge, opts.Feishu, opts.LLM, opts.AccessToken, opts.NoAuth) + handler.SetParticipantService(opts.Participant) handler.SetHubService(opts.Hub) handler.SetTeamService(opts.Team) handler.SetTeamAdapter(opts.TeamAdapter) @@ -59,7 +60,7 @@ func Run(opts Options) error { } handler := newHandler(opts) router := handler.Routes() - router.Handle("/*", uiHandler()) + router.Handle("/*", uiFallbackHandler()) httpServer := &http.Server{ Addr: opts.ListenAddr, @@ -67,7 +68,7 @@ func Run(opts Options) error { ReadHeaderTimeout: 5 * time.Second, } - if opts.IMBus != nil && opts.BotBridge != nil { + if opts.IMBus != nil && opts.ParticipantBridge != nil { events, cancel := opts.IMBus.Subscribe() defer cancel() @@ -80,7 +81,7 @@ func Run(opts Options) error { if !ok { return } - handler.PublishBotEvent(evt) + handler.PublishParticipantEvent(evt) } } }() diff --git a/internal/server/ui.go b/internal/server/ui.go index a0745daf..38bae83b 100644 --- a/internal/server/ui.go +++ b/internal/server/ui.go @@ -2,6 +2,7 @@ package server import ( "net/http" + "strings" webui "csgclaw/web" ) @@ -9,3 +10,20 @@ import ( func uiHandler() http.Handler { return webui.Handler() } + +func uiFallbackHandler() http.Handler { + return apiAwareFallbackHandler(uiHandler()) +} + +func apiAwareFallbackHandler(ui http.Handler) http.Handler { + if ui == nil { + ui = http.NotFoundHandler() + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + ui.ServeHTTP(w, r) + }) +} diff --git a/internal/server/ui_test.go b/internal/server/ui_test.go new file mode 100644 index 00000000..b452ca93 --- /dev/null +++ b/internal/server/ui_test.go @@ -0,0 +1,45 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAPIAwareFallbackRejectsUnknownAPIPaths(t *testing.T) { + handler := apiAwareFallbackHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ui")) + })) + + tests := []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/api"}, + {method: http.MethodGet, path: "/api/unknown/u-manager/events"}, + {method: http.MethodPost, path: "/api/v1/channels/csgclaw/bots"}, + } + + for _, tt := range tests { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("%s %s status = %d, want %d", tt.method, tt.path, rec.Code, http.StatusNotFound) + } + } +} + +func TestAPIAwareFallbackServesUIPaths(t *testing.T) { + handler := apiAwareFallbackHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ui")) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/workspace", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if rec.Body.String() != "ui" { + t.Fatalf("body = %q, want %q", rec.Body.String(), "ui") + } +} diff --git a/internal/team/bot_id.go b/internal/team/bot_id.go deleted file mode 100644 index 611c73d2..00000000 --- a/internal/team/bot_id.go +++ /dev/null @@ -1,32 +0,0 @@ -package team - -import ( - "fmt" - "strings" -) - -func cleanBotID(id string) string { - return strings.TrimSpace(id) -} - -func requireCanonicalBotID(field, id string) (string, error) { - id = cleanBotID(id) - if id == "" { - return "", nil - } - if !strings.HasPrefix(id, "u-") { - return "", invalidCanonicalBotIDError(field, id) - } - return id, nil -} - -func invalidCanonicalBotIDError(field, id string) error { - return fmt.Errorf("%s must be a canonical user id starting with u-: %s", field, id) -} - -// BotIDsMatch reports whether two bot ids refer to the same stored user id. -func BotIDsMatch(left, right string) bool { - left = cleanBotID(left) - right = cleanBotID(right) - return left != "" && left == right -} diff --git a/internal/team/bot_id_test.go b/internal/team/bot_id_test.go deleted file mode 100644 index cebd6d4a..00000000 --- a/internal/team/bot_id_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package team - -import "testing" - -func TestCleanBotIDDoesNotInferCanonicalID(t *testing.T) { - if got := cleanBotID(" p-w-0604 "); got != "p-w-0604" { - t.Fatalf("cleanBotID() = %q, want p-w-0604", got) - } - if BotIDsMatch("u-p-w-0604", "p-w-0604") { - t.Fatal("BotIDsMatch() = true, want false for distinct stored ids") - } -} diff --git a/internal/team/csgclaw_adapter.go b/internal/team/csgclaw_adapter.go index ab31971c..f55ece91 100644 --- a/internal/team/csgclaw_adapter.go +++ b/internal/team/csgclaw_adapter.go @@ -145,12 +145,12 @@ func (a *CSGClawAdapter) SendMessage(_ context.Context, req SendMessageRequest) func (a *CSGClawAdapter) ensureBotUser(botID string, role string) (im.User, error) { var err error - botID, err = requireCanonicalBotID("bot_id", botID) + botID, err = requireCanonicalParticipantID("bot_id", botID) if err != nil { return im.User{}, err } if botID == "" { - return im.User{}, fmt.Errorf("bot id is required") + return im.User{}, fmt.Errorf("participant id is required") } user, _, err := a.im.EnsureAgentUser(im.EnsureAgentUserRequest{ ID: botID, diff --git a/internal/team/model.go b/internal/team/model.go index ade9b6d5..9e94852d 100644 --- a/internal/team/model.go +++ b/internal/team/model.go @@ -43,7 +43,7 @@ type TeamMeta struct { UpdatedAt time.Time `json:"updated_at"` } -// MemberPresence captures runtime state derived from room members plus bot identity. +// MemberPresence captures runtime state derived from room members plus participant identity. type MemberPresence struct { TeamID string `json:"team_id"` BotID string `json:"bot_id"` diff --git a/internal/team/participant_id.go b/internal/team/participant_id.go new file mode 100644 index 00000000..36027a09 --- /dev/null +++ b/internal/team/participant_id.go @@ -0,0 +1,32 @@ +package team + +import ( + "fmt" + "strings" +) + +func cleanParticipantID(id string) string { + return strings.TrimSpace(id) +} + +func requireCanonicalParticipantID(field, id string) (string, error) { + id = cleanParticipantID(id) + if id == "" { + return "", nil + } + if !strings.HasPrefix(id, "u-") { + return "", invalidCanonicalParticipantIDError(field, id) + } + return id, nil +} + +func invalidCanonicalParticipantIDError(field, id string) error { + return fmt.Errorf("%s must be a canonical user id starting with u-: %s", field, id) +} + +// ParticipantIDsMatch reports whether two participant ids refer to the same stored user id. +func ParticipantIDsMatch(left, right string) bool { + left = cleanParticipantID(left) + right = cleanParticipantID(right) + return left != "" && left == right +} diff --git a/internal/team/participant_id_test.go b/internal/team/participant_id_test.go new file mode 100644 index 00000000..5e2d51f0 --- /dev/null +++ b/internal/team/participant_id_test.go @@ -0,0 +1,12 @@ +package team + +import "testing" + +func TestCleanBotIDDoesNotInferCanonicalID(t *testing.T) { + if got := cleanParticipantID(" p-w-0604 "); got != "p-w-0604" { + t.Fatalf("cleanParticipantID() = %q, want p-w-0604", got) + } + if ParticipantIDsMatch("u-p-w-0604", "p-w-0604") { + t.Fatal("ParticipantIDsMatch() = true, want false for distinct stored ids") + } +} diff --git a/internal/team/service.go b/internal/team/service.go index c1bee1e7..6c757073 100644 --- a/internal/team/service.go +++ b/internal/team/service.go @@ -277,7 +277,7 @@ func (s *Service) CreateTeam(input CreateTeamInput) (TeamMeta, error) { if strings.TrimSpace(input.LeadBotID) == "" { return TeamMeta{}, fmt.Errorf("lead_bot_id is required") } - leadBotID, err := requireCanonicalBotID("lead_bot_id", input.LeadBotID) + leadBotID, err := requireCanonicalParticipantID("lead_bot_id", input.LeadBotID) if err != nil { return TeamMeta{}, err } @@ -388,7 +388,7 @@ func (s *Service) CreateTask(input CreateTaskInput) (TeamTask, error) { if err := s.validateDependsOnLocked(input.TeamID, input.DependsOn); err != nil { return TeamTask{}, err } - if _, err := requireCanonicalBotID("assign_to", input.AssignTo); err != nil { + if _, err := requireCanonicalParticipantID("assign_to", input.AssignTo); err != nil { return TeamTask{}, err } @@ -436,7 +436,7 @@ func (s *Service) CreateTasks(input CreateTaskBatchInput) (CreateTasksResult, er if strings.TrimSpace(item.Title) == "" { return CreateTasksResult{}, fmt.Errorf("tasks[%d].title is required", i) } - if _, err := requireCanonicalBotID("assign_to", item.AssignTo); err != nil { + if _, err := requireCanonicalParticipantID("assign_to", item.AssignTo); err != nil { return CreateTasksResult{}, fmt.Errorf("tasks[%d].assign_to: %w", i, err) } idRef := strings.TrimSpace(item.IDRef) @@ -591,7 +591,7 @@ func (s *Service) PlanTask(input PlanTaskInput) (PlanTaskResult, error) { result := PlanTaskResult{AlreadyPlanned: false} for i, item := range input.Tasks { assignTo := firstNonEmpty(strings.TrimSpace(item.AssignTo), fallbackPlanAssignee(task.AssignedTo, meta)) - if _, err := requireCanonicalBotID("assign_to", assignTo); err != nil { + if _, err := requireCanonicalParticipantID("assign_to", assignTo); err != nil { return PlanTaskResult{}, fmt.Errorf("plan tasks[%d].assign_to: %w", i, err) } child := s.newTaskLocked(meta, CreateTaskInput{ @@ -765,7 +765,7 @@ func taskAssignedToManager(assignedTo string, teamMeta TeamMeta) bool { return false } if leadBotID := strings.TrimSpace(teamMeta.LeadBotID); leadBotID != "" { - return BotIDsMatch(assignedTo, leadBotID) + return ParticipantIDsMatch(assignedTo, leadBotID) } return assignedTo == "u-manager" } @@ -808,7 +808,7 @@ func (s *Service) AssignTask(input AssignTaskInput) (TeamTask, error) { if strings.TrimSpace(input.AssignedTo) == "" { return TeamTask{}, fmt.Errorf("assigned_to is required") } - assignedTo, err := requireCanonicalBotID("assigned_to", input.AssignedTo) + assignedTo, err := requireCanonicalParticipantID("assigned_to", input.AssignedTo) if err != nil { return TeamTask{}, err } @@ -852,7 +852,7 @@ func (s *Service) ClaimTask(input ClaimTaskInput) (TeamTask, error) { } before := s.captureTeamStateLocked(meta.ID) eventStart := len(s.events[meta.ID]) - botID, err := requireCanonicalBotID("bot_id", input.BotID) + botID, err := requireCanonicalParticipantID("bot_id", input.BotID) if err != nil { return TeamTask{}, err } @@ -870,7 +870,7 @@ func (s *Service) ClaimNext(teamID string, botID string) (TeamTask, error) { s.mu.Lock() defer s.mu.Unlock() - botID, err := requireCanonicalBotID("bot_id", botID) + botID, err := requireCanonicalParticipantID("bot_id", botID) if err != nil { return TeamTask{}, err } @@ -966,7 +966,7 @@ func (s *Service) bestClaimCandidateLocked(teamID string, botID string) *TeamTas if strings.TrimSpace(task.ParentID) != "" && task.DispatchedAt == nil { continue } - if strings.TrimSpace(task.AssignedTo) != "" && !BotIDsMatch(task.AssignedTo, botID) { + if strings.TrimSpace(task.AssignedTo) != "" && !ParticipantIDsMatch(task.AssignedTo, botID) { continue } if !s.dependenciesCompletedLocked(teamID, task.DependsOn) { @@ -1368,7 +1368,7 @@ func (s *Service) GetPresence(teamID string, botID string) (MemberPresence, bool s.mu.Lock() defer s.mu.Unlock() - botID = cleanBotID(botID) + botID = cleanParticipantID(botID) if botID == "" { return MemberPresence{}, false } @@ -1396,7 +1396,7 @@ func (s *Service) UpsertPresence(input UpsertPresenceInput) (MemberPresence, err if err != nil { return MemberPresence{}, err } - botID, err := requireCanonicalBotID("bot_id", input.BotID) + botID, err := requireCanonicalParticipantID("bot_id", input.BotID) if err != nil { return MemberPresence{}, err } @@ -1416,7 +1416,7 @@ func (s *Service) UpsertPresence(input UpsertPresenceInput) (MemberPresence, err role := strings.TrimSpace(input.Role) if role == "" { role = "worker" - if BotIDsMatch(botID, meta.LeadBotID) { + if ParticipantIDsMatch(botID, meta.LeadBotID) { role = "manager" } } @@ -1522,7 +1522,7 @@ func (s *Service) blockTask(input UpdateTaskStatusInput) (TeamTask, error) { } func (s *Service) claimableLocked(meta TeamMeta, task *TeamTask, botID string) error { - botID = cleanBotID(botID) + botID = cleanParticipantID(botID) if botID == "" { return fmt.Errorf("bot_id is required") } @@ -1537,7 +1537,7 @@ func (s *Service) claimableLocked(meta TeamMeta, task *TeamTask, botID string) e if strings.TrimSpace(task.ParentID) != "" && task.DispatchedAt == nil { return fmt.Errorf("%w: task %s has not been dispatched", ErrTaskNotClaimable, task.ID) } - if task.AssignedTo != "" && !BotIDsMatch(task.AssignedTo, botID) { + if task.AssignedTo != "" && !ParticipantIDsMatch(task.AssignedTo, botID) { return fmt.Errorf("%w: task %s is assigned to %s", ErrTaskNotClaimable, task.ID, task.AssignedTo) } if !s.dependenciesCompletedLocked(meta.ID, task.DependsOn) { @@ -1621,7 +1621,7 @@ func (s *Service) readyChildrenCountLocked(meta TeamMeta, parentID string) int { default: continue } - if cleanBotID(child.AssignedTo) == "" { + if cleanParticipantID(child.AssignedTo) == "" { continue } if !s.dependenciesCompletedLocked(meta.ID, child.DependsOn) { @@ -1648,7 +1648,7 @@ func (s *Service) dispatchReadyChildrenLocked(meta TeamMeta, parentID string, ac default: continue } - assignee := cleanBotID(child.AssignedTo) + assignee := cleanParticipantID(child.AssignedTo) if assignee == "" { continue } @@ -1703,7 +1703,7 @@ func (s *Service) aggregateChildResultsLocked(teamID string, parentID string) st } func (s *Service) claimLocked(meta TeamMeta, task *TeamTask, botID string) { - botID = cleanBotID(botID) + botID = cleanParticipantID(botID) now := s.now() task.Status = TaskStatusInProgress task.ClaimedBy = botID @@ -1759,7 +1759,7 @@ func (s *Service) requireTaskOperatorLocked(meta TeamMeta, task *TeamTask, actor if actorID == "" { return fmt.Errorf("actor_id is required") } - if BotIDsMatch(actorID, meta.LeadBotID) || BotIDsMatch(actorID, task.ClaimedBy) { + if ParticipantIDsMatch(actorID, meta.LeadBotID) || ParticipantIDsMatch(actorID, task.ClaimedBy) { return nil } return fmt.Errorf("actor %q cannot operate task %s", actorID, task.ID) @@ -1822,9 +1822,9 @@ func (s *Service) dependenciesCompletedLocked(teamID string, dependsOn []string) } func (s *Service) ensureWorkerFreeLocked(teamID string, botID string) error { - botID = cleanBotID(botID) + botID = cleanParticipantID(botID) for _, task := range s.tasksForTeamLocked(teamID) { - if task.Status == TaskStatusInProgress && BotIDsMatch(task.ClaimedBy, botID) { + if task.Status == TaskStatusInProgress && ParticipantIDsMatch(task.ClaimedBy, botID) { return fmt.Errorf("%w: %s", ErrWorkerAlreadyBusy, botID) } } @@ -1853,7 +1853,7 @@ func (s *Service) newTaskLocked(meta TeamMeta, input CreateTaskInput) *TeamTask Body: strings.TrimSpace(input.Body), Status: status, CreatedBy: strings.TrimSpace(input.CreatedBy), - AssignedTo: cleanBotID(input.AssignTo), + AssignedTo: cleanParticipantID(input.AssignTo), DependsOn: cloneStrings(input.DependsOn), Priority: input.Priority, DeadlineAt: cloneTimePtr(input.DeadlineAt), @@ -2024,7 +2024,7 @@ func (s *Service) snapshotTeamLocked(teamID string) teamSnapshot { } func (s *Service) markPresenceDirtyLocked(teamID string, botID string) { - botID = cleanBotID(botID) + botID = cleanParticipantID(botID) if s.dirtyPresence[teamID] == nil { s.dirtyPresence[teamID] = make(map[string]struct{}) } @@ -2042,8 +2042,8 @@ func (s *Service) loadStoreState() error { taskMap := make(map[string]*TeamTask, len(snapshot.Tasks)) for _, task := range snapshot.Tasks { taskCopy := cloneTask(task) - taskCopy.AssignedTo = cleanBotID(taskCopy.AssignedTo) - taskCopy.ClaimedBy = cleanBotID(taskCopy.ClaimedBy) + taskCopy.AssignedTo = cleanParticipantID(taskCopy.AssignedTo) + taskCopy.ClaimedBy = cleanParticipantID(taskCopy.ClaimedBy) taskMap[task.ID] = &taskCopy s.bumpTaskIdentifierLocked(task.ID) } @@ -2058,7 +2058,7 @@ func (s *Service) loadStoreState() error { presenceMap := make(map[string]*MemberPresence, len(snapshot.Presence)) for _, p := range snapshot.Presence { pCopy := clonePresence(p) - pCopy.BotID = cleanBotID(pCopy.BotID) + pCopy.BotID = cleanParticipantID(pCopy.BotID) presenceMap[pCopy.BotID] = &pCopy } s.presence[teamID] = presenceMap @@ -2124,7 +2124,7 @@ func (s *Service) updatePresenceForTaskLocked(meta TeamMeta, task *TeamTask, sta } func (s *Service) updatePresenceLocked(meta TeamMeta, botID string, state string, currentTaskID string, summary string) { - botID = cleanBotID(botID) + botID = cleanParticipantID(botID) if botID == "" { return } @@ -2143,7 +2143,7 @@ func (s *Service) updatePresenceLocked(meta TeamMeta, botID string, state string p.Summary = strings.TrimSpace(summary) p.LastHeartbeatAt = now p.UpdatedAt = now - if BotIDsMatch(botID, meta.LeadBotID) { + if ParticipantIDsMatch(botID, meta.LeadBotID) { p.Role = "manager" } s.markPresenceDirtyLocked(meta.ID, botID) diff --git a/internal/templates/embed/openclaw-manager/workspace/AGENTS.md b/internal/templates/embed/openclaw-manager/workspace/AGENTS.md index dfb8aa04..e1495fda 100644 --- a/internal/templates/embed/openclaw-manager/workspace/AGENTS.md +++ b/internal/templates/embed/openclaw-manager/workspace/AGENTS.md @@ -19,7 +19,7 @@ first-run hatch or identity onboarding unless the user explicitly asks for it. ## Role -You are an OpenClaw manager bot connected to CSGClaw. Orchestrate work, +You are an OpenClaw manager agent connected to CSGClaw. Orchestrate work, dispatch to workers when appropriate, and handle direct requests when manager execution is the right path. Stay practical, accurate, and concise. @@ -32,7 +32,7 @@ task or command** (for example: "你好", "hi", "hello", "help", "你能做什 1. Do **not** run `csgclaw-cli`, load dispatch skills, or start tool-heavy work yet. 2. Reply warmly and briefly in the **user's language**. -3. Introduce yourself as the **CSGClaw manager** — the coordinator for bots, +3. Introduce yourself as the **CSGClaw manager** — the coordinator for agents, workers, rooms, and task handoff in this workspace. 4. Summarize what you can help with, with **short example prompts** the user can copy or adapt. @@ -44,7 +44,7 @@ Suggested capability bullets (pick 3–4 that fit; keep the whole reply concise) e.g. "帮我创建一个 GitLab worker" - **Assign work** to existing workers in IM rooms and track multi-step handoffs — e.g. "把登录页 UI 交给 frontend worker 做" -- **Manage bots and rooms** — list workers, create rooms or Feishu groups, add +- **Manage participants and rooms** — list workers, create rooms or Feishu groups, add members — e.g. "列出当前所有 worker" - **Answer CSGClaw usage questions** — explain the manager vs worker model when asked @@ -76,15 +76,15 @@ or identity onboarding. treat `` as the required skill slug and the remaining text as the task instruction. - Prefer local workspace skills over external discovery. - **Agent creation first:** if the user wants to create/add/set up/provision an - agent, bot, robot, or worker—or needs a new capability-specific worker—read - `skills/agent-creator/SKILL.md` immediately. Never run `bot create` without + agent, robot, or worker—or needs a new capability-specific worker—read + `skills/agent-creator/SKILL.md` immediately. Never run `participant create --bind create` without `--from-template` for a new worker. - **Team orchestration second:** for multi-worker handoff when workers exist (or after `agent-creator` finishes), read `skills/agent-teams/SKILL.md` and use `csgclaw-cli team` (create tasks, plan, start). Each main task gets its own execution room when started. Use `skills/manager-worker-dispatch/SKILL.md` only as a legacy fallback outside team tasks. -- For CSGClaw room, bot, member, Feishu group/chat creation, or adding bots to +- For CSGClaw room, participant, member, Feishu group/chat creation, or adding participants to Feishu groups, read and use `skills/basics/SKILL.md` first and run `csgclaw-cli`. Do not conclude group creation is unsupported just because the native OpenClaw `feishu_chat` tool only supports read/query actions. diff --git a/internal/templates/embed/openclaw-manager/workspace/IDENTITY.md b/internal/templates/embed/openclaw-manager/workspace/IDENTITY.md index 303b4972..5a6afde4 100644 --- a/internal/templates/embed/openclaw-manager/workspace/IDENTITY.md +++ b/internal/templates/embed/openclaw-manager/workspace/IDENTITY.md @@ -2,7 +2,7 @@ Name: OpenClaw -Role: CSGClaw manager bot +Role: CSGClaw manager agent Nature: OpenClaw runtime agent managed by CSGClaw diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/SKILL.md index b0202479..ac79cd51 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-creator -description: Mandatory skill for provisioning any new CSGClaw agent, bot, robot, or worker. Use immediately when the user asks to create, add, set up, or provision an agent/bot/worker (including GitLab, frontend, QA, or other specialized workers), when dispatch needs a missing worker, or when asking which hub template fits. Always hub list + match + hub get + bot create --from-template with --env for secrets. Never run bot create without --from-template for a new worker. Do NOT use for task dispatch to existing workers or todo.json tracking only. +description: Mandatory skill for provisioning any new CSGClaw agent-backed participant or worker. Use immediately when the user asks to create, add, set up, or provision an agent, robot, worker, or user-facing "bot" (including GitLab, frontend, QA, or other specialized workers), when dispatch needs a missing worker, or when asking which hub template fits. Always hub list + match + hub get + participant create --type agent --bind create --from-template with --env for secrets. Never run participant create --bind create without --from-template for a new worker. Do NOT use for task dispatch to existing workers or todo.json tracking only. --- # Agent Creator @@ -11,7 +11,7 @@ Use `basics` only after create for room membership or IM mentions. Use `manager- ## Routing Gate (mandatory) -Before running **any** `csgclaw-cli bot create` for a **new** worker: +Before running **any** `csgclaw-cli participant create --type agent --bind create` for a **new** worker: 1. Read this skill first. 2. Run `csgclaw-cli --output json hub list` and pick a template (do not skip even if the user named a capability like GitLab). @@ -24,9 +24,9 @@ If dispatch or any other skill says "create a worker", that means **this skill** Use this skill when: -- the user asks to create, add, set up, or provision an agent, bot, robot, or worker +- the user asks to create, add, set up, or provision an agent, robot, worker, or user-facing "bot" - the user names a capability (GitLab, frontend, QA, review, etc.) and needs a matching worker -- `bot list` shows no suitable available worker for the required capability +- `participant list` shows no suitable available worker for the required capability - dispatch needs a new worker (pause dispatch, complete provisioning here, then resume with `basics` + dispatch) Do **not** use this skill when: @@ -41,7 +41,7 @@ Never run a bare worker create like: ```bash # FORBIDDEN for new workers -csgclaw-cli bot create --name gitlab-worker --role worker --runtime openclaw_sandbox +csgclaw-cli participant create --type agent --bind create --name gitlab-worker --role worker --runtime openclaw_sandbox ``` Never tell the worker secrets in chat instead of `--env`. @@ -51,9 +51,9 @@ Never skip `hub list` / `hub get` because you think you already know the templat ## Workflow 1. Confirm the user wants a **new** worker (or dispatch lacks one). If an available worker already matches, stop and reuse it. -2. `csgclaw-cli bot list --channel ` — avoid duplicate names; ask reuse vs new if ambiguous. +2. `csgclaw-cli participant list --channel --type agent` — avoid duplicate names; ask reuse vs new if ambiguous. 3. `csgclaw-cli --output json hub list` — match by `name`, `description`, and `role`. -4. No match → say so plainly; do not fall back to bare `bot create`. +4. No match → say so plainly; do not fall back to bare `participant create --bind create`. 5. Multiple matches → short comparison; let the user choose. 6. `csgclaw-cli --output json hub get ` — read `image_env`. 7. Collect every `required=true` env with no `default`; never echo `secret=true` values. @@ -61,7 +61,7 @@ Never skip `hub list` / `hub get` because you think you already know the templat 9. Create: ```bash -csgclaw-cli bot create \ +csgclaw-cli participant create --type agent --bind create \ --name gitlab-worker \ --description "GitLab issue and MR worker" \ --role worker \ @@ -70,15 +70,15 @@ csgclaw-cli bot create \ --channel ``` -10. Report bot id, template id, and env status. Use `basics` for `member create` if the user wants the worker in the room. Do **not** auto-dispatch unless asked. +10. Report participant id, template id, and env status. Use `basics` for `member create` if the user wants the worker in the room. Do **not** auto-dispatch unless asked. ## Commands ```bash csgclaw-cli --output json hub list csgclaw-cli --output json hub get builtin.gitlab-worker -csgclaw-cli bot list --channel -csgclaw-cli bot create --from-template --env KEY=VALUE ... --channel +csgclaw-cli participant list --channel --type agent +csgclaw-cli participant create --type agent --bind create --from-template --env KEY=VALUE ... --channel ``` Template env vars with `default` are injected by the server; pass `--env` only for secrets and overrides. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/agents/openai.yaml b/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/agents/openai.yaml index 388f24ac..54483a8f 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/agents/openai.yaml +++ b/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Agent Creator" short_description: "Mandatory hub-template worker provisioning" - default_prompt: "Use $agent-creator before any new bot create. Hub list, hub get, then bot create --from-template with --env." + default_prompt: "Use $agent-creator before any new worker create. Hub list, hub get, then participant create --type agent --bind create --from-template with --env." diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/agent-teams/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/agent-teams/SKILL.md index 008b940e..6457d0ae 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/agent-teams/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/agent-teams/SKILL.md @@ -37,13 +37,15 @@ Do not invent extra commands. Stay within the shipped CLI surface and Web Tasks Create or enable a team room: ```bash -csgclaw-cli team create --channel csgclaw --room-id --lead-bot-id --member-bot-ids +csgclaw-cli team create --channel csgclaw --room-id --lead-bot-id --member-bot-ids ``` +The `--lead-bot-id` and `--member-bot-ids` flag names are legacy; pass participant IDs. + Create one or more tasks: ```bash -csgclaw-cli team task create-batch --team --created-by --file +csgclaw-cli team task create-batch --team --created-by --file ``` Recommended batch shape for a main task plus subtasks: @@ -58,12 +60,12 @@ Recommended batch shape for a main task plus subtasks: { "title": "Draft release note", "parent_ref": "story", - "assign_to": "u-writer" + "assign_to": "writer" }, { "title": "Smoke test", "parent_ref": "story", - "assign_to": "u-tester" + "assign_to": "tester" } ] } @@ -77,7 +79,7 @@ Attach a new subtask to an existing main task with `parent_id`: { "title": "Prepare rollback note", "parent_id": "task-12", - "assign_to": "u-writer" + "assign_to": "writer" } ] } diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md index 7058af83..354aa9cd 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md @@ -1,6 +1,6 @@ --- name: basics -description: Handle routine CSGClaw CLI administration for rooms, Feishu group/chat creation, bot listing, room members, and IM mentions. Use for list bots, member create, message create, and room operations. Do NOT use for creating a new worker—use agent-creator instead (hub list + bot create --from-template). +description: Handle routine CSGClaw CLI administration for rooms, Feishu group/chat creation, participant listing, room members, and IM mentions. Use for list participants, member create, message create, and room operations. Do NOT use for creating a new worker—use agent-creator instead (hub list + participant create --type agent --bind create --from-template). --- # CSGClaw CLI Basics @@ -15,13 +15,13 @@ This skill covers direct CLI actions such as: - create a room - create a Feishu group/chat through CSGClaw - list rooms -- list all bots +- list all participants - list room members -- add a bot as a room member +- add a participant as a room member - send a message, including a message with a mention - check command help for the current CLI surface before assuming flags -Do **not** use this skill to **create a new worker**. For any new agent/bot/worker provisioning, use `agent-creator` (`hub list`, `hub get`, `bot create --from-template`). +Do **not** use this skill to **create a new worker**. For any new agent/worker provisioning, use `agent-creator` (`hub list`, `hub get`, `participant create --type agent --bind create --from-template`). Do not use this skill when the task requires any of the following: @@ -34,21 +34,21 @@ For hub template selection and `--from-template` creation, use `agent-creator` i ## Workflow -1. Identify the exact room, bot, or member operation the user needs. +1. Identify the exact room, participant, or member operation the user needs. 2. If room context matters, inspect it first with `room list` or `member list`, especially to see whether the room is direct. 3. Run `csgclaw-cli -h` or `csgclaw-cli -h` if the current command surface is not already clear. 4. Execute the smallest direct CLI command that completes the request. -5. Show the user the key result such as the room ID, bot ID, member list summary, or sent message result. +5. Show the user the key result such as the room ID, participant ID, member list summary, or sent message result. ## Common Commands Create a room: ```bash -csgclaw-cli room create --title test-room --creator-id u-manager --member-ids u-manager,u-dev --channel +csgclaw-cli room create --title test-room --creator-id manager --member-ids manager,dev --channel csgclaw ``` -Use CSGClaw bot IDs in room, member, and message commands. For Feishu room creation, keep the same bot ID parameters; CSGClaw converts configured bot IDs to Feishu app IDs and sends them as `bot_id_list`. +Use participant IDs in room, member, and message commands. The default manager participant is `manager`; its backing agent ID is `u-manager`. For Feishu room creation, pass Feishu participant IDs; CSGClaw resolves the configured app credentials internally. List rooms and check whether a room is direct: @@ -56,13 +56,13 @@ List rooms and check whether a room is direct: csgclaw-cli room list --channel ``` -List bots: +List participants: ```bash -csgclaw-cli bot list --channel +csgclaw-cli participant list --channel --type agent ``` -Create a bot. Always include `--description`: +Create a worker participant: ```bash # Do not use this for new workers. Use agent-creator with --from-template instead. @@ -74,36 +74,36 @@ List members in a room: csgclaw-cli member list --room-id oc_xxx --channel ``` -Add a bot into a non-direct room: +Add a participant into a non-direct room: ```bash -csgclaw-cli member create --room-id oc_xxx --user-id u-alex --inviter-id u-manager --channel +csgclaw-cli member create --room-id oc_xxx --user-id alex --inviter-id manager --channel csgclaw ``` -If the current room is direct in the local `csgclaw` channel, do not try to add the bot directly. Create a new room that includes the current DM participants plus the new bot: +If the current room is direct in the local `csgclaw` channel, do not try to add the participant directly. Create a new room that includes the current DM participants plus the new participant: ```bash csgclaw-cli room create \ --title "manager-dev-alex" \ - --creator-id u-manager \ - --member-ids u-manager,u-dev,u-alex \ - --channel + --creator-id manager \ + --member-ids manager,dev,alex \ + --channel csgclaw ``` -For Feishu, keep the same bot ID parameters: +For Feishu, keep the same participant ID boundary: ```bash csgclaw-cli room create \ --title "manager-dev-alex" \ - --creator-id u-manager \ - --member-ids u-manager,u-dev,u-alex \ + --creator-id manager \ + --member-ids manager,dev,alex \ --channel feishu ``` -Send a message with a mention. Use the mentioned bot ID for `--mention-id`: +Send a message with a mention. Use the mentioned participant ID for `--mention-id`: ```bash -csgclaw-cli message create --room-id oc_xxx --sender-id u-manager --content "Please take a look." --mention-id u-alex --channel +csgclaw-cli message create --room-id oc_xxx --sender-id manager --content "Please take a look." --mention-id alex --channel csgclaw ``` ## Notifying workers in IM (critical) @@ -112,28 +112,28 @@ Workers are configured with **`mention_only`**: they only process group messages | Do | Do not | |----|--------| -| `csgclaw-cli message create ... --mention-id u-gitlab-worker` (ID from `bot list`) | Type `@gitlab-worker` or `@worker-name` in `--content`, room replies, or the PicoClaw `message` tool | -| Verify delivery with `message list` — content must include `` | Assume a human-style `@` in prose wakes the worker | -| Run `bot list` and `member list` before the first dispatch | Skip membership checks and post assignment text only | +| `csgclaw-cli message create ... --mention-id gitlab-worker` (participant ID from `participant list`) | Type `@gitlab-worker` or `@worker-name` in `--content`, room replies, or the PicoClaw `message` tool | +| Verify delivery with `message list` — content must include a structured `` tag | Assume a human-style `@` in prose wakes the worker | +| Run `participant list` and `member list` before the first dispatch | Skip membership checks and post assignment text only | Minimal handoff flow: -1. `csgclaw-cli bot list` — resolve the worker **bot ID** (e.g. `u-gitlab-worker`, not the display name). +1. `csgclaw-cli participant list` — resolve the worker participant ID (e.g. `gitlab-worker`, not the display name). 2. `csgclaw-cli member list` — confirm the worker is in the room; `member create` if missing. 3. `csgclaw-cli message create` with `--mention-id` and the task body. 4. `csgclaw-cli message list` — confirm the stored message contains ``. For multi-worker team tasks, use `agent-teams` (`csgclaw-cli team` plan/start) instead of manual room messages. Use `manager-worker-dispatch` only when team tasks are not in use. -Example worker handoff (replace room ID, worker ID, and channel): +Example worker handoff (replace room ID, participant ID, and channel): ```bash csgclaw-cli message create \ --room-id \ - --sender-id u-manager \ - --mention-id u-alex \ + --sender-id manager \ + --mention-id alex \ --content "Please implement the login page changes we discussed." \ - --channel + --channel csgclaw ``` Do **not** post `@alex` plain text in the room instead of `--mention-id`. @@ -141,12 +141,12 @@ Do **not** post `@alex` plain text in the room instead of `--mention-id`. ## Operating Rules - Prefer direct `csgclaw-cli` commands over ad hoc HTTP calls. -- Use `bot list` before creating a new bot if the user may be referring to an existing one. -- When a **new** worker is needed, use `agent-creator`; do not run bare `bot create` from this skill. +- Use `participant list` before creating a new worker if the user may be referring to an existing one. +- When a **new** worker is needed, use `agent-creator`; do not run bare `participant create --bind create` from this skill. - Verify room membership with `member list` after adding a member when room presence matters. -- A direct room cannot accept an added bot as a new member. Create a new room with `--member-ids` containing the existing DM bots and the new bot. -- For Feishu, prefer `room create --member-ids` for new groups after bot configs exist. Use `member create` only for an existing Feishu group; that path requires manager app scopes such as `im:chat.members:write_only` or `im:chat`. -- Keep `csgclaw-cli` parameters bot-facing across channels: use bot IDs such as `u-manager`, `u-dev`, and `u-alex`. +- A direct room cannot accept an added participant as a new member. Create a new room with `--member-ids` containing the existing DM participants and the new participant. +- For Feishu, prefer `room create --member-ids` for new groups after Feishu credentials exist. Use `member create` only for an existing Feishu group; that path requires manager app scopes such as `im:chat.members:write_only` or `im:chat`. +- Use participant IDs at the CLI boundary. For the local CSGClaw manager use `manager`; use `u-manager` only when calling an agent route or the Feishu credential config API field that still names its key `bot_id`. - Never notify a worker with plain-text `@name`; always use `message create --mention-id` and verify `` in `message list`. - Keep the response focused on the concrete CLI result instead of introducing external planning artifacts. - Hand off to `agent-teams` for multi-worker team orchestration; use `manager-worker-dispatch` only if the user explicitly needs tracker handoff outside team tasks. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/basics/agents/openai.yaml b/internal/templates/embed/openclaw-manager/workspace/skills/basics/agents/openai.yaml index ad8e9319..ac8b478b 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/basics/agents/openai.yaml +++ b/internal/templates/embed/openclaw-manager/workspace/skills/basics/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "CSGClaw CLI Basics" - short_description: "Basic CSGClaw CLI room/bot/member operations" - default_prompt: "Use $basics to handle basic CSGClaw CLI operations such as creating rooms or Feishu groups, listing bots, creating bots, listing room members, and adding bots into rooms." + short_description: "Basic CSGClaw CLI room/participant/member operations" + default_prompt: "Use $basics to handle basic CSGClaw CLI operations such as creating rooms or Feishu groups, listing participants, listing room members, and adding participants into rooms." diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md index 7b9aaadc..cc818ccd 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md @@ -1,11 +1,11 @@ --- name: feishu -description: Configure and troubleshoot CSGClaw Feishu/Lark channel credentials for manager or worker bots. Use when the Manager needs to generate a bot creation URL or QR code, collect App ID/App Secret through registration, write and reload channel config through csgclaw-cli bot config, ensure or recreate agents, or debug Feishu messages not reaching CSGClaw/OpenClaw bots. +description: Configure and troubleshoot CSGClaw Feishu/Lark channel credentials for manager or worker agents. Use when the Manager needs to generate a Feishu bot app creation URL or QR code, collect App ID/App Secret through registration, write and reload channel config through Feishu config API, ensure or recreate agents, or debug Feishu messages not reaching CSGClaw/OpenClaw workers. --- # Feishu -This skill sets up Feishu/Lark bot credentials for CSGClaw-managed OpenClaw manager and worker bots. +This skill sets up Feishu/Lark bot app credentials for CSGClaw-managed OpenClaw manager and worker agents. ## Script @@ -23,7 +23,7 @@ If `start`/`poll` returns a machine-mode `next` command, prefer that absolute co - `scripts/feishu_register.py`: User-facing CLI entrypoint. Supports `start`, `poll`, `finalize`, `status`, `recreate-agent`. - `scripts/feishu_setup/commands.py`: Parses CLI arguments and maps them to handler functions. - `scripts/feishu_setup/registration.py`: Implements registration flow and device-code polling state transitions. -- `scripts/feishu_setup/csgclaw.py`: Applies config to CSGClaw, triggers reload, and performs bot/agent ensure/recreate actions. +- `scripts/feishu_setup/csgclaw.py`: Applies config to CSGClaw, triggers reload, and performs participant/agent ensure/recreate actions. - `scripts/feishu_setup/state.py`: Stores and migrates registration state files. - `scripts/feishu_setup/config.py`: Defines constants, env-key names, and default path constants. - `scripts/tests/`: tests and fixtures for script behavior. @@ -36,30 +36,30 @@ The script uses Feishu/Lark's accounts registration flow: 4. poll with `action=poll`, `device_code=<...>`, `tp=ob_app` 5. when the user completes app creation, receive `client_id` and `client_secret` 6. map `client_id` -> CSGClaw `app_id`, and `client_secret` -> CSGClaw `app_secret` -7. immediately write the secret to CSGClaw through `csgclaw-cli bot config --channel feishu --set` without printing it +7. immediately write the secret to CSGClaw through `PUT /api/v1/channels/feishu/config` without printing it -Do not add or require a public Feishu Open Platform HTTP webhook as the main inbound path. OpenClaw uses Feishu/Lark WebSocket mode for real inbound bot messages. CSGClaw's `/api/v1/channels/feishu/bots/{bot}/events` endpoint is an internal SSE bridge for CSGClaw manager-to-worker dispatch, not a Feishu public webhook. +Do not add or require a public Feishu Open Platform HTTP webhook as the main inbound path. OpenClaw uses Feishu/Lark WebSocket mode for real inbound bot messages. CSGClaw's `/api/v1/channels/feishu/participants/{participant}/events` endpoint is an internal SSE bridge for CSGClaw manager-to-worker dispatch, not a Feishu public webhook. ## When to Use Use this skill when the user asks to: -- create/configure Feishu for `u-manager` or a worker such as `u-dev` +- create/configure Feishu credentials for the manager agent `u-manager` or a worker agent such as `u-dev` - generate a Feishu/Lark bot creation URL or QR code -- get Feishu AK/SK, App ID/App Secret, or client_id/client_secret for a CSGClaw bot +- get Feishu AK/SK, App ID/App Secret, or client_id/client_secret for a CSGClaw-managed agent - reload CSGClaw channel config after setting Feishu credentials - recreate a worker or manager after Feishu credentials are configured -- debug why Feishu messages do not reach a CSGClaw/OpenClaw bot +- debug why Feishu messages do not reach a CSGClaw/OpenClaw worker Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Feishu app development. ## Terms -- CSGClaw bot ID: usually `u-manager`, `u-dev`, `u-qa`, etc. +- Target agent ID: usually `u-manager`, `u-dev`, `u-qa`, etc. The helper script still names this legacy argument `--bot-id`. - Feishu `app_id` / `app_secret`: the Feishu bot application's credentials. - AK/SK in user wording usually means Feishu `app_id/app_secret` or `client_id/client_secret` returned by the registration flow. - Manager agent: usually `u-manager`; recreating it can interrupt the current manager skill run. -- Worker agent: any non-manager bot, for example `u-dev`; recreating it is usually safe after config succeeds. +- Worker agent: any non-manager agent, for example `u-dev`; recreating it is usually safe after config succeeds. ## Prerequisites @@ -71,21 +71,21 @@ Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Fei - inside manager box: typically `~/.openclaw/workspace/skills/feishu` or your configured skill root - host repo path: `internal/templates/embed/openclaw-manager/workspace/skills/feishu` 4. Server build supports: - - `csgclaw-cli bot config --channel feishu --set/--get/--reload` - - `POST /api/v1/channels/feishu/bots` + - Feishu config API (`PUT`/`GET`/`POST /api/v1/channels/feishu/config`) + - `POST /api/v1/channels/feishu/participants` - `POST /api/v1/agents/{id}/recreate` ## Manager Group Permissions -CSGClaw cannot silently grant Feishu/Lark app scopes from inside the OpenClaw runtime. Feishu group operations use the manager bot's Feishu app credentials, so the tenant admin must approve the required scopes in Feishu/Lark Open Platform. +CSGClaw cannot silently grant Feishu/Lark app scopes from inside the OpenClaw runtime. Feishu group operations use the manager agent's Feishu bot app credentials, so the tenant admin must approve the required scopes in Feishu/Lark Open Platform. -For new Feishu groups, after the manager and worker Feishu configs exist, prefer creating the group with all bot IDs already included: +For new Feishu groups, after the manager and worker Feishu configs exist, prefer creating the group with all participant IDs already included: ```bash -csgclaw-cli room create --title dev-ui-group --creator-id u-manager --member-ids u-manager,u-dev --channel feishu +csgclaw-cli room create --title dev-ui-group --creator-id manager --member-ids manager,dev --channel feishu ``` -This uses Feishu `bot_id_list` during chat creation and avoids the separate `member create` path for new groups. +CSGClaw resolves those participant IDs to the configured Feishu app credentials during chat creation and avoids the separate `member create` path for new groups. For existing Feishu groups, `csgclaw-cli member list` and `member create` require manager app scopes such as: @@ -94,33 +94,33 @@ For existing Feishu groups, `csgclaw-cli member list` and `member create` requir - `im:chat.members:write_only` - or the broader `im:chat` -`finalize` prints `manager_group_scopes` and `manager_group_permission_url`. Send that URL to the user/admin when Feishu returns `Access denied` for group member inspection or adding a worker bot to an existing group. +`finalize` prints `manager_group_scopes` and `manager_group_permission_url`. Send that URL to the user/admin when Feishu returns `Access denied` for group member inspection or adding a worker agent's Feishu bot app to an existing group. ## Safe Credential Rules 1. Never print `app_secret`, `client_secret`, access tokens, verification tokens, encryption keys, or connection strings. 2. If a secret must be represented in examples or summaries, write `[REDACTED]`. 3. The script must print only `app_secret: present` after finalize. -4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `csgclaw-cli bot config --channel feishu --set --app-secret-stdin`. -5. Verify with `csgclaw-cli bot config --channel feishu --get`, not by printing the secret. +4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `PUT /api/v1/channels/feishu/config`. +5. Verify with `GET /api/v1/channels/feishu/config?bot_id=`, not by printing the secret. -## Choose Target Bot +## Choose Target Agent Ask for the target when it is not explicit. If the user does not specify an agent in the request, ask: "请明确要对接飞书的目标 Agent 名字(如 `manager`/`u-manager` 或 `dev`/`u-dev`)". Resolve target: 1. If input is `manager` or `u-manager`, treat as manager flow. -2. Otherwise, treat input as worker flow, set `bot_id` to the input if it already starts with `u-`, otherwise prefix `u-`. +2. Otherwise, treat input as worker flow, set the target agent ID to the input if it already starts with `u-`, otherwise prefix `u-`. 3. If only role was inferred as manager, stop using recreate path and force action-card flow. Example normalization: -- `dev` -> worker `u-dev` -- `u-dev` -> worker `u-dev` +- `dev` -> worker agent `u-dev`, participant `dev` +- `u-dev` -> worker agent `u-dev`, participant `dev` - `manager` -> manager - `u-manager` -> manager -For worker flow, finalize writes config, reloads Feishu channel config, ensures the CSGClaw bot, then recreates the worker so runtime env/files are materialized from the updated config. +For worker flow, finalize writes config, reloads Feishu channel config, ensures the CSGClaw Feishu participant, then recreates the worker so runtime env/files are materialized from the updated config. ## Primary QR/Launcher Flow @@ -130,7 +130,7 @@ Run from this skill directory: ```bash python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py start \ - --bot-id \ + --bot-id \ --role worker \ --bot-name \ --description "dev worker agent" \ @@ -162,12 +162,12 @@ By default, `finalize` will: 1. poll Feishu/Lark until credentials are available or timeout 2. receive `client_id/client_secret` -3. write `app_id/app_secret` to CSGClaw through `csgclaw-cli bot config` +3. write `app_id/app_secret` to CSGClaw through `Feishu config API` - for `u-manager`, overwrite global `admin_open_id` only with the registration `open_id` - - for worker bots, ignore registration `open_id` and do not read, preserve, write, or report `admin_open_id` + - for worker agents, ignore registration `open_id` and do not read, preserve, write, or report `admin_open_id` 4. auto-reload channel config -5. ensure the CSGClaw bot through `POST /api/v1/channels/feishu/bots` -6. for worker targets, recreate the worker after bot ensure so the new Feishu env/files take effect +5. ensure the CSGClaw Feishu participant through `POST /api/v1/channels/feishu/participants` +6. for worker targets, recreate the worker after participant ensure so the new Feishu env/files take effect - if BoxLite reports `box with name '' already exists` while CSGClaw reports `agent "" not found`, stop and tell the user the host has a stale partial worker box; do not keep trying random API paths or host-only commands from inside manager 7. for manager targets, print a `csgclaw.action_card` JSON payload with a whitelisted `rebuild-manager` action; the CSGClaw Web chat message should render the button to complete the window-triggered manager bootstrap replace flow. 8. print JSON with `app_secret: present`, never the real secret @@ -178,9 +178,9 @@ For a worker, default finalize is usually enough: python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id ``` -Use an exec/tool timeout of at least 600 seconds for this command. For workers, finalize recreates the target worker after config reload and bot ensure; do not create a second worker or change the bot id. +Use an exec/tool timeout of at least 600 seconds for this command. For workers, finalize recreates the target worker after config reload and participant ensure; do not create a second worker or change the target agent ID. -For manager, default finalize configures and ensures the bot, then prints a structured action card. Return the JSON object exactly as the chat message content: no leading sentence, no Markdown table, no bullet list, no ```json fence, and no explanatory wrapper. The CSGClaw Web frontend will render a "重建 Manager" button. +For manager, default finalize configures credentials and ensures the participant, then prints a structured action card. Return the JSON object exactly as the chat message content: no leading sentence, no Markdown table, no bullet list, no ```json fence, and no explanatory wrapper. The CSGClaw Web frontend will render a "重建 Manager" button. The click is handled by the browser and calls the manager bootstrap replace surface (`POST /api/v1/agents` with `{"id":"u-manager","replace":true}`), not the hazardous generic recreate route. Do not run `python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py recreate-agent --bot-id u-manager` as a terminal self-recreate step anymore. The manager-rebuild action must be completed by clicking the rendered Web window button, which calls `POST /api/v1/agents` with `{"id":"u-manager","replace":true}`. @@ -215,36 +215,35 @@ If Feishu/Lark registration endpoint fails, expires, or tenant policy blocks sca - App ID, usually `cli_...` - App Secret, provided only through a secure path. -Use `csgclaw-cli bot config` to set manually: +Use the Feishu config API to set manually: ```bash -printf '%s' '[REDACTED]' | csgclaw-cli bot config --channel feishu --set \ - --bot-id u-dev \ - --app-id cli_xxx \ - --app-secret-stdin +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-dev","app_id":"cli_xxx","app_secret":"[REDACTED]"}' ``` -or: +For manager setup, include `admin_open_id`: ```bash -csgclaw-cli bot config --channel feishu --set \ - --bot-id u-dev \ - --app-id cli_xxx \ - --app-secret-file /secure/path/feishu_app_secret +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-manager","app_id":"cli_xxx","app_secret":"[REDACTED]","admin_open_id":"ou_xxx"}' ``` ## CLI Workflow Used by Script -The script writes and reloads Feishu config through `csgclaw-cli bot config` because sandboxed skills should not edit host files directly or hand-roll config API calls. +The script writes and reloads Feishu config through `Feishu config API` because sandboxed skills should not edit host files directly or hand-roll config API calls. For `u-manager`, the script passes the registration `open_id` as the global `admin_open_id` while setting config and auto-reloading: ```bash -printf '%s' '[REDACTED]' | csgclaw-cli --output json bot config --channel feishu --set \ - --bot-id u-manager \ - --app-id cli_xxx \ - --admin-open-id ou_xxx \ - --app-secret-stdin +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-manager","app_id":"cli_xxx","app_secret":"[REDACTED]","admin_open_id":"ou_xxx"}' ``` Expected response shape: @@ -260,13 +259,13 @@ Expected response shape: } ``` -Ensure bot: +Ensure participant: ```bash -csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +csgclaw-cli participant create --type agent --bind create --id dev --agent-id u-dev --name dev --description "dev worker agent" --role worker --channel feishu --channel-user-ref ou_xxx --channel-user-kind open_id --channel-app-ref cli_xxx ``` -Recreate the worker after config reload and bot ensure so the runtime picks up the updated Feishu credentials: +Recreate the worker after config reload and participant ensure so the runtime picks up the updated Feishu credentials: ```bash curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/agents/u-dev/recreate" \ @@ -275,12 +274,14 @@ curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/agents/u-dev/recreate" \ ## CLI Workflow for Manual Control -Use `csgclaw-cli bot config` for channel config. Use the helper script or the backend recreate API for agent recreate, because lite `csgclaw-cli` does not expose agent commands and manager boxes usually do not have full `csgclaw`. +Use `Feishu config API` for channel config. Use the helper script or the backend recreate API for agent recreate, because lite `csgclaw-cli` does not expose agent commands and manager boxes usually do not have full `csgclaw`. ```bash -csgclaw-cli bot config --channel feishu --get --bot-id u-dev -csgclaw-cli bot config --channel feishu --reload -csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +curl -sS "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config?bot_id=u-dev" \ + -H "Authorization: Bearer [REDACTED]" +curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" +csgclaw-cli participant create --type agent --bind create --id dev --agent-id u-dev --name dev --description "dev worker agent" --role worker --channel feishu --channel-user-ref ou_xxx --channel-user-kind open_id --channel-app-ref cli_xxx python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py recreate-agent --bot-id u-dev ``` @@ -301,8 +302,8 @@ python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py f Run the command with exec `timeout` at least `600`. -4. Confirm finalize recreated the worker after reload and bot ensure. -5. Tell the user to test from Feishu by messaging or @mentioning the bot. +4. Confirm finalize recreated the worker after reload and participant ensure. +5. Tell the user to test from Feishu by messaging or @mentioning the Feishu bot app. ## Manager One-Shot Recipe @@ -331,11 +332,11 @@ Do not use the generic manager recreate endpoint or any terminal/host-side manag 1. Using `csgclaw-cli agent ...`: lite CLI does not have agent commands. Use full `csgclaw` or API. 2. Running host-only `csgclaw` or `boxlite` commands from inside manager: manager usually only has `csgclaw-cli`; use this script/API from manager, and ask the host operator to clean stale BoxLite boxes if needed. -3. Looking for removed `csgclaw channel ...` commands: Feishu config belongs to `csgclaw-cli bot config --channel feishu`. -4. Creating the CSGClaw bot before writing/reloading Feishu config: this can create local placeholder identity. +3. Looking for removed `csgclaw-cli bot config ...` commands: use `csgclaw-cli participant config --channel feishu ...`, backed by `/api/v1/channels/feishu/config`. +4. Creating the CSGClaw participant before writing/reloading Feishu config: this can create local placeholder identity. 5. Expecting reload to update an already-running OpenClaw box: recreate is still required. 6. Calling manager recreate from inside this manager-hosted skill: return the action card so the current window renders the rebuild button. -7. Checking `agent list` or `bot list` after manager recreate and treating `stopped` as failure: manager gateway runs in daemon mode, so BoxLite status is not a reliable success signal for this skill. +7. Checking `agent list` or `participant list` after manager recreate and treating `stopped` as failure: manager gateway runs in daemon mode, so BoxLite status is not a reliable success signal for this skill. 8. Printing secrets in summaries or logs: always mask as `[REDACTED]` or `present`. 9. Calling CSGClaw SSE endpoint a Feishu webhook: it is an internal CSGClaw-to-runtime bridge. 10. If Feishu changes the accounts registration endpoint or tenant policy blocks PersonalAgent creation, fall back to manual App ID/App Secret setup. @@ -344,10 +345,10 @@ Do not use the generic manager recreate endpoint or any terminal/host-side manag - [ ] `start` printed a launcher URL or QR code for the user. - [ ] `finalize` output shows `app_secret` only as `present`. -- [ ] `finalize` configured `bot_id` and `app_id` in CSGClaw. +- [ ] `finalize` configured the target agent ID (`bot_id` field) and `app_id` in CSGClaw. - [ ] CSGClaw channel config was reloaded. -- [ ] CSGClaw bot exists with `channel=feishu`. -- [ ] Worker agents are recreated after config reload and bot ensure. +- [ ] CSGClaw participant exists with `channel=feishu`. +- [ ] Worker agents are recreated after config reload and participant ensure. - [ ] New worker finalize was run with a tool timeout of at least 600 seconds. - [ ] Manager finalize returned a raw `csgclaw.action_card` JSON object with `rebuild-manager` action metadata for the web button. - [ ] No manager-hosted command called the generic manager recreate endpoint or any host-side manager rebuild command. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py index e900598d..ab66490c 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py @@ -52,7 +52,7 @@ def resolve_manager_app_id(args: argparse.Namespace, state: dict, result: dict) try: config = csgclaw_cli_json( args, - ["bot", "config", "--channel", "feishu", "--get", "--bot-id", "u-manager"], + ["participant", "config", "--channel", "feishu", "--get", "--bot-id", "u-manager"], ) except RuntimeError: return "" @@ -269,7 +269,7 @@ def build_parser() -> argparse.ArgumentParser: finalize.add_argument("--registration-id", required=True) finalize.add_argument("--timeout", type=int, default=DEFAULT_EXPIRE_SECONDS) finalize.add_argument("--no-configure", action="store_true", help="Do not write CSGClaw config; for debugging only, still never prints secret") - finalize.add_argument("--no-ensure-bot", action="store_true", help="Skip POST /api/v1/channels/feishu/bots") + finalize.add_argument("--no-ensure-bot", action="store_true", help="Skip POST /api/v1/channels/feishu/participants") finalize.add_argument("--role", choices=["worker", "manager"], default="", help="Override role for ensure/recreate logic") finalize.add_argument("--bot-name", default="", help="Override bot name for ensure") finalize.add_argument("--description", default="", help="Override bot description for ensure") diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py index 677475cb..460c12c9 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py @@ -99,22 +99,15 @@ def csgclaw_cli_json(args, cli_args: list[str], input_text: Optional[str] = None def configure_csgclaw(args, state: dict, result: dict) -> dict: bot_id = state["bot_id"] - cli_args = [ - "bot", - "config", - "--channel", - "feishu", - "--set", - "--bot-id", - bot_id, - "--app-id", - result["app_id"], - "--app-secret-stdin", - ] + payload = { + "bot_id": bot_id, + "app_id": result["app_id"], + "app_secret": result["app_secret"], + } candidate_admin_open_id = str(result.get("open_id") or "").strip() if bot_id == "u-manager" and candidate_admin_open_id: - cli_args.extend(["--admin-open-id", candidate_admin_open_id]) - response = csgclaw_cli_json(args, cli_args, input_text=result["app_secret"] + "\n") or {} + payload["admin_open_id"] = candidate_admin_open_id + response = api_json(args, "PUT", "/api/v1/channels/feishu/config", payload) or {} if bot_id == "u-manager": if candidate_admin_open_id: response["admin_open_id"] = candidate_admin_open_id @@ -140,12 +133,22 @@ def ensure_bot(args, state: dict, result: dict) -> Optional[dict]: description = args.description or state.get("description") or f"{name} Feishu {role} agent" payload = { "id": bot_id, + "type": "agent", "name": name, - "description": description, - "role": role, - "channel": "feishu", + "channel_app_ref": result.get("app_id") or state.get("app_id") or "", + "channel_user": {"ref": bot_id, "kind": "local_user_id"}, + "agent_binding": { + "mode": "create", + "agent_id": bot_id, + "agent": { + "id": bot_id, + "name": name, + "description": description, + "role": role, + }, + }, } - return api_json(args, "POST", f"/api/v1/channels/feishu/bots", payload) + return api_json(args, "POST", "/api/v1/channels/feishu/participants", payload) def worker_box_conflict_message(bot_id: str, name: str) -> str: @@ -171,10 +174,10 @@ def is_same_bot_name_conflict(exc: RuntimeError, bot_id: str) -> bool: def bot_exists(args, bot_id: str) -> bool: - bots = csgclaw_cli_json(args, ["bot", "list", "--channel", "feishu"]) - if not isinstance(bots, list): - raise RuntimeError(f"csgclaw-cli bot list returned unexpected JSON: {bots!r}") - return any(str(bot.get("id") or "").strip() == bot_id for bot in bots if isinstance(bot, dict)) + participants = csgclaw_cli_json(args, ["participant", "list", "--channel", "feishu", "--type", "agent"]) + if not isinstance(participants, list): + raise RuntimeError(f"csgclaw-cli participant list returned unexpected JSON: {participants!r}") + return any(str(item.get("id") or "").strip() == bot_id for item in participants if isinstance(item, dict)) def maybe_recreate(args, state: dict, worker_existed_before_ensure: Optional[bool] = None) -> Optional[dict]: diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py index 7b5de372..6b8a766a 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py @@ -55,7 +55,7 @@ def test_worker_finalize_continues_recreate_when_same_bot_already_exists(self): def fake_ensure_bot(args, state, result): raise RuntimeError( - 'CSGClaw API POST /api/v1/channels/feishu/bots failed: HTTP 400: ' + 'CSGClaw API POST /api/v1/channels/feishu/participants failed: HTTP 400: ' 'bot name "web-dev" already exists in channel "feishu" with id "u-web-dev"' ) diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md index 2c9ff00f..2f9e7363 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md @@ -7,7 +7,7 @@ description: Use this skill only for manager-led multi-worker coordination in CS Break a multi-worker admin request into clear tasks, choose workers by capability, and dispatch them through CSGClaw's real local interfaces in sequence. -Use the `basics` skill for room, bot, and member operations that support the dispatch flow. Use `skill-installer` for registry skill search; workers use `skill-installer` for install (not `find_skills`). +Use the `basics` skill for room, participant, and member operations that support the dispatch flow. Use `skill-installer` for registry skill search; workers use `skill-installer` for install (not `find_skills`). Use `scripts/manager_worker_api.py` only for `start-tracking` and `stop-tracking`. Check the current CLI surface through the `basics` skill instead of writing ad hoc API requests. @@ -23,22 +23,22 @@ Do not use this skill for: - collecting template `image_env` values - creating agents with `--from-template` -For those flows, use `agent-creator`. Never create a new worker with bare `bot create` from dispatch. +For those flows, use `agent-creator`. Never create a new worker with bare `participant create --bind create` from dispatch. ## Mandatory Dispatch Order When a user asks for manager-led coordination (especially GitLab planning, issue queries, breakdown, assignment, or execution handoff), follow this order and do not skip steps: 1. Read this skill first before any domain execution. -2. Check existing workers first (`bot list`) and prefer reusing a capable available worker. +2. Check existing workers first (`participant list`) and prefer reusing a capable available worker. 3. Ensure the selected worker is a member of the target room (add if missing). 4. If no suitable worker exists or the matching one is `unavailable`: - - **New worker needed:** stop dispatch, follow `agent-creator` (hub list → hub get → `bot create --from-template` + `--env`), then use `basics` to add the worker to the room. - - **Existing worker unavailable:** use `basics` / recreate paths for that bot id only; do not bare-create a replacement. + - **New worker needed:** stop dispatch, follow `agent-creator` (hub list → hub get → `participant create --type agent --bind create --from-template` + `--env`), then use `basics` to add the worker to the room. + - **Existing worker unavailable:** use `basics` / recreate paths for that participant only; do not bare-create a replacement. 5. Dispatch the task to the worker in-room after membership is confirmed. Do not start with repo/code exploration, web fetch/search, or manager self-execution when the task is delegable to an existing or creatable worker. -Do not skip `bot list` by assuming worker availability from memory or prior turns. +Do not skip `participant list` by assuming worker availability from memory or prior turns. ## Fast Path @@ -60,15 +60,15 @@ Do not inspect or modify project implementation files before dispatch unless you ## Workflow 1. Break the admin request into concrete deliverables. -2. Match each task to the needed capability; use the `basics` skill to inspect existing workers first (`bot list`) and reuse by matching `description`. -3. If a suitable worker does not exist or is `unavailable`, provision via `agent-creator` (new) or recreate the existing bot (unavailable) before dispatch. +2. Match each task to the needed capability; use the `basics` skill to inspect existing workers first (`participant list`) and reuse by matching `description`. +3. If a suitable worker does not exist or is `unavailable`, provision via `agent-creator` (new) or recreate the existing worker before dispatch. 4. Use the `basics` skill to ensure every required worker has joined the target room, then verify the full required worker set. 5. Dispatch the user task to selected workers in-room after membership checks pass. 6. Choose a suitable project directory under `~/.openclaw/workspace/projects`; create a short slug directory if none fits. 7. Write or overwrite `todo.json` in that directory as the only source of truth for the current dispatch plan, but only after the room-membership verification succeeds. 8. Start `scripts/manager_worker_api.py start-tracking` against that `todo.json`, but only after all required workers are confirmed present in the room. 9. Let the tracker own sequential handoff; workers must reply in-room with results or blockers, and neither the manager nor workers should manually assign the next worker while tracking is active. -10. After `start-tracking`, run an explicit dispatch-delivery check before claiming success: query recent room messages and confirm the tracker/bot has posted a task message with a real mention (`mention_id` targeting the assignee user; rendered as `...`). +10. After `start-tracking`, run an explicit dispatch-delivery check before claiming success: query recent room messages and confirm the tracker has posted a task message with a real mention (`mention_id` targeting the assignee participant; rendered as `...`). ## Room Membership Gate @@ -163,11 +163,11 @@ Split cross-capability work into multiple tasks instead of giving one vague pack Use the `basics` skill whenever this workflow needs any of these supporting operations: - create the target room -- list workers or bots +- list workers or participants - add a worker into the room - verify room membership before tracking -When a **new** worker is required, use `agent-creator` instead of bare `bot create`. Use `basics` here only for recreate when an existing listed worker is `unavailable`. +When a **new** worker is required, use `agent-creator` instead of bare `participant create --bind create`. Use `basics` here only for recreate when an existing listed worker is `unavailable`. ## Tracking Script Usage @@ -191,8 +191,8 @@ csgclaw-cli --output json message list --room-id --channel Name`. +- There is a new tracker dispatch message from the manager participant in the target room. +- The dispatch message includes mention to the selected assignee (`mention_id` equals the worker participant ID). Do **not** rely on plain-text `@name`; CSGClaw renders mentions as `Name`. - Message content matches the task dispatch text pattern (task id/todo path context). If verification does not pass: @@ -200,7 +200,7 @@ If verification does not pass: 1. Wait briefly and re-check message list up to 3 times. 2. If still missing, report dispatch failure with evidence. 3. Do **not** send a manual fallback assignment message while tracking is active. -4. Stop tracking if needed, fix root cause (assignee handle mismatch, room membership mismatch, bot id mismatch), then restart tracking. +4. Stop tracking if needed, fix root cause (assignee handle mismatch, room membership mismatch, participant ID mismatch), then restart tracking. Stop the tracking: @@ -214,7 +214,7 @@ If you need to direct the human user to the project files on their Mac, point th Use this when exactly one worker should act (for example install a registry skill via `skill-installer`, run one GitLab job) and you do **not** need multi-step `todo.json` sequencing. -1. Use the `basics` skill: `bot list`, confirm room membership, then `message create --mention-id `. +1. Use the `basics` skill: `participant list`, confirm room membership, then `message create --mention-id `. 2. Do **not** reply in the room with plain `@worker-name` after registry skill discovery or other tools — that does not wake workers under `mention_only`. 3. Verify with `csgclaw-cli message list` that the dispatch message contains ``. 4. Use `start-tracking` only when multiple workers or ordered handoff is required. @@ -222,7 +222,7 @@ Use this when exactly one worker should act (for example install a registry skil ## Operating Rules - Reuse available workers before creating new ones. -- If a matching worker is listed as `unavailable`, recreate that existing bot; do not bare-create a different replacement worker. +- If a matching worker is listed as `unavailable`, recreate that existing worker; do not bare-create a different replacement worker. - Before writing the final `todo.json` or running `start-tracking`, use the `basics` skill to verify all required workers are already members of the target room. - Treat missing room membership as a blocker: add the worker, verify again, and only then continue with `todo.json` and tracking. - Keep `todo.json` aligned with the actual assignment being dispatched. @@ -231,5 +231,5 @@ Use this when exactly one worker should act (for example install a registry skil - Never "补发" assignment messages manually after `start-tracking`; treat missing mention/`mention_id` dispatch as a verification failure to debug, not a prompt for manual dispatch. - While tracking is active, do not manually tell the next worker to start in prose. The tracker is the only sequencer. - When a worker finishes, they must reply in the shared room with a normal summary or blocker note; updating `todo.json` alone does not release the next task. -- Route all non-tracking room, bot, and member operations through the `basics` skill; do not use `scripts/manager_worker_api.py` for those operations. +- Route all non-tracking room, participant, and member operations through the `basics` skill; do not use `scripts/manager_worker_api.py` for those operations. - If `start-tracking` or `stop-tracking` response shape differs from expectations, patch the script instead of improvising around it. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md index 8d88fcb5..475654da 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md @@ -9,7 +9,7 @@ The skill and CLI use `room` as the user-facing term. Where the underlying HTTP - `CSGCLAW_BASE_URL`: Preferred when the script runs inside a CSGClaw box. - `CSGCLAW_ACCESS_TOKEN`: Preferred bearer token when the script runs inside a CSGClaw box. - `MANAGER_API_BASE_URL`: Optional. Default: `http://127.0.0.1:18080` -- `MANAGER_API_TOKEN`: Optional bearer token. Required for `/api/bots/*` when the server enables auth. +- `MANAGER_API_TOKEN`: Optional bearer token. Required for participant APIs when the server enables auth. - `MANAGER_API_TIMEOUT`: Optional request timeout in seconds. Default: `30` ## Local Config @@ -21,16 +21,16 @@ When available, load the CSGClaw API settings from `~/.openclaw/openclaw.json`: ## Expected Endpoints -### Dispatch task by bot message +### Dispatch task by participant message - Method: `POST` -- Path: `/api/bots/{bot_id}/messages/send` +- Path: `/api/v1/channels/csgclaw/participants/{participant_id}/messages` - Request body: ```json { "room_id": "room-123", - "text": "bob 你来写前端代码,实现设置页 UI" + "text": "bob 你来写前端代码,实现设置页 UI" } ``` @@ -49,7 +49,7 @@ When available, load the CSGClaw API settings from `~/.openclaw/openclaw.json`: ## Notes - There is no dedicated task-assignment API. -- Dispatch still means sending a normal bot message in the target room and mentioning the worker. +- Dispatch still means sending a normal participant message in the target room and mentioning the worker participant. - Each task in `todo.json` should carry an `id` task number, increasing in dispatch order such as `1`, `2`, `3`. - `start-tracking` watches `todo.json`, room history, and IM bootstrap data. - The first task dispatches immediately. Later tasks dispatch only after the previous task both: diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py index cb76060f..b04055c0 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py @@ -513,7 +513,11 @@ def _mock_response( "payload": payload, } - if method == "POST" and path.startswith("/api/bots/") and path.endswith("/messages/send"): + if ( + method == "POST" + and path.startswith("/api/v1/channels/csgclaw/participants/") + and path.endswith("/messages") + ): result["message_id"] = "dry-run-message-id" return result diff --git a/internal/templates/embed/openclaw-worker/workspace/AGENTS.md b/internal/templates/embed/openclaw-worker/workspace/AGENTS.md index 2f2bc96f..c7ca0c6e 100644 --- a/internal/templates/embed/openclaw-worker/workspace/AGENTS.md +++ b/internal/templates/embed/openclaw-worker/workspace/AGENTS.md @@ -19,16 +19,16 @@ first-run hatch or identity onboarding unless the user explicitly asks for it. ## Role -You are an OpenClaw worker bot connected to CSGClaw. Help with general requests, +You are an OpenClaw worker agent connected to CSGClaw. Help with general requests, workspace tasks, and skill-based work. Stay practical, accurate, and concise. ## CSGClaw Runtime - CSGClaw provides the channel bridge and LLM bridge through runtime config. -- Your CSGClaw bot ID uses the worker ID from the channel/runtime config, - commonly `u-` such as `u-frontend-dev`. Rendered mentions may display - only the handle, such as `@frontend-dev`; that is the same identity when the - structured mention or team claim command uses your bot ID. +- Your CSGClaw participant ID comes from the channel/runtime config, commonly a + stable worker slug such as `frontend-dev`. Rendered mentions may display only + the handle, such as `@frontend-dev`; use the exact participant ID shown in + structured mentions or team claim commands. - Do not edit `~/.openclaw/openclaw.json` unless the user asks you to change runtime configuration. - Treat channel messages as user-visible output. Keep private context private, diff --git a/internal/templates/embed/openclaw-worker/workspace/IDENTITY.md b/internal/templates/embed/openclaw-worker/workspace/IDENTITY.md index bd8e7ad8..9f9a4507 100644 --- a/internal/templates/embed/openclaw-worker/workspace/IDENTITY.md +++ b/internal/templates/embed/openclaw-worker/workspace/IDENTITY.md @@ -2,7 +2,7 @@ Name: OpenClaw -Role: CSGClaw worker bot +Role: CSGClaw worker agent Nature: OpenClaw runtime agent managed by CSGClaw diff --git a/internal/templates/embed/openclaw-worker/workspace/skills/agent-teams/SKILL.md b/internal/templates/embed/openclaw-worker/workspace/skills/agent-teams/SKILL.md index 28b3e4c4..c745bb7b 100644 --- a/internal/templates/embed/openclaw-worker/workspace/skills/agent-teams/SKILL.md +++ b/internal/templates/embed/openclaw-worker/workspace/skills/agent-teams/SKILL.md @@ -11,10 +11,10 @@ Only begin work after an explicit dispatch message in the task execution room: ```text [team] Task is ready for you -Claim it with: csgclaw-cli team task claim --team --task --bot-id +Claim it with: csgclaw-cli team task claim --team --task --bot-id ``` -CSGClaw worker IDs normally use the `u-` form, for example `u-frontend-dev`. Rendered mentions may show only the handle, for example `@frontend-dev`. Treat a structured mention for your bot and the `--bot-id` value in the dispatch command as the same identity; use the exact `--bot-id` shown when claiming or updating the task. +The `--bot-id` flag name is legacy; pass the worker participant ID shown in the dispatch message, for example `frontend-dev`. Rendered mentions may show only the handle, for example `@frontend-dev`; use the exact `--bot-id` value shown when claiming or updating the task. Ignore team setup and planning messages, including `[team] Task created`, `[team] Task planning complete`, and `[team] ... started assigning tasks`. Those messages are not permission to start work. @@ -33,13 +33,13 @@ Never infer `team_id` from the room id. A room id such as `room-178...` is not a Claim the dispatched task: ```bash -csgclaw-cli team task claim --team --task --bot-id +csgclaw-cli team task claim --team --task --bot-id ``` If the dispatch did not include a task id, claim the next available task: ```bash -csgclaw-cli team task claim-next --team --bot-id +csgclaw-cli team task claim-next --team --bot-id ``` After claiming, confirm the response status is `in_progress` for the same task before doing the work. @@ -47,7 +47,7 @@ After claiming, confirm the response status is `in_progress` for the same task b Report a completed task: ```bash -csgclaw-cli team task update --team --task --actor-id --status completed --result "" +csgclaw-cli team task update --team --task --actor-id --status completed --result "" ``` The task is not complete until this CLI status update succeeds. Sending a normal room message with the result is useful, but it does not update task state. @@ -55,13 +55,13 @@ The task is not complete until this CLI status update succeeds. Sending a normal Report a blocked task: ```bash -csgclaw-cli team task update --team --task --actor-id --status blocked --reason "" +csgclaw-cli team task update --team --task --actor-id --status blocked --reason "" ``` Report a failed task: ```bash -csgclaw-cli team task update --team --task --actor-id --status failed --error "" +csgclaw-cli team task update --team --task --actor-id --status failed --error "" ``` ## Working Rules diff --git a/internal/templates/embed/picoclaw-manager/workspace/AGENT.md b/internal/templates/embed/picoclaw-manager/workspace/AGENT.md index fbab58fa..fb08f4bb 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/AGENT.md +++ b/internal/templates/embed/picoclaw-manager/workspace/AGENT.md @@ -41,8 +41,8 @@ be practical, accurate, and efficient. - If an available worker can handle the required skill/domain, manager must dispatch to that worker first. - Manager may execute domain work directly only when no suitable worker is available, or when the human explicitly requires manager-only execution. - When direct execution is used as fallback, manager should explain why dispatch was not possible. -- Dispatch means waking a worker with a real IM mention (`csgclaw-cli message create --mention-id ` so the message contains `...`). Do **not** type plain-text `@worker-name` in the room or PicoClaw `message` tool content; workers use `mention_only` and will ignore it. Manager-side `subagent` calls are not valid worker dispatch. -- For work that should be **handed off to a worker** (actionable, tool-heavy, or clearly matching a worker’s skills from `bot list` / descriptions): do **not** open with `web_fetch` or `web_search` to do the worker’s job yourself. For multi-worker team workflows, follow `workspace/skills/agent-teams/SKILL.md` (plan/start via `csgclaw-cli team` and the Tasks API) so dispatch, claim, and status stay on the server task state. Use `manager-worker-dispatch` only when the user explicitly needs tracker-driven sequential handoff outside team tasks. If a **new** worker is needed, use `agent-creator` to provision from hub templates before dispatch continues. Use web tools only for manager-only questions, lightweight clarification, or after you have explained why dispatch is blocked. +- Dispatch means waking a worker with a real IM mention (`csgclaw-cli message create --mention-id ` so the message contains `...`). Do **not** type plain-text `@worker-name` in the room or PicoClaw `message` tool content; workers use `mention_only` and will ignore it. Manager-side `subagent` calls are not valid worker dispatch. +- For work that should be **handed off to a worker** (actionable, tool-heavy, or clearly matching a worker's skills from `participant list` / descriptions): do **not** open with `web_fetch` or `web_search` to do the worker's job yourself. For multi-worker team workflows, follow `workspace/skills/agent-teams/SKILL.md` (plan/start via `csgclaw-cli team` and the Tasks API) so dispatch, claim, and status stay on the server task state. Use `manager-worker-dispatch` only when the user explicitly needs tracker-driven sequential handoff outside team tasks. If a **new** worker is needed, use `agent-creator` to provision from hub templates before dispatch continues. Use web tools only for manager-only questions, lightweight clarification, or after you have explained why dispatch is blocked. ## Casual messages and CSGClaw onboarding @@ -50,7 +50,7 @@ When the user sends a greeting, small talk, or a vague message with **no clear t 1. Do **not** run `csgclaw-cli`, load dispatch skills, or start tool-heavy work yet. 2. Reply warmly and briefly in the **user's language**. -3. Introduce yourself as the **CSGClaw manager** (PicoClaw manager) — the coordinator for bots, workers, rooms, and task handoff in this workspace. +3. Introduce yourself as the **CSGClaw manager** (PicoClaw manager) — the coordinator for agents, workers, rooms, and task handoff in this workspace. 4. Summarize what you can help with, with **short example prompts** the user can copy or adapt. 5. End with one open question: what would they like to do next? @@ -58,7 +58,7 @@ Suggested capability bullets (pick 3–4 that fit; keep the whole reply concise) - **Create workers** from hub templates (GitLab, frontend, QA, review, etc.) — e.g. "帮我创建一个 GitLab worker" - **Assign work** to existing workers in IM rooms and track multi-step handoffs — e.g. "把登录页 UI 交给 frontend worker 做" -- **Manage bots and rooms** — list workers, create rooms, add members — e.g. "列出当前所有 worker" +- **Manage participants and rooms** — list workers, create rooms, add members — e.g. "列出当前所有 worker" - **Answer CSGClaw usage questions** — explain the manager vs worker model when asked Do **not** list skill search or install in the welcome message. Workers install skills themselves via `skill-installer`; manager only dispatches that work when the user asks. @@ -67,7 +67,7 @@ Keep the intro to roughly **6–10 lines** unless the user asks for more detail. ## Skill loading priority -1. **Agent creation first.** If the user wants to create/add/set up/provision an agent, bot, robot, or worker—or names a capability that needs a new worker (GitLab, frontend, QA, etc.)—read `workspace/skills/agent-creator/SKILL.md` **immediately** and follow it. Do **not** run `bot create` without `--from-template`. Skip dispatch until provisioning completes or an existing worker is reused. +1. **Agent creation first.** If the user wants to create/add/set up/provision an agent, bot, robot, or worker—or names a capability that needs a new worker (GitLab, frontend, QA, etc.)—read `workspace/skills/agent-creator/SKILL.md` **immediately** and follow it. Do **not** run `participant create --bind create` without `--from-template`. Skip dispatch until provisioning completes or an existing worker is reused. 2. **Team orchestration second.** For executable multi-worker handoff when workers already exist (or after `agent-creator` finishes), read `workspace/skills/agent-teams/SKILL.md` and use `csgclaw-cli team` (create tasks, plan, start). Each main task gets its own execution room when started; workers are woken there via structured mentions from team dispatch. Use `workspace/skills/manager-worker-dispatch/SKILL.md` only as a legacy fallback when team tasks are not appropriate. - Only after dispatch routing decides execution mode may manager read a domain skill (for worker dispatch constraints or fallback direct execution). - Before planning or dispatching a task, first list local skills under `workspace/skills` and choose from them. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/SKILL.md b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/SKILL.md index 81409a2b..3e8ca057 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/SKILL.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-creator -description: Mandatory skill for provisioning any new CSGClaw agent, bot, robot, or worker. Use immediately when the user asks to create, add, set up, or provision an agent/bot/worker (including GitLab, frontend, backend, QA, or other specialized workers), when dispatch needs a missing worker, or when asking which hub template fits. Always hub list + match + hub get + bot create --from-template with --env for secrets. Never run bot create without --from-template for a new worker. Do NOT use for task dispatch to existing workers or todo.json tracking only. +description: Mandatory skill for provisioning any new CSGClaw agent-backed participant or worker. Use immediately when the user asks to create, add, set up, or provision an agent, robot, worker, or user-facing "bot" (including GitLab, frontend, backend, QA, or other specialized workers), when dispatch needs a missing worker, or when asking which hub template fits. Always hub list + match + hub get + participant create --type agent --bind create --from-template with --env for secrets. Never run participant create --bind create without --from-template for a new worker. Do NOT use for task dispatch to existing workers or todo.json tracking only. --- # Agent Creator @@ -11,7 +11,7 @@ Use `basics` only after create for room membership or IM mentions. Use `manager- ## Routing Gate (mandatory) -Before running **any** `csgclaw-cli bot create` for a **new** worker: +Before running **any** `csgclaw-cli participant create --type agent --bind create` for a **new** worker: 1. Read this skill first. 2. Run `csgclaw-cli --output json hub list` and pick a template (do not skip even if the user named a capability like GitLab). @@ -24,9 +24,9 @@ If dispatch or any other skill says "create a worker", that means **this skill** Use this skill when: -- the user asks to create, add, set up, or provision an agent, bot, robot, or worker +- the user asks to create, add, set up, or provision an agent, robot, worker, or user-facing "bot" - the user names a capability (GitLab, frontend, backend, QA, review, etc.) and needs a matching worker -- `bot list` shows no suitable available worker for the required capability +- `participant list` shows no suitable available worker for the required capability - dispatch needs a new worker (pause dispatch, complete provisioning here, then resume with `basics` + dispatch) Do **not** use this skill when: @@ -41,7 +41,7 @@ Never run a bare worker create like: ```bash # FORBIDDEN for new workers -csgclaw-cli bot create --name gitlab-worker --role worker --runtime picoclaw_sandbox +csgclaw-cli participant create --type agent --bind create --name gitlab-worker --role worker --runtime picoclaw_sandbox ``` Never tell the worker secrets in chat instead of `--env`. @@ -51,9 +51,9 @@ Never skip `hub list` / `hub get` because you think you already know the templat ## Workflow 1. Confirm the user wants a **new** worker (or dispatch lacks one). If an available worker already matches, stop and reuse it. -2. `csgclaw-cli bot list --channel ` — avoid duplicate names; ask reuse vs new if ambiguous. +2. `csgclaw-cli participant list --channel --type agent` — avoid duplicate names; ask reuse vs new if ambiguous. 3. `csgclaw-cli --output json hub list` — match by `name`, `description`, and `role`. -4. No match → say so plainly; do not fall back to bare `bot create`. +4. No match → say so plainly; do not fall back to bare `participant create --bind create`. 5. Multiple matches → short comparison; let the user choose. 6. `csgclaw-cli --output json hub get ` — read `image_env`. 7. Collect every `required=true` env with no `default`; never echo `secret=true` values. @@ -61,7 +61,7 @@ Never skip `hub list` / `hub get` because you think you already know the templat 9. Create: ```bash -csgclaw-cli bot create \ +csgclaw-cli participant create --type agent --bind create \ --name gitlab-worker \ --description "GitLab issue and MR worker" \ --role worker \ @@ -70,15 +70,15 @@ csgclaw-cli bot create \ --channel ``` -10. Report bot id, template id, and env status. Use `basics` for `member create` if the user wants the worker in the room. Do **not** auto-dispatch unless asked. +10. Report participant id, template id, and env status. Use `basics` for `member create` if the user wants the worker in the room. Do **not** auto-dispatch unless asked. ## Commands ```bash csgclaw-cli --output json hub list csgclaw-cli --output json hub get builtin.gitlab-worker -csgclaw-cli bot list --channel -csgclaw-cli bot create --from-template --env KEY=VALUE ... --channel +csgclaw-cli participant list --channel --type agent +csgclaw-cli participant create --type agent --bind create --from-template --env KEY=VALUE ... --channel ``` Template env vars with `default` are injected by the server; pass `--env` only for secrets and overrides. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/agents/openai.yaml b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/agents/openai.yaml index 388f24ac..54483a8f 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/agents/openai.yaml +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Agent Creator" short_description: "Mandatory hub-template worker provisioning" - default_prompt: "Use $agent-creator before any new bot create. Hub list, hub get, then bot create --from-template with --env." + default_prompt: "Use $agent-creator before any new worker create. Hub list, hub get, then participant create --type agent --bind create --from-template with --env." diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-teams/SKILL.md b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-teams/SKILL.md index 80097309..efe9a13b 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-teams/SKILL.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-teams/SKILL.md @@ -37,13 +37,15 @@ Do not invent extra commands. Stay within the shipped CLI surface and Web Tasks Create or enable a team room: ```bash -csgclaw-cli team create --channel csgclaw --room-id --lead-bot-id --member-bot-ids +csgclaw-cli team create --channel csgclaw --room-id --lead-bot-id --member-bot-ids ``` +The `--lead-bot-id` and `--member-bot-ids` flag names are legacy; pass participant IDs. + Create one or more tasks: ```bash -csgclaw-cli team task create-batch --team --created-by --file +csgclaw-cli team task create-batch --team --created-by --file ``` Recommended batch shape for a main task plus subtasks: @@ -58,12 +60,12 @@ Recommended batch shape for a main task plus subtasks: { "title": "Draft release note", "parent_ref": "story", - "assign_to": "u-writer" + "assign_to": "writer" }, { "title": "Smoke test", "parent_ref": "story", - "assign_to": "u-tester" + "assign_to": "tester" } ] } @@ -77,7 +79,7 @@ Attach a new subtask to an existing main task with `parent_id`: { "title": "Prepare rollback note", "parent_id": "task-12", - "assign_to": "u-writer" + "assign_to": "writer" } ] } diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/basics/SKILL.md b/internal/templates/embed/picoclaw-manager/workspace/skills/basics/SKILL.md index 090e857a..ee1d383a 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/basics/SKILL.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/basics/SKILL.md @@ -1,6 +1,6 @@ --- name: basics -description: Handle routine CSGClaw CLI administration for rooms, bot listing, room members, and IM mentions. Use for list bots, member create, message create, and room operations. Do NOT use for creating a new worker—use agent-creator instead (hub list + bot create --from-template). +description: Handle routine CSGClaw CLI administration for rooms, participant listing, room members, and IM mentions. Use for list participants, member create, message create, and room operations. Do NOT use for creating a new worker—use agent-creator instead (hub list + participant create --type agent --bind create --from-template). --- # CSGClaw CLI Basics @@ -14,13 +14,13 @@ This skill covers direct CLI actions such as: - create a room - list rooms -- list all bots +- list all participants - list room members -- add a bot as a room member +- add a participant as a room member - send a message, including a message with a mention - check command help for the current CLI surface before assuming flags -Do **not** use this skill to **create a new worker**. For any new agent/bot/worker provisioning, use `agent-creator` (`hub list`, `hub get`, `bot create --from-template`). +Do **not** use this skill to **create a new worker**. For any new agent/worker provisioning, use `agent-creator` (`hub list`, `hub get`, `participant create --type agent --bind create --from-template`). Do not use this skill when the task requires any of the following: @@ -33,21 +33,21 @@ For hub template selection and `--from-template` creation, use `agent-creator` i ## Workflow -1. Identify the exact room, bot, or member operation the user needs. +1. Identify the exact room, participant, or member operation the user needs. 2. If room context matters, inspect it first with `room list` or `member list`, especially to see whether the room is direct. 3. Run `csgclaw-cli -h` or `csgclaw-cli -h` if the current command surface is not already clear. 4. Execute the smallest direct CLI command that completes the request. -5. Show the user the key result such as the room ID, bot ID, member list summary, or sent message result. +5. Show the user the key result such as the room ID, participant ID, member list summary, or sent message result. ## Common Commands Create a room: ```bash -csgclaw-cli room create --title test-room --creator-id u-manager --member-ids u-manager,u-dev --channel +csgclaw-cli room create --title test-room --creator-id manager --member-ids manager,dev --channel csgclaw ``` -Use CSGClaw bot IDs in room, member, and message commands. +Use CSGClaw participant IDs in CSGClaw-channel room, member, and message commands. The default manager participant is `manager`; its backing agent ID is `u-manager`. List rooms and check whether a room is direct: @@ -55,13 +55,13 @@ List rooms and check whether a room is direct: csgclaw-cli room list --channel ``` -List bots: +List participants: ```bash -csgclaw-cli bot list --channel +csgclaw-cli participant list --channel --type agent ``` -Create a bot. Always include `--description`: +Create a worker participant: ```bash # Do not use this for new workers. Use agent-creator with --from-template instead. @@ -73,36 +73,36 @@ List members in a room: csgclaw-cli member list --room-id oc_xxx --channel ``` -Add a bot into a non-direct room: +Add a participant into a non-direct room: ```bash -csgclaw-cli member create --room-id oc_xxx --user-id u-alex --inviter-id u-manager --channel +csgclaw-cli member create --room-id oc_xxx --user-id alex --inviter-id manager --channel csgclaw ``` -If the current room is direct in the local `csgclaw` channel, do not try to add the bot directly. Create a new room that includes the current DM participants plus the new bot: +If the current room is direct in the local `csgclaw` channel, do not try to add the participant directly. Create a new room that includes the current DM participants plus the new participant: ```bash csgclaw-cli room create \ --title "manager-dev-alex" \ - --creator-id u-manager \ - --member-ids u-manager,u-dev,u-alex \ - --channel + --creator-id manager \ + --member-ids manager,dev,alex \ + --channel csgclaw ``` -For Feishu, keep the same bot ID parameters: +For Feishu, use the configured Feishu participant IDs: ```bash csgclaw-cli room create \ --title "manager-dev-alex" \ - --creator-id u-manager \ - --member-ids u-manager,u-dev,u-alex \ + --creator-id manager \ + --member-ids manager,dev,alex \ --channel feishu ``` -Send a message with a mention. Use the mentioned bot ID for `--mention-id`: +Send a message with a mention. Use the mentioned participant ID for `--mention-id`: ```bash -csgclaw-cli message create --room-id oc_xxx --sender-id u-manager --content "Please take a look." --mention-id u-alex --channel +csgclaw-cli message create --room-id oc_xxx --sender-id manager --content "Please take a look." --mention-id alex --channel csgclaw ``` ## Notifying workers in IM (critical) @@ -111,28 +111,28 @@ Workers are configured with **`mention_only`**: they only process group messages | Do | Do not | |----|--------| -| `csgclaw-cli message create ... --mention-id u-gitlab-worker` (ID from `bot list`) | Type `@gitlab-worker` or `@worker-name` in `--content`, room replies, or the PicoClaw `message` tool | -| Verify delivery with `message list` — content must include `` | Assume a human-style `@` in prose wakes the worker | -| Run `bot list` and `member list` before the first dispatch | Skip membership checks and post assignment text only | +| `csgclaw-cli message create ... --mention-id gitlab-worker` (participant ID from `participant list`) | Type `@gitlab-worker` or `@worker-name` in `--content`, room replies, or the PicoClaw `message` tool | +| Verify delivery with `message list` — content must include a structured `` tag | Assume a human-style `@` in prose wakes the worker | +| Run `participant list` and `member list` before the first dispatch | Skip membership checks and post assignment text only | Minimal handoff flow: -1. `csgclaw-cli bot list` — resolve the worker **bot ID** (e.g. `u-gitlab-worker`, not the display name). +1. `csgclaw-cli participant list` — resolve the worker participant ID (e.g. `gitlab-worker`, not the display name). 2. `csgclaw-cli member list` — confirm the worker is in the room; `member create` if missing. 3. `csgclaw-cli message create` with `--mention-id` and the task body. 4. `csgclaw-cli message list` — confirm the stored message contains ``. For multi-worker team tasks, use `agent-teams` (`csgclaw-cli team` plan/start) instead of manual room messages. Use `manager-worker-dispatch` only when team tasks are not in use. -Example worker handoff (replace room ID, worker ID, and channel): +Example worker handoff (replace room ID, participant ID, and channel): ```bash csgclaw-cli message create \ --room-id \ - --sender-id u-manager \ - --mention-id u-alex \ + --sender-id manager \ + --mention-id alex \ --content "Please implement the login page changes we discussed." \ - --channel + --channel csgclaw ``` Do **not** post `@alex` plain text in the room instead of `--mention-id`. @@ -140,11 +140,11 @@ Do **not** post `@alex` plain text in the room instead of `--mention-id`. ## Operating Rules - Prefer direct `csgclaw-cli` commands over ad hoc HTTP calls. -- Use `bot list` before creating a new bot if the user may be referring to an existing one. -- When a **new** worker is needed, use `agent-creator`; do not run bare `bot create` from this skill. +- Use `participant list` before creating a new worker if the user may be referring to an existing one. +- When a **new** worker is needed, use `agent-creator`; do not run bare `participant create --bind create` from this skill. - Verify room membership with `member list` after adding a member when room presence matters. -- A direct room cannot accept an added bot as a new member. Create a new room with `--member-ids` containing the existing DM bots and the new bot. -- Keep `csgclaw-cli` parameters bot-facing across channels: use bot IDs such as `u-manager`, `u-dev`, and `u-alex`. +- A direct room cannot accept an added participant as a new member. Create a new room with `--member-ids` containing the existing DM participants and the new participant. +- Use participant IDs at the CLI boundary. For the local CSGClaw manager use `manager`; use `u-manager` only when calling an agent route or the Feishu credential config API field that still names its key `bot_id`. - Never notify a worker with plain-text `@name`; always use `message create --mention-id` and verify `` in `message list`. - Keep the response focused on the concrete CLI result instead of introducing external planning artifacts. - Hand off to `agent-teams` for multi-worker team orchestration; use `manager-worker-dispatch` only if the user explicitly needs tracker handoff outside team tasks. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/basics/agents/openai.yaml b/internal/templates/embed/picoclaw-manager/workspace/skills/basics/agents/openai.yaml index fab9e3f8..80855f5d 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/basics/agents/openai.yaml +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/basics/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "CSGClaw CLI Basics" - short_description: "Basic CSGClaw CLI room/bot/member operations" - default_prompt: "Use $basics to handle basic CSGClaw CLI operations such as creating rooms, listing bots, creating bots, listing room members, and adding bots into rooms." + short_description: "Basic CSGClaw CLI room/participant/member operations" + default_prompt: "Use $basics to handle basic CSGClaw CLI operations such as creating rooms, listing participants, listing room members, and adding participants into rooms." diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/SKILL.md b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/SKILL.md index e9f5b458..da96389c 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/SKILL.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/SKILL.md @@ -1,11 +1,11 @@ --- name: feishu -description: Configure and troubleshoot CSGClaw Feishu/Lark channel credentials for manager or worker bots. Use when the Manager needs to generate a bot creation URL or QR code, collect App ID/App Secret through registration, write and reload channel config through csgclaw-cli bot config, ensure or recreate agents, or debug Feishu messages not reaching CSGClaw/PicoClaw bots. +description: Configure and troubleshoot CSGClaw Feishu/Lark channel credentials for manager or worker agents. Use when the Manager needs to generate a Feishu bot app creation URL or QR code, collect App ID/App Secret through registration, write and reload channel config through Feishu config API, ensure or recreate agents, or debug Feishu messages not reaching CSGClaw/PicoClaw workers. --- # Feishu -This skill sets up Feishu/Lark bot credentials for CSGClaw-managed PicoClaw manager and worker bots. +This skill sets up Feishu/Lark bot app credentials for CSGClaw-managed PicoClaw manager and worker agents. ## Script @@ -23,7 +23,7 @@ If `start`/`poll` returns a machine-mode `next` command, prefer that absolute co - `scripts/feishu_register.py`: User-facing CLI entrypoint. Supports `start`, `poll`, `finalize`, `status`, `recreate-agent`. - `scripts/feishu_setup/commands.py`: Parses CLI arguments and maps them to handler functions. - `scripts/feishu_setup/registration.py`: Implements registration flow and device-code polling state transitions. -- `scripts/feishu_setup/csgclaw.py`: Applies config to CSGClaw, triggers reload, and performs bot/agent ensure/recreate actions. +- `scripts/feishu_setup/csgclaw.py`: Applies config to CSGClaw, triggers reload, and performs participant/agent ensure/recreate actions. - `scripts/feishu_setup/state.py`: Stores and migrates registration state files. - `scripts/feishu_setup/config.py`: Defines constants, env-key names, and default path constants. - `scripts/tests/`: tests and fixtures for script behavior. @@ -36,30 +36,30 @@ The script uses Feishu/Lark's accounts registration flow: 4. poll with `action=poll`, `device_code=<...>`, `tp=ob_app` 5. when the user completes app creation, receive `client_id` and `client_secret` 6. map `client_id` -> CSGClaw `app_id`, and `client_secret` -> CSGClaw `app_secret` -7. immediately write the secret to CSGClaw through `csgclaw-cli bot config --channel feishu --set` without printing it +7. immediately write the secret to CSGClaw through `PUT /api/v1/channels/feishu/config` without printing it -Do not add or require a public Feishu Open Platform HTTP webhook as the main inbound path. PicoClaw uses Feishu/Lark WebSocket mode for inbound bot messages. CSGClaw's `/api/v1/channels/feishu/bots/{bot}/events` endpoint is an internal SSE bridge for PicoClaw workers, not a Feishu public webhook. +Do not add or require a public Feishu Open Platform HTTP webhook as the main inbound path. PicoClaw uses Feishu/Lark WebSocket mode for inbound bot messages. CSGClaw's `/api/v1/channels/feishu/participants/{participant}/events` endpoint is an internal SSE bridge for PicoClaw workers, not a Feishu public webhook. ## When to Use Use this skill when the user asks to: -- create/configure Feishu for `u-manager` or a worker such as `u-dev` +- create/configure Feishu credentials for the manager agent `u-manager` or a worker agent such as `u-dev` - generate a Feishu/Lark bot creation URL or QR code -- get Feishu AK/SK, App ID/App Secret, or client_id/client_secret for a CSGClaw bot +- get Feishu AK/SK, App ID/App Secret, or client_id/client_secret for a CSGClaw-managed agent - reload CSGClaw channel config after setting Feishu credentials - recreate a worker or manager after Feishu credentials are configured -- debug why Feishu messages do not reach a CSGClaw/PicoClaw bot +- debug why Feishu messages do not reach a CSGClaw/PicoClaw worker Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Feishu app development. ## Terms -- CSGClaw bot ID: usually `u-manager`, `u-dev`, `u-qa`, etc. +- Target agent ID: usually `u-manager`, `u-dev`, `u-qa`, etc. The helper script still names this legacy argument `--bot-id`. - Feishu `app_id` / `app_secret`: the Feishu bot application's credentials. - AK/SK in user wording usually means Feishu `app_id/app_secret` or `client_id/client_secret` returned by the registration flow. - Manager agent: usually `u-manager`; recreating it can interrupt the current manager skill run. -- Worker agent: any non-manager bot, for example `u-dev`; recreating it is usually safe after config succeeds. +- Worker agent: any non-manager agent, for example `u-dev`; recreating it is usually safe after config succeeds. ## Prerequisites @@ -71,8 +71,8 @@ Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Fei - inside manager box: typically `~/.picoclaw/workspace/skills/feishu` or your configured skill root - host repo path: `internal/templates/embed/picoclaw-manager/workspace/skills/feishu` 4. Server build supports: - - `csgclaw-cli bot config --channel feishu --set/--get/--reload` - - `POST /api/v1/channels/feishu/bots` + - Feishu config API (`PUT`/`GET`/`POST /api/v1/channels/feishu/config`) + - `POST /api/v1/channels/feishu/participants` - `POST /api/v1/agents/{id}/recreate` ## Safe Credential Rules @@ -80,30 +80,30 @@ Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Fei 1. Never print `app_secret`, `client_secret`, access tokens, verification tokens, encryption keys, or connection strings. 2. If a secret must be represented in examples or summaries, write `[REDACTED]`. 3. The script must print only `app_secret: present` after finalize. -4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `csgclaw-cli bot config --channel feishu --set --app-secret-stdin`. -5. Verify with `csgclaw-cli bot config --channel feishu --get`, not by printing the secret. +4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `PUT /api/v1/channels/feishu/config`. +5. Verify with `GET /api/v1/channels/feishu/config?bot_id=`, not by printing the secret. -## Choose Target Bot +## Choose Target Agent Ask for the target when it is not explicit. If the user does not specify an agent in the request, ask: "请明确要对接飞书的目标 Agent 名字(如 `manager`/`u-manager` 或 `dev`/`u-dev`)". Resolve target: 1. If input is `manager` or `u-manager`, treat as manager flow. -2. Otherwise, treat input as worker flow, set `bot_id` to the input if it already starts with `u-`, otherwise prefix `u-`. +2. Otherwise, treat input as worker flow, set the target agent ID to the input if it already starts with `u-`, otherwise prefix `u-`. 3. If only role was inferred as manager, stop using recreate path and force action-card flow. Example normalization: -- `dev` -> worker `u-dev` -- `u-dev` -> worker `u-dev` +- `dev` -> worker agent `u-dev`, participant `dev` +- `u-dev` -> worker agent `u-dev`, participant `dev` - `manager` -> manager - `u-manager` -> manager -For worker flow, check whether the Feishu bot already exists before deciding recreate: +For worker flow, check whether the Feishu participant already exists before deciding recreate: ```bash -./csgclaw-cli --output json bot list --channel feishu +./csgclaw-cli --output json participant list --channel feishu --type agent ``` -Treat a row whose `id` equals `$bot_id` as an existing Feishu bot (needs recreate after ensure), and no matching row as missing (skip recreate, let bot ensure create it). +Treat a row whose `agent_id` equals the target agent ID as an existing Feishu participant (needs recreate after ensure), and no matching row as missing (skip recreate, let participant ensure create it). ## Primary QR/Launcher Flow @@ -113,7 +113,7 @@ Run from this skill directory: ```bash python /home/picoclaw/.picoclaw/workspace/skills/feishu/scripts/feishu_register.py start \ - --bot-id \ + --bot-id \ --role worker \ --bot-name \ --description "dev worker agent" \ @@ -145,14 +145,14 @@ By default, `finalize` will: 1. poll Feishu/Lark until credentials are available or timeout 2. receive `client_id/client_secret` -3. write `app_id/app_secret` to CSGClaw through `csgclaw-cli bot config` +3. write `app_id/app_secret` to CSGClaw through `Feishu config API` - for `u-manager`, overwrite global `admin_open_id` only with the registration `open_id` - - for worker bots, ignore registration `open_id` and do not read, preserve, write, or report `admin_open_id` + - for worker agents, ignore registration `open_id` and do not read, preserve, write, or report `admin_open_id` 4. auto-reload channel config -5. ensure the CSGClaw bot through `POST /api/v1/channels/feishu/bots` -6. for worker targets, check whether the Feishu bot already existed before ensure using `./csgclaw-cli --output json bot list --channel feishu`: - - existing bot: recreate its worker so the new Feishu env takes effect - - missing bot: let bot ensure create it with the already-reloaded config, then skip redundant recreate +5. ensure the CSGClaw Feishu participant through `POST /api/v1/channels/feishu/participants` +6. for worker targets, check whether the Feishu participant already existed before ensure using `./csgclaw-cli --output json participant list --channel feishu --type agent`: + - existing participant: recreate its worker so the new Feishu env takes effect + - missing participant: let participant ensure create it with the already-reloaded config, then skip redundant recreate - if BoxLite reports `box with name '' already exists` while CSGClaw reports `agent "" not found`, stop and tell the user the host has a stale partial worker box; do not keep trying random API paths or host-only commands from inside manager 7. for manager targets, print a `csgclaw.action_card` JSON payload with a whitelisted `rebuild-manager` action; the CSGClaw Web chat message should render the button to complete the window-triggered manager bootstrap replace flow. 8. print JSON with `app_secret: present`, never the real secret @@ -163,12 +163,12 @@ For a worker, default finalize is usually enough: python /home/picoclaw/.picoclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id ``` -Use an exec/tool timeout of at least 600 seconds for this command. Before deciding recreate, use `./csgclaw-cli --output json bot list --channel feishu`: +Use an exec/tool timeout of at least 600 seconds for this command. Before deciding recreate, use `./csgclaw-cli --output json participant list --channel feishu --type agent`: - matching `id`: recreate existing worker - - no matching `id`: skip recreate, because bot ensure has already created it -If `worker_existed_before_ensure` is `true`, the script recreates the existing worker after config reload; do not create a second worker or change the bot id. + - no matching `id`: skip recreate, because participant ensure has already created it +If `worker_existed_before_ensure` is `true`, the script recreates the existing worker after config reload; do not create a second worker or change the target agent ID. -For manager, default finalize configures and ensures the bot, then prints a structured action card. Return the JSON object exactly as the chat message content: no leading sentence, no Markdown table, no bullet list, no ```json fence, and no explanatory wrapper. The CSGClaw Web frontend will render a "重建 Manager" button. +For manager, default finalize configures credentials and ensures the participant, then prints a structured action card. Return the JSON object exactly as the chat message content: no leading sentence, no Markdown table, no bullet list, no ```json fence, and no explanatory wrapper. The CSGClaw Web frontend will render a "重建 Manager" button. The click is handled by the browser and calls the manager bootstrap replace surface (`POST /api/v1/agents` with `{"id":"u-manager","replace":true}`), not the hazardous generic recreate route. Do not run `python /home/picoclaw/.picoclaw/workspace/skills/feishu/scripts/feishu_register.py recreate-agent --bot-id u-manager` as a terminal self-recreate step anymore. The manager-rebuild action must be completed by clicking the rendered Web window button, which calls `POST /api/v1/agents` with `{"id":"u-manager","replace":true}`. @@ -203,36 +203,35 @@ If Feishu/Lark registration endpoint fails, expires, or tenant policy blocks sca - App ID, usually `cli_...` - App Secret, provided only through a secure path. -Use `csgclaw-cli bot config` to set manually: +Use the Feishu config API to set manually: ```bash -printf '%s' '[REDACTED]' | csgclaw-cli bot config --channel feishu --set \ - --bot-id u-dev \ - --app-id cli_xxx \ - --app-secret-stdin +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-dev","app_id":"cli_xxx","app_secret":"[REDACTED]"}' ``` -or: +For manager setup, include `admin_open_id`: ```bash -csgclaw-cli bot config --channel feishu --set \ - --bot-id u-dev \ - --app-id cli_xxx \ - --app-secret-file /secure/path/feishu_app_secret +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-manager","app_id":"cli_xxx","app_secret":"[REDACTED]","admin_open_id":"ou_xxx"}' ``` ## CLI Workflow Used by Script -The script writes and reloads Feishu config through `csgclaw-cli bot config` because sandboxed skills should not edit host files directly or hand-roll config API calls. +The script writes and reloads Feishu config through `Feishu config API` because sandboxed skills should not edit host files directly or hand-roll config API calls. For `u-manager`, the script passes the registration `open_id` as the global `admin_open_id` while setting config and auto-reloading: ```bash -printf '%s' '[REDACTED]' | csgclaw-cli --output json bot config --channel feishu --set \ - --bot-id u-manager \ - --app-id cli_xxx \ - --admin-open-id ou_xxx \ - --app-secret-stdin +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-manager","app_id":"cli_xxx","app_secret":"[REDACTED]","admin_open_id":"ou_xxx"}' ``` Expected response shape: @@ -248,13 +247,13 @@ Expected response shape: } ``` -Ensure bot: +Ensure participant: ```bash -csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +csgclaw-cli participant create --type agent --bind create --id dev --agent-id u-dev --name dev --description "dev worker agent" --role worker --channel feishu --channel-user-ref ou_xxx --channel-user-kind open_id --channel-app-ref cli_xxx ``` -Recreate existing worker only if `./csgclaw-cli --output json bot list --channel feishu` showed `u-dev` before ensure; if the bot was missing, the bot ensure step creates it with the already-reloaded config and this recreate call is skipped: +Recreate existing worker only if `./csgclaw-cli --output json participant list --channel feishu --type agent` already showed a participant bound to `u-dev` before ensure; if the participant was missing, the participant ensure step creates it with the already-reloaded config and this recreate call is skipped: ```bash curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/agents/u-dev/recreate" \ @@ -263,12 +262,14 @@ curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/agents/u-dev/recreate" \ ## CLI Workflow for Manual Control -Use `csgclaw-cli bot config` for channel config. Use the helper script or the backend recreate API for agent recreate, because lite `csgclaw-cli` does not expose agent commands and manager boxes usually do not have full `csgclaw`. +Use `Feishu config API` for channel config. Use the helper script or the backend recreate API for agent recreate, because lite `csgclaw-cli` does not expose agent commands and manager boxes usually do not have full `csgclaw`. ```bash -csgclaw-cli bot config --channel feishu --get --bot-id u-dev -csgclaw-cli bot config --channel feishu --reload -csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +curl -sS "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config?bot_id=u-dev" \ + -H "Authorization: Bearer [REDACTED]" +curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" +csgclaw-cli participant create --type agent --bind create --id dev --agent-id u-dev --name dev --description "dev worker agent" --role worker --channel feishu --channel-user-ref ou_xxx --channel-user-kind open_id --channel-app-ref cli_xxx python /home/picoclaw/.picoclaw/workspace/skills/feishu/scripts/feishu_register.py recreate-agent --bot-id u-dev ``` @@ -289,16 +290,16 @@ python /home/picoclaw/.picoclaw/workspace/skills/feishu/scripts/feishu_register. Run the command with exec `timeout` at least `600`. -4. Confirm existing Feishu bot before taking recreate path: +4. Confirm existing Feishu participant before taking recreate path: ```bash -./csgclaw-cli --output json bot list --channel feishu +./csgclaw-cli --output json participant list --channel feishu --type agent ``` If the list contains ``, the manager can trigger recreate flow for this worker after reload. -If the list does not contain ``, skip recreate and let bot ensure creation stand. +If the list does not contain ``, skip recreate and let participant ensure creation stand. -5. Tell the user to test from Feishu by messaging or @mentioning the bot. +5. Tell the user to test from Feishu by messaging or @mentioning the Feishu bot app. ## Manager One-Shot Recipe @@ -327,11 +328,11 @@ Do not use the generic manager recreate endpoint or any terminal/host-side manag 1. Using `csgclaw-cli agent ...`: lite CLI does not have agent commands. Use full `csgclaw` or API. 2. Running host-only `csgclaw` or `boxlite` commands from inside manager: manager usually only has `csgclaw-cli`; use this script/API from manager, and ask the host operator to clean stale BoxLite boxes if needed. -3. Looking for removed `csgclaw channel ...` commands: Feishu config belongs to `csgclaw-cli bot config --channel feishu`. -4. Creating the CSGClaw bot before writing/reloading Feishu config: this can create local placeholder identity. +3. Looking for removed `csgclaw-cli bot config ...` commands: use `csgclaw-cli participant config --channel feishu ...`, backed by `/api/v1/channels/feishu/config`. +4. Creating the CSGClaw participant before writing/reloading Feishu config: this can create local placeholder identity. 5. Expecting reload to update an already-running PicoClaw box: recreate is still required. 6. Calling manager recreate from inside this manager-hosted skill: return the action card so the current window renders the rebuild button. -7. Checking `agent list` or `bot list` after manager recreate and treating `stopped` as failure: manager gateway runs in daemon mode, so BoxLite status is not a reliable success signal for this skill. +7. Checking `agent list` or `participant list` after manager recreate and treating `stopped` as failure: manager gateway runs in daemon mode, so BoxLite status is not a reliable success signal for this skill. 8. Printing secrets in summaries or logs: always mask as `[REDACTED]` or `present`. 9. Calling CSGClaw SSE endpoint a Feishu webhook: it is an internal CSGClaw-to-PicoClaw bridge. 10. If Feishu changes the accounts registration endpoint or tenant policy blocks PersonalAgent creation, fall back to manual App ID/App Secret setup. @@ -340,9 +341,9 @@ Do not use the generic manager recreate endpoint or any terminal/host-side manag - [ ] `start` printed a launcher URL or QR code for the user. - [ ] `finalize` output shows `app_secret` only as `present`. -- [ ] `finalize` configured `bot_id` and `app_id` in CSGClaw. +- [ ] `finalize` configured the target agent ID (`bot_id` field) and `app_id` in CSGClaw. - [ ] CSGClaw channel config was reloaded. -- [ ] CSGClaw bot exists with `channel=feishu`. +- [ ] CSGClaw participant exists with `channel=feishu`. - [ ] Existing worker agents are recreated after config reload. - [ ] New worker finalize was run with a tool timeout of at least 600 seconds. - [ ] Manager finalize returned a raw `csgclaw.action_card` JSON object with `rebuild-manager` action metadata for the web button. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py index c08d4609..0979b56b 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py @@ -229,7 +229,7 @@ def build_parser() -> argparse.ArgumentParser: finalize.add_argument("--registration-id", required=True) finalize.add_argument("--timeout", type=int, default=DEFAULT_EXPIRE_SECONDS) finalize.add_argument("--no-configure", action="store_true", help="Do not write CSGClaw config; for debugging only, still never prints secret") - finalize.add_argument("--no-ensure-bot", action="store_true", help="Skip POST /api/v1/channels/feishu/bots") + finalize.add_argument("--no-ensure-bot", action="store_true", help="Skip POST /api/v1/channels/feishu/participants") finalize.add_argument("--role", choices=["worker", "manager"], default="", help="Override role for ensure/recreate logic") finalize.add_argument("--bot-name", default="", help="Override bot name for ensure") finalize.add_argument("--description", default="", help="Override bot description for ensure") diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py index 1b9c1b8f..ccc811a5 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py @@ -99,22 +99,15 @@ def csgclaw_cli_json(args, cli_args: list[str], input_text: Optional[str] = None def configure_csgclaw(args, state: dict, result: dict) -> dict: bot_id = state["bot_id"] - cli_args = [ - "bot", - "config", - "--channel", - "feishu", - "--set", - "--bot-id", - bot_id, - "--app-id", - result["app_id"], - "--app-secret-stdin", - ] + payload = { + "bot_id": bot_id, + "app_id": result["app_id"], + "app_secret": result["app_secret"], + } candidate_admin_open_id = str(result.get("open_id") or "").strip() if bot_id == "u-manager" and candidate_admin_open_id: - cli_args.extend(["--admin-open-id", candidate_admin_open_id]) - response = csgclaw_cli_json(args, cli_args, input_text=result["app_secret"] + "\n") or {} + payload["admin_open_id"] = candidate_admin_open_id + response = api_json(args, "PUT", "/api/v1/channels/feishu/config", payload) or {} if bot_id == "u-manager": if candidate_admin_open_id: response["admin_open_id"] = candidate_admin_open_id @@ -140,12 +133,22 @@ def ensure_bot(args, state: dict, result: dict) -> Optional[dict]: description = args.description or state.get("description") or f"{name} Feishu {role} agent" payload = { "id": bot_id, + "type": "agent", "name": name, - "description": description, - "role": role, - "channel": "feishu", + "channel_app_ref": result.get("app_id") or state.get("app_id") or "", + "channel_user": {"ref": bot_id, "kind": "local_user_id"}, + "agent_binding": { + "mode": "create", + "agent_id": bot_id, + "agent": { + "id": bot_id, + "name": name, + "description": description, + "role": role, + }, + }, } - return api_json(args, "POST", f"/api/v1/channels/feishu/bots", payload) + return api_json(args, "POST", "/api/v1/channels/feishu/participants", payload) def worker_box_conflict_message(bot_id: str, name: str) -> str: @@ -162,10 +165,10 @@ def is_box_name_conflict(exc: RuntimeError, name: str) -> bool: def bot_exists(args, bot_id: str) -> bool: - bots = csgclaw_cli_json(args, ["bot", "list", "--channel", "feishu"]) - if not isinstance(bots, list): - raise RuntimeError(f"csgclaw-cli bot list returned unexpected JSON: {bots!r}") - return any(str(bot.get("id") or "").strip() == bot_id for bot in bots if isinstance(bot, dict)) + participants = csgclaw_cli_json(args, ["participant", "list", "--channel", "feishu", "--type", "agent"]) + if not isinstance(participants, list): + raise RuntimeError(f"csgclaw-cli participant list returned unexpected JSON: {participants!r}") + return any(str(item.get("id") or "").strip() == bot_id for item in participants if isinstance(item, dict)) def maybe_recreate(args, state: dict, worker_existed_before_ensure: Optional[bool] = None) -> Optional[dict]: diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md index c6d117b2..eb2ad302 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md @@ -7,7 +7,7 @@ description: Use this skill only for manager-led multi-worker coordination in CS Break a multi-worker admin request into clear tasks, choose workers by capability, and dispatch them through CSGClaw's real local interfaces in sequence. -Use the `basics` skill for room, bot, and member operations that support the dispatch flow. Use `skill-installer` for registry skill search; workers use `skill-installer` for install (not `find_skills`). +Use the `basics` skill for room, participant, and member operations that support the dispatch flow. Use `skill-installer` for registry skill search; workers use `skill-installer` for install (not `find_skills`). Use `scripts/manager_worker_api.py` only for `start-tracking` and `stop-tracking`. Check the current CLI surface through the `basics` skill instead of writing ad hoc API requests. @@ -23,22 +23,22 @@ Do not use this skill for: - collecting template `image_env` values - creating agents with `--from-template` -For those flows, use `agent-creator`. Never create a new worker with bare `bot create` from dispatch. +For those flows, use `agent-creator`. Never create a new worker with bare `participant create --bind create` from dispatch. ## Mandatory Dispatch Order When a user asks for manager-led coordination (especially GitLab planning, issue queries, breakdown, assignment, or execution handoff), follow this order and do not skip steps: 1. Read this skill first before any domain execution. -2. Check existing workers first (`bot list`) and prefer reusing a capable available worker. +2. Check existing workers first (`participant list`) and prefer reusing a capable available worker. 3. Ensure the selected worker is a member of the target room (add if missing). 4. If no suitable worker exists or the matching one is `unavailable`: - - **New worker needed:** stop dispatch, follow `agent-creator` (hub list → hub get → `bot create --from-template` + `--env`), then use `basics` to add the worker to the room. - - **Existing worker unavailable:** use `basics` / recreate paths for that bot id only; do not bare-create a replacement. + - **New worker needed:** stop dispatch, follow `agent-creator` (hub list → hub get → `participant create --type agent --bind create --from-template` + `--env`), then use `basics` to add the worker to the room. + - **Existing worker unavailable:** use `basics` / recreate paths for that participant only; do not bare-create a replacement. 5. Dispatch the task to the worker in-room after membership is confirmed. Do not start with repo/code exploration, web fetch/search, or manager self-execution when the task is delegable to an existing or creatable worker. -Do not skip `bot list` by assuming worker availability from memory or prior turns. +Do not skip `participant list` by assuming worker availability from memory or prior turns. ## Fast Path @@ -60,15 +60,15 @@ Do not inspect or modify project implementation files before dispatch unless you ## Workflow 1. Break the admin request into concrete deliverables. -2. Match each task to the needed capability; use the `basics` skill to inspect existing workers first (`bot list`) and reuse by matching `description`. -3. If a suitable worker does not exist or is `unavailable`, provision via `agent-creator` (new) or recreate the existing bot (unavailable) before dispatch. +2. Match each task to the needed capability; use the `basics` skill to inspect existing workers first (`participant list`) and reuse by matching `description`. +3. If a suitable worker does not exist or is `unavailable`, provision via `agent-creator` (new) or recreate the existing worker before dispatch. 4. Use the `basics` skill to ensure every required worker has joined the target room, then verify the full required worker set. 5. Dispatch the user task to selected workers in-room after membership checks pass. 6. Choose a suitable project directory under `~/.picoclaw/workspace/projects`; create a short slug directory if none fits. 7. Write or overwrite `todo.json` in that directory as the only source of truth for the current dispatch plan, but only after the room-membership verification succeeds. 8. Start `scripts/manager_worker_api.py start-tracking` against that `todo.json`, but only after all required workers are confirmed present in the room. 9. Let the tracker own sequential handoff; workers must reply in-room with results or blockers, and neither the manager nor workers should manually assign the next worker while tracking is active. -10. After `start-tracking`, run an explicit dispatch-delivery check before claiming success: query recent room messages and confirm the tracker/bot has posted a task message with a real mention (`mention_id` targeting the assignee user; rendered as `...`). +10. After `start-tracking`, run an explicit dispatch-delivery check before claiming success: query recent room messages and confirm the tracker has posted a task message with a real mention (`mention_id` targeting the assignee participant; rendered as `...`). ## Room Membership Gate @@ -163,11 +163,11 @@ Split cross-capability work into multiple tasks instead of giving one vague pack Use the `basics` skill whenever this workflow needs any of these supporting operations: - create the target room -- list workers or bots +- list workers or participants - add a worker into the room - verify room membership before tracking -When a **new** worker is required, use `agent-creator` instead of bare `bot create`. Use `basics` here only for recreate when an existing listed worker is `unavailable`. +When a **new** worker is required, use `agent-creator` instead of bare `participant create --bind create`. Use `basics` here only for recreate when an existing listed worker is `unavailable`. ## Tracking Script Usage @@ -191,8 +191,8 @@ csgclaw-cli --output json message list --room-id --channel Name`. +- There is a new tracker dispatch message from the manager participant in the target room. +- The dispatch message includes mention to the selected assignee (`mention_id` equals the worker participant ID). Do **not** rely on plain-text `@name`; CSGClaw renders mentions as `Name`. - Message content matches the task dispatch text pattern (task id/todo path context). If verification does not pass: @@ -200,7 +200,7 @@ If verification does not pass: 1. Wait briefly and re-check message list up to 3 times. 2. If still missing, report dispatch failure with evidence. 3. Do **not** send a manual fallback assignment message while tracking is active. -4. Stop tracking if needed, fix root cause (assignee handle mismatch, room membership mismatch, bot id mismatch), then restart tracking. +4. Stop tracking if needed, fix root cause (assignee handle mismatch, room membership mismatch, participant ID mismatch), then restart tracking. Stop the tracking: @@ -214,7 +214,7 @@ If you need to direct the human user to the project files on their Mac, point th Use this when exactly one worker should act (for example install a registry skill via `skill-installer`, run one GitLab job) and you do **not** need multi-step `todo.json` sequencing. -1. Use the `basics` skill: `bot list`, confirm room membership, then `message create --mention-id `. +1. Use the `basics` skill: `participant list`, confirm room membership, then `message create --mention-id `. 2. Do **not** reply in the room with plain `@worker-name` after registry skill discovery or other tools — that does not wake workers under `mention_only`. 3. Verify with `csgclaw-cli message list` that the dispatch message contains ``. 4. Use `start-tracking` only when multiple workers or ordered handoff is required. @@ -222,7 +222,7 @@ Use this when exactly one worker should act (for example install a registry skil ## Operating Rules - Reuse available workers before creating new ones. -- If a matching worker is listed as `unavailable`, recreate that existing bot; do not bare-create a different replacement worker. +- If a matching worker is listed as `unavailable`, recreate that existing worker; do not bare-create a different replacement worker. - Before writing the final `todo.json` or running `start-tracking`, use the `basics` skill to verify all required workers are already members of the target room. - Treat missing room membership as a blocker: add the worker, verify again, and only then continue with `todo.json` and tracking. - Keep `todo.json` aligned with the actual assignment being dispatched. @@ -231,5 +231,5 @@ Use this when exactly one worker should act (for example install a registry skil - Never "补发" assignment messages manually after `start-tracking`; treat missing mention/`mention_id` dispatch as a verification failure to debug, not a prompt for manual dispatch. - While tracking is active, do not manually tell the next worker to start in prose. The tracker is the only sequencer. - When a worker finishes, they must reply in the shared room with a normal summary or blocker note; updating `todo.json` alone does not release the next task. -- Route all non-tracking room, bot, and member operations through the `basics` skill; do not use `scripts/manager_worker_api.py` for those operations. +- Route all non-tracking room, participant, and member operations through the `basics` skill; do not use `scripts/manager_worker_api.py` for those operations. - If `start-tracking` or `stop-tracking` response shape differs from expectations, patch the script instead of improvising around it. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md index 88eec3e7..2d84ab2d 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md @@ -9,7 +9,7 @@ The skill and CLI use `room` as the user-facing term. Where the underlying HTTP - `CSGCLAW_BASE_URL`: Preferred when the script runs inside a CSGClaw box. - `CSGCLAW_ACCESS_TOKEN`: Preferred bearer token when the script runs inside a CSGClaw box. - `MANAGER_API_BASE_URL`: Optional. Default: `http://127.0.0.1:18080` -- `MANAGER_API_TOKEN`: Optional bearer token. Required for `/api/bots/*` when the server enables auth. +- `MANAGER_API_TOKEN`: Optional bearer token. Required for participant APIs when the server enables auth. - `MANAGER_API_TIMEOUT`: Optional request timeout in seconds. Default: `30` ## Local Config @@ -21,16 +21,16 @@ When available, load the CSGClaw API settings from `~/.picoclaw/config.json`: ## Expected Endpoints -### Dispatch task by bot message +### Dispatch task by participant message - Method: `POST` -- Path: `/api/bots/{bot_id}/messages/send` +- Path: `/api/v1/channels/csgclaw/participants/{participant_id}/messages` - Request body: ```json { "room_id": "room-123", - "text": "bob 你来写前端代码,实现设置页 UI" + "text": "bob 你来写前端代码,实现设置页 UI" } ``` @@ -49,7 +49,7 @@ When available, load the CSGClaw API settings from `~/.picoclaw/config.json`: ## Notes - There is no dedicated task-assignment API. -- Dispatch still means sending a normal bot message in the target room and mentioning the worker. +- Dispatch still means sending a normal participant message in the target room and mentioning the worker participant. - Each task in `todo.json` should carry an `id` task number, increasing in dispatch order such as `1`, `2`, `3`. - `start-tracking` watches `todo.json`, room history, and IM bootstrap data. - The first task dispatches immediately. Later tasks dispatch only after the previous task both: diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py index cb76060f..b04055c0 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py @@ -513,7 +513,11 @@ def _mock_response( "payload": payload, } - if method == "POST" and path.startswith("/api/bots/") and path.endswith("/messages/send"): + if ( + method == "POST" + and path.startswith("/api/v1/channels/csgclaw/participants/") + and path.endswith("/messages") + ): result["message_id"] = "dry-run-message-id" return result diff --git a/internal/templates/embed/picoclaw-worker/workspace/AGENT.md b/internal/templates/embed/picoclaw-worker/workspace/AGENT.md index 5563e9b9..493959a9 100644 --- a/internal/templates/embed/picoclaw-worker/workspace/AGENT.md +++ b/internal/templates/embed/picoclaw-worker/workspace/AGENT.md @@ -52,13 +52,13 @@ be practical, accurate, and efficient. ## Duplicate dispatches from manager -- If the room shows two (or more) near-identical task lines from `u-manager` mentioning you with the same goal, treat them as **one** task: reply once with `ACK_SKILL`, then execute once. Do not spend multiple turns only confirming "already dispatched" without doing the work. +- If the room shows two (or more) near-identical task lines from `manager` mentioning you with the same goal, treat them as **one** task: reply once with `ACK_SKILL`, then execute once. Do not spend multiple turns only confirming "already dispatched" without doing the work. ## CSGClaw identity -- Your CSGClaw bot ID uses the worker ID from the channel/runtime config, commonly `u-` such as `u-frontend-dev`. -- Rendered mentions may display only the handle, such as `@frontend-dev`; that is the same identity as `u-frontend-dev` when the structured mention or claim command uses that bot ID. -- For team task dispatches, use the exact `--bot-id` shown in the dispatch command for claim and status updates. +- Your CSGClaw participant ID comes from the channel/runtime config, commonly a stable worker slug such as `frontend-dev`. +- Rendered mentions may display only the handle, such as `@frontend-dev`; use the exact participant ID shown in structured mentions or team claim commands. +- For team task dispatches, the `--bot-id` flag name is legacy; pass the exact participant ID shown in the dispatch command for claim and status updates. ## Goals diff --git a/internal/templates/embed/picoclaw-worker/workspace/skills/agent-teams/SKILL.md b/internal/templates/embed/picoclaw-worker/workspace/skills/agent-teams/SKILL.md index a7d43a33..4440a87b 100644 --- a/internal/templates/embed/picoclaw-worker/workspace/skills/agent-teams/SKILL.md +++ b/internal/templates/embed/picoclaw-worker/workspace/skills/agent-teams/SKILL.md @@ -11,10 +11,10 @@ Only begin work after an explicit dispatch message in the task execution room: ```text [team] Task is ready for you -Claim it with: csgclaw-cli team task claim --team --task --bot-id +Claim it with: csgclaw-cli team task claim --team --task --bot-id ``` -CSGClaw worker IDs normally use the `u-` form, for example `u-frontend-dev`. Rendered mentions may show only the handle, for example `@frontend-dev`. Treat a structured mention for your bot and the `--bot-id` value in the dispatch command as the same identity; use the exact `--bot-id` shown when claiming or updating the task. +The `--bot-id` flag name is legacy; pass the worker participant ID shown in the dispatch message, for example `frontend-dev`. Rendered mentions may show only the handle, for example `@frontend-dev`; use the exact `--bot-id` value shown when claiming or updating the task. Ignore team setup and planning messages, including `[team] Task created`, `[team] Task planning complete`, and `[team] ... started assigning tasks`. Those messages are not permission to start work. @@ -33,13 +33,13 @@ Never infer `team_id` from the room id. A room id such as `room-178...` is not a Claim the dispatched task: ```bash -csgclaw-cli team task claim --team --task --bot-id +csgclaw-cli team task claim --team --task --bot-id ``` If the dispatch did not include a task id, claim the next available task: ```bash -csgclaw-cli team task claim-next --team --bot-id +csgclaw-cli team task claim-next --team --bot-id ``` After claiming, confirm the response status is `in_progress` for the same task before doing the work. @@ -47,7 +47,7 @@ After claiming, confirm the response status is `in_progress` for the same task b Report a completed task: ```bash -csgclaw-cli team task update --team --task --actor-id --status completed --result "" +csgclaw-cli team task update --team --task --actor-id --status completed --result "" ``` The task is not complete until this CLI status update succeeds. Sending a normal room message with the result is useful, but it does not update task state. @@ -55,13 +55,13 @@ The task is not complete until this CLI status update succeeds. Sending a normal Report a blocked task: ```bash -csgclaw-cli team task update --team --task --actor-id --status blocked --reason "" +csgclaw-cli team task update --team --task --actor-id --status blocked --reason "" ``` Report a failed task: ```bash -csgclaw-cli team task update --team --task --actor-id --status failed --error "" +csgclaw-cli team task update --team --task --actor-id --status failed --error "" ``` ## Working Rules diff --git a/web/app/src/api/agents.ts b/web/app/src/api/agents.ts index aaa0343d..e3f9e1da 100644 --- a/web/app/src/api/agents.ts +++ b/web/app/src/api/agents.ts @@ -36,6 +36,20 @@ export type AgentUpdatePayload = { from_template?: string; }; +export type ParticipantLike = { + agent_id?: string | null; + channel?: string | null; + channel_app_ref?: string | null; + channel_user_kind?: string | null; + channel_user_ref?: string | null; + id?: string | null; + lifecycle_status?: string | null; + mentionable?: boolean | null; + metadata?: JSONRecord | null; + name?: string | null; + type?: string | null; +}; + function modelPayload(draft: AgentProfileModelRequest): AgentProfileModelRequest { return { agent_id: draft.agent_id, @@ -56,12 +70,18 @@ export function saveManagerProfileRequest(profile: JSONRecord): Promise { void options; - const bots = await get("api/v1/channels/csgclaw/bots"); - return Array.isArray(bots) ? bots : []; + const [agents, notifications] = await Promise.all([ + get("api/v1/agents?include_participants=true"), + get("api/v1/channels/csgclaw/participants?type=notification"), + ]); + return [ + ...(Array.isArray(agents) ? agents : []), + ...(Array.isArray(notifications) ? notifications.map(participantToAgentLike) : []), + ]; } export function fetchAgent(agentID: string): Promise { - return get(`api/v1/agents/${encodeURIComponent(agentID)}`); + return get(`api/v1/agents/${encodeURIComponent(agentID)}?include_participants=true`); } export function fetchAgentLogsRequest(agentID: string, options: FetchAgentLogsOptions = {}): Promise { @@ -121,22 +141,63 @@ export type CreateBotPayload = AgentUpdatePayload & { type?: string; }; -export function createBotRequest(payload: CreateBotPayload): Promise { - return post("api/v1/channels/csgclaw/bots", payload); -} - -export function createNotificationBotRequest(payload: CreateBotPayload): Promise { - return post("api/v1/channels/csgclaw/bots", { ...payload, type: BOT_TYPE_NOTIFICATION }); +export async function createBotRequest(payload: CreateBotPayload): Promise { + const participant = await post("api/v1/channels/csgclaw/participants", { + name: payload.name, + type: "agent", + agent_binding: { + mode: "create", + agent: { + name: payload.name, + role: payload.role, + description: payload.description, + image: payload.image, + runtime_kind: payload.runtime_kind, + from_template: payload.from_template, + runtime_options: payload.runtime_options, + agent_profile: payload.agent_profile, + }, + }, + }); + return participant.agent_id ? fetchAgent(participant.agent_id) : participantToAgentLike(participant); +} + +export async function createNotificationBotRequest(payload: CreateBotPayload): Promise { + const participant = await post("api/v1/channels/csgclaw/participants", { + name: payload.name, + type: "notification", + metadata: payload.runtime_options ?? {}, + }); + return participantToAgentLike(participant); } export function patchNotificationBotRequest(botID: string, payload: CreateBotPayload): Promise { - return patch(`api/v1/channels/csgclaw/bots/${encodeURIComponent(botID)}`, payload); + return patch(`api/v1/channels/csgclaw/participants/${encodeURIComponent(botID)}`, { + name: payload.name, + metadata: payload.runtime_options ?? {}, + }).then(participantToAgentLike); } export function deleteBotRequest(botID: string): Promise { - return del(`api/v1/channels/csgclaw/bots/${encodeURIComponent(botID)}`); + return del(`api/v1/channels/csgclaw/participants/${encodeURIComponent(botID)}`); } export function runAgentActionRequest(agentID: string, action: string): Promise { return post(`api/v1/agents/${encodeURIComponent(agentID)}/${action}`); } + +function participantToAgentLike(participant: ParticipantLike): AgentLike { + const metadata = participant.metadata ?? {}; + return { + id: participant.id, + name: participant.name, + type: participant.type === "notification" ? BOT_TYPE_NOTIFICATION : participant.type, + bot_type: participant.type === "notification" ? BOT_TYPE_NOTIFICATION : participant.type, + available: participant.lifecycle_status === "active", + handle: participant.channel_user_ref, + runtime_options: metadata, + notification_profile: metadata, + notifier_profile: metadata, + status: participant.lifecycle_status, + }; +} diff --git a/web/app/src/api/im.ts b/web/app/src/api/im.ts index 947e6382..8bf7297c 100644 --- a/web/app/src/api/im.ts +++ b/web/app/src/api/im.ts @@ -77,5 +77,5 @@ export function joinAgentToRoomRequest(payload: JoinAgentToRoomPayload): Promise } export function createUserRequest(payload: CreateUserPayload): Promise { - return post("api/v1/users", payload); + return post("api/v1/channels/csgclaw/users", payload); } diff --git a/web/app/src/hooks/workspace/useAgentController.ts b/web/app/src/hooks/workspace/useAgentController.ts index 914a550f..c3c4159a 100644 --- a/web/app/src/hooks/workspace/useAgentController.ts +++ b/web/app/src/hooks/workspace/useAgentController.ts @@ -61,6 +61,7 @@ import { profileToDraft, providerNeedsAuth, resolvedNotifierWebhookOrigin, + resolveAgentChannelUserID, runtimeImageForKind, startAgentCreateProgress, } from "@/models/agents"; @@ -578,7 +579,7 @@ export function useAgentController({ }); } - async function openCreateNotificationBotModal(): Promise { + async function openCreateNotificationParticipantModal(): Promise { setAgentModalMode("create"); setAgentCreateBotKind(BOT_CREATE_KIND_NOTIFICATION); setEditingAgent(null); @@ -948,7 +949,7 @@ export function useAgentController({ try { let updatedAgent: AgentLike | null = null; if (action === "delete") { - await deleteBotRequest(item.id); + await deleteBotRequest(csgclawParticipantIDForAgent(item)); } else { updatedAgent = await runAgentActionRequest(item.id, action); } @@ -973,7 +974,7 @@ export function useAgentController({ setAgentActionBusy(`${item.id}:delete-bot`); setAgentsError(""); try { - await deleteBotRequest(item.id); + await deleteBotRequest(csgclawParticipantIDForAgent(item)); await refreshAgents(); await refreshWorkspaceBootstrap(); if (item.id === MANAGER_AGENT_ID) { @@ -1095,24 +1096,25 @@ export function useAgentController({ } async function openAgentDirectMessage(item: AgentLike | null | undefined): Promise { - if (!item?.id || !data?.current_user_id) { + const channelUserID = resolveAgentChannelUserID(item); + if (!channelUserID || !data?.current_user_id) { return; } setAgentsError(""); try { let nextData = null; - let direct = directConversationForUser(item.id); + let direct = directConversationForUser(channelUserID); if (!direct) { await createUserRequest({ - id: item.id, - name: item.name, - handle: item.handle || item.id.replace(/^u-/, "") || item.name, - role: item.role || WORKER_AGENT_ROLE, + id: channelUserID, + name: item?.name || channelUserID, + handle: item?.handle || channelUserID.replace(/^u-/, "") || item?.name, + role: item?.role || WORKER_AGENT_ROLE, }); nextData = await refreshWorkspaceBootstrap(); direct = directConversationForUser( - item.id, + channelUserID, nextData?.rooms ?? rooms, nextData?.current_user_id ?? data.current_user_id, ); @@ -1145,7 +1147,7 @@ export function useAgentController({ notificationAgentItems, openCreateAgentModal, openCreateTeamModal, - openCreateNotificationBotModal, + openCreateNotificationParticipantModal, openEditAgentModal, runningAgentCount, runAgentAction, @@ -1287,3 +1289,10 @@ export function useAgentController({ : null, }; } + +function csgclawParticipantIDForAgent(item: AgentLike): string { + const participant = item.participants?.find( + (candidate) => String(candidate?.channel || "").trim() === "csgclaw" && String(candidate?.id || "").trim(), + ); + return String(participant?.id || item.id || "").trim(); +} diff --git a/web/app/src/hooks/workspace/useWorkspaceController.ts b/web/app/src/hooks/workspace/useWorkspaceController.ts index 1231667e..7ba8097d 100644 --- a/web/app/src/hooks/workspace/useWorkspaceController.ts +++ b/web/app/src/hooks/workspace/useWorkspaceController.ts @@ -288,7 +288,7 @@ export function useWorkspaceController() { onToggleWorkspaceGroup: shell.toggleWorkspaceGroup, onCreateRoom: () => conversation.openCreateRoomModal(), onCreateAgent: agent.openCreateAgentModal, - onCreateNotificationBot: agent.openCreateNotificationBotModal, + onCreateNotificationParticipant: agent.openCreateNotificationParticipantModal, onCreateTeam: async ({ title, lead_bot_id, member_bot_ids }) => { await agent.agentViewProps.onCreateTeam?.({ channel: "csgclaw", diff --git a/web/app/src/models/agents.ts b/web/app/src/models/agents.ts index b6ce3a9b..7ae11a62 100644 --- a/web/app/src/models/agents.ts +++ b/web/app/src/models/agents.ts @@ -11,6 +11,7 @@ import { DEFAULT_RUNTIME_KIND, MANAGER_AGENT_ID, MANAGER_AGENT_NAME, + MANAGER_PARTICIPANT_ID, MANAGER_AGENT_ROLE, RUNTIME_KIND_OPTIONS, WORKER_AGENT_ROLE, @@ -77,6 +78,19 @@ export type AgentLike = AgentProfileLike & { status?: string | null; template_name?: string | null; user_id?: string | null; + participants?: { + agent_id?: string | null; + channel?: string | null; + channel_app_ref?: string | null; + channel_user_kind?: string | null; + channel_user_ref?: string | null; + id?: string | null; + lifecycle_status?: string | null; + mentionable?: boolean | null; + metadata?: JSONRecord | null; + name?: string | null; + type?: string | null; + }[] | null; }; export type AvatarLikeUser = { @@ -188,6 +202,23 @@ export function isManagerAgent(item: AgentLike | null | undefined): boolean { return item?.role === MANAGER_AGENT_ROLE || item?.id === MANAGER_AGENT_ID; } +export function resolveAgentChannelUserID(item: AgentLike | null | undefined): string { + if (!item) { + return ""; + } + const participant = item.participants?.find( + (candidate) => String(candidate?.channel || "").trim() === "csgclaw" && String(candidate?.id || "").trim(), + ); + const channelUserID = String(participant?.channel_user_ref || participant?.id || "").trim(); + if (channelUserID) { + return channelUserID; + } + if (isManagerAgent(item)) { + return MANAGER_PARTICIPANT_ID; + } + return String(item.user_id || item.id || "").trim(); +} + export function normalizeNotifierDeliveryMode(mode: unknown): string { const value = String(mode || "") .trim() @@ -361,9 +392,9 @@ export function notificationBotMetaLabel(item: AgentLike | null | undefined, t: export function notificationPushWebhookPathForBot(botID: unknown): string { const id = String(botID || "").trim(); if (!id) { - return "/api/v1/channels/csgclaw/bots//notifications"; + return "/api/v1/channels/csgclaw/participants//notifications"; } - return `/api/v1/channels/csgclaw/bots/${encodeURIComponent(id)}/notifications`; + return `/api/v1/channels/csgclaw/participants/${encodeURIComponent(id)}/notifications`; } export function notifierPushWebhookPathForAgent(botID: unknown): string { @@ -714,7 +745,6 @@ export function agentToDraft(agent: AgentDraftSource | null | undefined): AgentD role: agent?.role || WORKER_AGENT_ROLE, bot_type: botType, description: agent?.description || profile.description || "", - avatar: agent?.avatar || "", default_image: agent?.image || "", image: agent?.image || "", from_template: agent?.from_template || "", diff --git a/web/app/src/models/composer.ts b/web/app/src/models/composer.ts index b22c4d62..0ab6ff12 100644 --- a/web/app/src/models/composer.ts +++ b/web/app/src/models/composer.ts @@ -18,6 +18,7 @@ export type ComposerSlashState = { query: string; startOffset: number; textNode: Node; + tokenElement?: HTMLElement; }; export function createMentionTokenElement(user) { @@ -401,12 +402,16 @@ export function getComposerSlashState(root) { } let context = getActiveTextQueryContext(range.startContainer, range.startOffset); if (!context && range.startContainer.nodeType === Node.ELEMENT_NODE) { - const textNode = getAdjacentSlashTokenTextNode(range.startContainer, range.startOffset); - if (textNode) { - context = { - textNode, - offset: textNode.textContent?.length ?? 0, - textBeforeCursor: textNode.textContent ?? "", + const tokenElement = getAdjacentSlashTokenElement(range.startContainer, range.startOffset); + if (tokenElement) { + const text = tokenElement.textContent ?? ""; + const query = text.startsWith("/") ? text.slice(1) : text; + return { + query, + startOffset: 0, + endOffset: text.length, + textNode: tokenElement.firstChild ?? tokenElement, + tokenElement, }; } } @@ -517,8 +522,16 @@ export function replaceComposerSlashWithSegments(root, segments) { } const range = document.createRange(); - range.setStart(slashState.textNode, slashState.startOffset); - range.setEnd(slashState.textNode, slashState.endOffset); + if (slashState.tokenElement) { + range.setStartBefore(slashState.tokenElement); + range.setEndAfter(slashState.tokenElement); + } else { + const endOffset = replacementEndsWithWhitespace(segments) + ? consumeSingleFollowingWhitespace(slashState.textNode, slashState.endOffset) + : slashState.endOffset; + range.setStart(slashState.textNode, slashState.startOffset); + range.setEnd(slashState.textNode, endOffset); + } range.deleteContents(); const marker = document.createTextNode(""); @@ -613,7 +626,7 @@ function isComposerTokenNode(node: Node | null): boolean { return Boolean(element.dataset?.userId || element.dataset?.composerSlashToken); } -function getAdjacentSlashTokenTextNode(node: Node, offset: number): Text | null { +function getAdjacentSlashTokenElement(node: Node, offset: number): HTMLElement | null { if (node.nodeType !== Node.ELEMENT_NODE) { return null; } @@ -622,11 +635,30 @@ function getAdjacentSlashTokenTextNode(node: Node, offset: number): Text | null return null; } const element = previous as HTMLElement; - if (!element.dataset?.composerSlashToken) { - return null; + return element.dataset?.composerSlashToken ? element : null; +} + +function replacementEndsWithWhitespace(segments): boolean { + for (let index = (segments?.length ?? 0) - 1; index >= 0; index -= 1) { + const segment = segments[index]; + if (!segment || segment.type === "mention") { + continue; + } + const text = String(segment.text ?? ""); + if (!text) { + continue; + } + return /\s$/.test(text); + } + return false; +} + +function consumeSingleFollowingWhitespace(textNode: Node, offset: number): number { + if (textNode.nodeType !== Node.TEXT_NODE) { + return offset; } - const textNode = element.firstChild; - return textNode?.nodeType === Node.TEXT_NODE ? (textNode as Text) : null; + const text = textNode.textContent ?? ""; + return /\s/.test(text.charAt(offset)) ? offset + 1 : offset; } export function placeCaretNearNode(root, node, direction) { diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx index 22fa5189..ca7ada09 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx @@ -70,7 +70,6 @@ export function AgentDetailPane({ onStart, onStop, onRecreate, - onUpgrade, onDelete, onInvite, onOpenDM, @@ -140,7 +139,6 @@ export function AgentDetailPane({ publishBusy={publishBusy} onStart={onStart} onStop={onStop} - onUpgrade={onUpgrade} onRecreate={onRecreate} onInvite={onInvite} onDelete={onDelete} @@ -413,7 +411,6 @@ function AgentActionsMenu({ publishBusy, onStart, onStop, - onUpgrade, onRecreate, onInvite, onDelete, @@ -434,10 +431,6 @@ function AgentActionsMenu({ {running ? t("agentStop") : t("agentStart")} ) : null} - onUpgrade?.(item)}> - {t("agentUpgrade")} - {upgradeNeeded ? {t("agentUpdateAvailable")} : null} - onRecreate(item)}> {t("agentRecreate")} diff --git a/web/app/src/pages/AgentPage/components/AgentList/AgentList.tsx b/web/app/src/pages/AgentPage/components/AgentList/AgentList.tsx index 00dbe75d..447918ee 100644 --- a/web/app/src/pages/AgentPage/components/AgentList/AgentList.tsx +++ b/web/app/src/pages/AgentPage/components/AgentList/AgentList.tsx @@ -27,7 +27,6 @@ export function AgentSection({ onStart, onStop, onRecreate, - onUpgrade, onDelete, onInvite, }) { @@ -59,7 +58,6 @@ export function AgentSection({ onStart={onStart} onStop={onStop} onRecreate={onRecreate} - onUpgrade={onUpgrade} onDelete={onDelete} onInvite={onInvite} /> @@ -82,7 +80,6 @@ export function AgentRow({ onStart, onStop, onRecreate, - onUpgrade, onDelete, onInvite, }) { @@ -140,14 +137,6 @@ export function AgentRow({ ) : null} {!isNotification ? ( <> -