Files
story/internal/elderagent/client.go
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

229 lines
6.7 KiB
Go

// Package elderagent provisions one persistent Volcano bot per elder.
package elderagent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
volcbase "github.com/volcengine/volc-sdk-golang/base"
)
type Bot struct {
ID string `json:"Id"`
Name string `json:"Name"`
Config map[string]any `json:"Config"`
AgentConfig map[string]any `json:"AgentConfig"`
Bindings []map[string]any `json:"Bindings"`
}
type Provider interface {
Ensure(context.Context, string, map[string]any, map[string]any) (Bot, error)
}
func (c *Client) Update(ctx context.Context, id string, config, agent map[string]any) error {
var result map[string]any
return c.call(ctx, "AibotUpdate", map[string]any{"Id": id, "Config": config, "AgentConfig": agent}, &result)
}
// Verify managed fields with a readback. Credentials can be masked by the
// provider and are deliberately neither compared nor returned in diagnostics.
func (c *Client) VerifyConfiguration(ctx context.Context, id string, config, agent map[string]any) error {
var result struct {
Bots []Bot `json:"Bots"`
}
if err := c.call(ctx, "AibotQuery", map[string]any{"Id": id}, &result); err != nil {
return err
}
for _, bot := range result.Bots {
if bot.ID != id {
continue
}
for _, path := range [][]string{{"S2SConfig", "ProviderParams", "dialog", "system_role"}, {"MemoryConfig", "Enable"}} {
want, wok := configurationField(config, path)
got, gok := configurationField(bot.Config, path)
if !wok || !gok || !sameJSON(want, got) {
return fmt.Errorf("PERSONA_VERIFY_FAILED")
}
}
for _, key := range []string{"WelcomeMessage", "ServerMessageURLForRTS", "ServerMessageSignatureForRTS"} {
want, wok := agent[key]
got, gok := bot.AgentConfig[key]
if wok && (!gok || !sameJSON(want, got)) {
return fmt.Errorf("PERSONA_VERIFY_FAILED")
}
}
return nil
}
return fmt.Errorf("PERSONA_VERIFY_FAILED")
}
func configurationField(m map[string]any, path []string) (any, bool) {
for _, key := range path[:len(path)-1] {
m, _ = m[key].(map[string]any)
if m == nil {
return nil, false
}
}
v, ok := m[path[len(path)-1]]
return v, ok
}
func sameJSON(a, b any) bool {
ar, _ := json.Marshal(a)
br, _ := json.Marshal(b)
return bytes.Equal(ar, br)
}
type Client struct {
Endpoint string
Credentials volcbase.Credentials
HTTP *http.Client
Binding map[string]any
}
func (c *Client) ForProduct(instance, product string) *Client {
if instance != "" && product != "" {
c.Binding = map[string]any{"InstanceId": instance, "ProductKey": product}
}
return c
}
func (c *Client) bind(ctx context.Context, bot Bot) error {
if len(c.Binding) == 0 {
return nil
}
for _, b := range bot.Bindings {
if b["InstanceId"] == c.Binding["InstanceId"] && b["ProductKey"] == c.Binding["ProductKey"] {
return nil
}
}
var result map[string]any
return c.call(ctx, "AibotBind", map[string]any{"Id": bot.ID, "Binding": c.Binding}, &result)
}
func (c *Client) BindConfigured(ctx context.Context, id string) error {
if len(c.Binding) == 0 {
return nil
}
var result struct {
Bots []Bot `json:"Bots"`
}
if err := c.call(ctx, "AibotQuery", map[string]any{"Id": id, "WithBindings": true}, &result); err != nil {
return err
}
for _, bot := range result.Bots {
if bot.ID == id {
return c.bind(ctx, bot)
}
}
return fmt.Errorf("AibotQuery_NOT_FOUND")
}
func NewClient(ak, sk, token, endpoint, region string) *Client {
if endpoint == "" {
endpoint = "https://rtc.volcengineapi.com"
}
if region == "" {
region = "cn-north-1"
}
return &Client{Endpoint: endpoint, Credentials: volcbase.Credentials{AccessKeyID: ak, SecretAccessKey: sk, SessionToken: token, Service: "rtc", Region: region}, HTTP: &http.Client{Timeout: 15 * time.Second}}
}
func (c *Client) call(ctx context.Context, action string, body any, result any) error {
if c.Credentials.AccessKeyID == "" || c.Credentials.SecretAccessKey == "" {
return fmt.Errorf("VOLC_CREDENTIALS_REQUIRED")
}
u, err := url.Parse(c.Endpoint)
if err != nil {
return err
}
q := u.Query()
q.Set("Action", action)
q.Set("Version", "2025-08-01")
u.RawQuery = q.Encode()
raw, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.Credentials.Sign(req)
res, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("%s_TRANSPORT_ERROR", action)
}
defer res.Body.Close()
var env struct {
ResponseMetadata struct {
Error *struct {
Code string `json:"Code"`
} `json:"Error"`
} `json:"ResponseMetadata"`
Result json.RawMessage `json:"Result"`
}
if err = json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&env); err != nil {
return fmt.Errorf("%s_INVALID_RESPONSE", action)
}
if env.ResponseMetadata.Error != nil {
return fmt.Errorf("%s:%s", action, env.ResponseMetadata.Error.Code)
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("%s_HTTP_%d", action, res.StatusCode)
}
return json.Unmarshal(env.Result, result)
}
// A deterministic name recovers an already-created bot after a lost response.
// Callers serialize this method for the same elder, including across API workers.
func (c *Client) Ensure(ctx context.Context, name string, config, agent map[string]any) (Bot, error) {
var queried struct {
Bots []Bot `json:"Bots"`
NextIterator string `json:"NextIterator"`
}
iterator := ""
for {
if err := c.call(ctx, "AibotQuery", map[string]any{"Name": name, "AccessType": "public", "WithBindings": true, "Limit": 200, "Iterator": iterator}, &queried); err != nil {
return Bot{}, err
}
for _, b := range queried.Bots {
if b.Name == name {
var updated map[string]any
if err := c.call(ctx, "AibotUpdate", map[string]any{"Id": b.ID, "Config": config, "AgentConfig": agent}, &updated); err != nil {
return Bot{}, err
}
b.Config = config
b.AgentConfig = agent
if err := c.bind(ctx, b); err != nil {
return Bot{}, err
}
return b, nil
}
}
if queried.NextIterator == "" {
break
}
if queried.NextIterator == iterator {
return Bot{}, fmt.Errorf("AibotQuery_INVALID_PAGINATION")
}
iterator = queried.NextIterator
}
var created struct {
Id string `json:"Id"`
}
if err := c.call(ctx, "AibotCreate", map[string]any{"Name": name, "AccessType": "public", "Config": config, "AgentConfig": agent}, &created); err != nil {
return Bot{}, err
}
if created.Id == "" {
return Bot{}, fmt.Errorf("AibotCreate_MISSING_ID")
}
bot := Bot{ID: created.Id, Name: name, Config: config, AgentConfig: agent}
if err := c.bind(ctx, bot); err != nil {
return Bot{}, err
}
return bot, nil
}