yao/agent/robot/store/robot.go
Max df5836d8cc Enhance Robot API with Bio Field and CRUD Operations
- Added a `Bio` field to the `Robot` structure, allowing for a description of the robot.
- Updated the `cleanupAPITestRobots` function to delete robots with member IDs starting with both "robot_api_" and "api_robot_".
- Implemented new API functions for creating, updating, and removing robots, ensuring proper validation and cache management.
- Enhanced request and response types in `api/types.go` to include the new `Bio` field.
- Added comprehensive tests for the new CRUD operations in `robot_test.go`, ensuring robust validation and error handling.
2026-01-22 11:50:25 +08:00

634 lines
16 KiB
Go

package store
import (
"context"
"fmt"
"time"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/robot/utils"
)
// RobotRecord - persistent storage for robot member
// Maps to __yao.member model
type RobotRecord struct {
ID int64 `json:"id,omitempty"` // Auto-increment primary key
MemberID string `json:"member_id"` // Unique robot identifier
TeamID string `json:"team_id"` // Team ID
MemberType string `json:"member_type"` // Always "robot" for robots
Status string `json:"status"` // Member status: active | inactive | pending | suspended
RobotStatus string `json:"robot_status"` // Robot status: idle | working | paused | error | maintenance
AutonomousMode bool `json:"autonomous_mode"` // Whether autonomous mode is enabled
// Profile
DisplayName string `json:"display_name"` // Display name
Bio string `json:"bio,omitempty"` // Robot description
Avatar string `json:"avatar,omitempty"`
// Identity & Role
SystemPrompt string `json:"system_prompt"` // System prompt
RoleID string `json:"role_id"` // Role within team
ManagerID string `json:"manager_id"` // Direct manager user_id (who manages this robot)
// Communication
RobotEmail string `json:"robot_email"` // Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array)
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array)
// Capabilities
RobotConfig interface{} `json:"robot_config"` // Robot config JSON
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
LanguageModel string `json:"language_model,omitempty"` // Language model name
// Limits
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
// Ownership & Audit
InvitedBy string `json:"invited_by,omitempty"` // Who created/added this robot
JoinedAt *time.Time `json:"joined_at,omitempty"` // When robot was created
// Timestamps
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
// Yao Permission Fields (automatically handled by Yao model when permission:true)
// These fields are passed through to the model layer for permission control
YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id (set on create)
YaoUpdatedBy string `json:"__yao_updated_by,omitempty"` // Updater user_id (set on update)
YaoTeamID string `json:"__yao_team_id,omitempty"` // Permission team scope
YaoTenantID string `json:"__yao_tenant_id,omitempty"` // Permission tenant scope
}
// RobotListOptions - options for listing robot records
type RobotListOptions struct {
TeamID string `json:"team_id,omitempty"`
Status types.RobotStatus `json:"status,omitempty"`
Keywords string `json:"keywords,omitempty"` // Search in display_name
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
Page int `json:"page,omitempty"`
PageSize int `json:"pagesize,omitempty"`
OrderBy string `json:"order_by,omitempty"`
}
// RobotStore - persistent storage for robot members
type RobotStore struct {
modelID string
}
// NewRobotStore creates a new robot store instance
func NewRobotStore() *RobotStore {
return &RobotStore{
modelID: "__yao.member",
}
}
// robotFields are the fields to select when loading robots
var robotFields = []interface{}{
// Basic
"id",
"member_id",
"team_id",
"member_type",
"status",
"robot_status",
"autonomous_mode",
// Profile
"display_name",
"bio",
"avatar",
// Identity & Role
"system_prompt",
"role_id",
"manager_id",
// Communication
"robot_email",
"authorized_senders",
"email_filter_rules",
// Capabilities
"robot_config",
"agents",
"mcp_servers",
"language_model",
// Limits
"cost_limit",
// Ownership & Audit
"invited_by",
"joined_at",
// Timestamps
"created_at",
"updated_at",
}
// Save creates or updates a robot member record
func (s *RobotStore) Save(ctx context.Context, record *RobotRecord) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
// Ensure member_type is robot
record.MemberType = "robot"
data := s.recordToMap(record)
// Check if record exists by member_id
existing, err := s.Get(ctx, record.MemberID)
if err == nil && existing != nil {
// Update existing record
_, err = mod.UpdateWhere(
model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: record.MemberID},
},
},
data,
)
if err != nil {
return fmt.Errorf("failed to update robot record: %w", err)
}
return nil
}
// Create new record
_, err = mod.Create(data)
if err != nil {
return fmt.Errorf("failed to create robot record: %w", err)
}
return nil
}
// Get retrieves a robot record by member_id
func (s *RobotStore) Get(ctx context.Context, memberID string) (*RobotRecord, error) {
mod := model.Select(s.modelID)
if mod == nil {
return nil, fmt.Errorf("model %s not found", s.modelID)
}
rows, err := mod.Get(model.QueryParam{
Select: robotFields,
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
Limit: 1,
})
if err != nil {
return nil, fmt.Errorf("failed to get robot record: %w", err)
}
if len(rows) == 0 {
return nil, nil
}
return s.mapToRecord(rows[0])
}
// List retrieves robot records with filters
func (s *RobotStore) List(ctx context.Context, opts *RobotListOptions) ([]*RobotRecord, int, error) {
mod := model.Select(s.modelID)
if mod == nil {
return nil, 0, fmt.Errorf("model %s not found", s.modelID)
}
// Build where conditions - only require member_type=robot
wheres := []model.QueryWhere{
{Column: "member_type", Value: "robot"},
}
if opts != nil {
if opts.TeamID != "" {
wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID})
}
if opts.Status != "" {
wheres = append(wheres, model.QueryWhere{Column: "robot_status", Value: string(opts.Status)})
}
if opts.Keywords != "" {
wheres = append(wheres, model.QueryWhere{
Column: "display_name",
OP: "like",
Value: "%" + opts.Keywords + "%",
})
}
}
// Build order
orders := []model.QueryOrder{}
if opts != nil && opts.OrderBy != "" {
orders = append(orders, model.QueryOrder{Column: opts.OrderBy})
} else {
orders = append(orders, model.QueryOrder{Column: "created_at", Option: "desc"})
}
// Determine pagination
page := 1
pageSize := 100
if opts != nil {
if opts.Page > 0 {
page = opts.Page
}
if opts.PageSize > 0 {
pageSize = opts.PageSize
}
// Limit overrides PageSize for simple limit queries
if opts.Limit > 0 {
pageSize = opts.Limit
}
}
// Execute paginated query
result, err := mod.Paginate(model.QueryParam{
Select: robotFields,
Wheres: wheres,
Orders: orders,
}, page, pageSize)
if err != nil {
return nil, 0, fmt.Errorf("failed to list robots: %w", err)
}
// Get total count
total := 0
if t, ok := result.Get("total").(int); ok {
total = t
}
// Parse records
records := []*RobotRecord{}
data := result.Get("data")
switch rows := data.(type) {
case []maps.MapStr:
for _, row := range rows {
record, err := s.mapToRecord(map[string]interface{}(row))
if err != nil {
continue // skip invalid records
}
records = append(records, record)
}
case []map[string]interface{}:
for _, row := range rows {
record, err := s.mapToRecord(row)
if err != nil {
continue // skip invalid records
}
records = append(records, record)
}
}
return records, total, nil
}
// Delete removes a robot member by member_id
func (s *RobotStore) Delete(ctx context.Context, memberID string) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
_, err := mod.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
})
if err != nil {
return fmt.Errorf("failed to delete robot record: %w", err)
}
return nil
}
// UpdateConfig updates only the robot_config field
func (s *RobotStore) UpdateConfig(ctx context.Context, memberID string, config interface{}) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
data := map[string]interface{}{
"robot_config": config,
}
_, err := mod.UpdateWhere(
model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
},
data,
)
if err != nil {
return fmt.Errorf("failed to update robot config: %w", err)
}
return nil
}
// UpdateStatus updates the robot_status field
func (s *RobotStore) UpdateStatus(ctx context.Context, memberID string, status types.RobotStatus) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
data := map[string]interface{}{
"robot_status": string(status),
}
_, err := mod.UpdateWhere(
model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
},
data,
)
if err != nil {
return fmt.Errorf("failed to update robot status: %w", err)
}
return nil
}
// recordToMap converts RobotRecord to map for model operations
func (s *RobotStore) recordToMap(record *RobotRecord) map[string]interface{} {
data := map[string]interface{}{
// Required fields
"member_id": record.MemberID,
"team_id": record.TeamID,
"member_type": "robot",
"autonomous_mode": record.AutonomousMode,
}
// Status
if record.Status != "" {
data["status"] = record.Status
} else {
data["status"] = "active"
}
if record.RobotStatus != "" {
data["robot_status"] = record.RobotStatus
} else {
data["robot_status"] = "idle"
}
// Profile
if record.DisplayName != "" {
data["display_name"] = record.DisplayName
}
if record.Bio != "" {
data["bio"] = record.Bio
}
if record.Avatar != "" {
data["avatar"] = record.Avatar
}
// Identity & Role
if record.SystemPrompt != "" {
data["system_prompt"] = record.SystemPrompt
}
if record.RoleID != "" {
data["role_id"] = record.RoleID
}
if record.ManagerID != "" {
data["manager_id"] = record.ManagerID
}
// Communication
if record.RobotEmail != "" {
data["robot_email"] = record.RobotEmail
}
if record.AuthorizedSenders != nil {
data["authorized_senders"] = record.AuthorizedSenders
}
if record.EmailFilterRules != nil {
data["email_filter_rules"] = record.EmailFilterRules
}
// Capabilities
if record.RobotConfig != nil {
data["robot_config"] = record.RobotConfig
}
if record.Agents != nil {
data["agents"] = record.Agents
}
if record.MCPServers != nil {
data["mcp_servers"] = record.MCPServers
}
if record.LanguageModel != "" {
data["language_model"] = record.LanguageModel
}
// Limits
if record.CostLimit > 0 {
data["cost_limit"] = record.CostLimit
}
// Ownership & Audit
if record.InvitedBy != "" {
data["invited_by"] = record.InvitedBy
}
if record.JoinedAt != nil {
// Format time for Gou model (expects string format)
data["joined_at"] = record.JoinedAt.Format("2006-01-02 15:04:05")
}
// Yao Permission Fields - pass through for model layer
if record.YaoCreatedBy != "" {
data["__yao_created_by"] = record.YaoCreatedBy
}
if record.YaoUpdatedBy != "" {
data["__yao_updated_by"] = record.YaoUpdatedBy
}
if record.YaoTeamID != "" {
data["__yao_team_id"] = record.YaoTeamID
}
if record.YaoTenantID != "" {
data["__yao_tenant_id"] = record.YaoTenantID
}
return data
}
// mapToRecord converts a model row to RobotRecord
func (s *RobotStore) mapToRecord(row map[string]interface{}) (*RobotRecord, error) {
record := &RobotRecord{}
// Basic fields
if v, ok := row["id"]; ok {
switch id := v.(type) {
case float64:
record.ID = int64(id)
case int64:
record.ID = id
case int:
record.ID = int64(id)
}
}
if v, ok := row["member_id"].(string); ok {
record.MemberID = v
}
if v, ok := row["team_id"].(string); ok {
record.TeamID = v
}
if v, ok := row["member_type"].(string); ok {
record.MemberType = v
}
if v, ok := row["status"].(string); ok {
record.Status = v
}
if v, ok := row["robot_status"].(string); ok {
record.RobotStatus = v
}
if v, ok := row["autonomous_mode"]; ok {
record.AutonomousMode = utils.ToBool(v)
}
// Profile
if v, ok := row["display_name"].(string); ok {
record.DisplayName = v
}
if v, ok := row["bio"].(string); ok {
record.Bio = v
}
if v, ok := row["avatar"].(string); ok {
record.Avatar = v
}
// Identity & Role
if v, ok := row["system_prompt"].(string); ok {
record.SystemPrompt = v
}
if v, ok := row["role_id"].(string); ok {
record.RoleID = v
}
if v, ok := row["manager_id"].(string); ok {
record.ManagerID = v
}
// Communication
if v, ok := row["robot_email"].(string); ok {
record.RobotEmail = v
}
if v := row["authorized_senders"]; v != nil {
record.AuthorizedSenders = utils.ToJSONValue(v)
}
if v := row["email_filter_rules"]; v != nil {
record.EmailFilterRules = utils.ToJSONValue(v)
}
// Capabilities
if v := row["robot_config"]; v != nil {
record.RobotConfig = utils.ToJSONValue(v)
}
if v := row["agents"]; v != nil {
record.Agents = utils.ToJSONValue(v)
}
if v := row["mcp_servers"]; v != nil {
record.MCPServers = utils.ToJSONValue(v)
}
if v, ok := row["language_model"].(string); ok {
record.LanguageModel = v
}
// Limits
if v := row["cost_limit"]; v != nil {
record.CostLimit = utils.ToFloat64(v)
}
// Ownership & Audit
if v, ok := row["invited_by"].(string); ok {
record.InvitedBy = v
}
if v := row["joined_at"]; v != nil {
record.JoinedAt = utils.ToTimestamp(v)
}
// Timestamps
if v := row["created_at"]; v != nil {
record.CreatedAt = utils.ToTimestamp(v)
}
if v := row["updated_at"]; v != nil {
record.UpdatedAt = utils.ToTimestamp(v)
}
// Yao Permission Fields
if v, ok := row["__yao_created_by"].(string); ok {
record.YaoCreatedBy = v
}
if v, ok := row["__yao_updated_by"].(string); ok {
record.YaoUpdatedBy = v
}
if v, ok := row["__yao_team_id"].(string); ok {
record.YaoTeamID = v
}
if v, ok := row["__yao_tenant_id"].(string); ok {
record.YaoTenantID = v
}
return record, nil
}
// ToRobot converts a RobotRecord to types.Robot
func (r *RobotRecord) ToRobot() (*types.Robot, error) {
robot := &types.Robot{
MemberID: r.MemberID,
TeamID: r.TeamID,
DisplayName: r.DisplayName,
Bio: r.Bio,
SystemPrompt: r.SystemPrompt,
AutonomousMode: r.AutonomousMode,
RobotEmail: r.RobotEmail,
}
// Parse robot_status
if r.RobotStatus != "" {
robot.Status = types.RobotStatus(r.RobotStatus)
} else {
robot.Status = types.RobotIdle
}
// Parse robot_config
if r.RobotConfig != nil {
config, err := types.ParseConfig(r.RobotConfig)
if err != nil {
return nil, fmt.Errorf("failed to parse robot_config: %w", err)
}
robot.Config = config
}
return robot, nil
}
// FromRobot creates a RobotRecord from types.Robot
func FromRobot(robot *types.Robot) *RobotRecord {
record := &RobotRecord{
MemberID: robot.MemberID,
TeamID: robot.TeamID,
DisplayName: robot.DisplayName,
Bio: robot.Bio,
SystemPrompt: robot.SystemPrompt,
RobotStatus: string(robot.Status),
AutonomousMode: robot.AutonomousMode,
RobotEmail: robot.RobotEmail,
MemberType: "robot",
Status: "active",
}
if robot.Config != nil {
record.RobotConfig = robot.Config
}
return record
}