mirror of
https://github.com/m5stack/StackChan.git
synced 2026-04-28 03:22:39 +00:00
533 lines
14 KiB
Go
533 lines
14 KiB
Go
/*
|
|
SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
|
|
SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
package xiaozhi
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"stackChan/internal/model"
|
|
"stackChan/internal/model/xiaozhi"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/encoding/gjson"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/net/gclient"
|
|
"github.com/gogf/gf/v2/os/gctx"
|
|
)
|
|
|
|
var (
|
|
ctx = gctx.New()
|
|
token string // Memory stored Token
|
|
tokenExpire time.Time // Token expiration time
|
|
mu sync.Mutex // Mutex lock, ensure Token update thread-safe
|
|
ticker *time.Ticker // 24-hour timer
|
|
macSeparatorRegex = regexp.MustCompile(`[^0-9a-fA-F]`)
|
|
validMacRegex = regexp.MustCompile(`^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$`)
|
|
globalClient *gclient.Client // Global HTTP client
|
|
)
|
|
|
|
const (
|
|
baseUrl = "https://xiaozhi.me/"
|
|
tokenPath = "api/developers/token"
|
|
agentTemplatesList = "api/developers/agent-templates/list"
|
|
devices = "api/developers/devices"
|
|
deviceUnbind = "api/developers/unbind-device"
|
|
agentsDelete = "api/agents/delete"
|
|
createAgent = "api/agents"
|
|
chats = "api/chats/list"
|
|
tokenExpiry = 24 * time.Hour // Token valid for 24 hours
|
|
agents = "api/agents"
|
|
)
|
|
|
|
func init() {
|
|
// Initialize global HTTP client
|
|
globalClient = g.Client()
|
|
globalClient.SetTimeout(10 * time.Second)
|
|
globalClient.SetHeader("Content-Type", "application/json")
|
|
|
|
// Start 24-hour timer to periodically check and refresh Token
|
|
ticker = time.NewTicker(tokenExpiry)
|
|
g.Log().Info(ctx, "xiaozhi token auto refresh ticker started, refresh cycle: 24 hours")
|
|
|
|
go func() {
|
|
for range ticker.C {
|
|
mu.Lock()
|
|
// Force clear Token, next GetToken call will auto-refresh
|
|
token = ""
|
|
tokenExpire = time.Time{}
|
|
mu.Unlock()
|
|
g.Log().Info(ctx, "token expired, auto refresh")
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Unified request processing method, auto handle Session expiration
|
|
func doRequest(method string, path string, data interface{}, resp interface{}) error {
|
|
// Get token
|
|
tokenString, err := GetToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clone client and set Authorization header
|
|
client := globalClient.Clone()
|
|
client.SetHeader("Authorization", "Bearer "+tokenString)
|
|
|
|
var response *g.Var
|
|
|
|
// Send request based on HTTP method
|
|
switch strings.ToUpper(method) {
|
|
case "GET":
|
|
fullUrl := baseUrl + path
|
|
if params, ok := data.(g.Map); ok && len(params) > 0 {
|
|
var queryParts []string
|
|
for k, v := range params {
|
|
queryParts = append(queryParts, fmt.Sprintf("%s=%v", k, v))
|
|
}
|
|
queryStr := strings.Join(queryParts, "&")
|
|
if strings.Contains(fullUrl, "?") {
|
|
fullUrl += "&" + queryStr
|
|
} else {
|
|
fullUrl += "?" + queryStr
|
|
}
|
|
}
|
|
response = client.GetVar(ctx, fullUrl)
|
|
case "POST":
|
|
response = client.PostVar(ctx, baseUrl+path, data)
|
|
case "PUT":
|
|
response = client.PutVar(ctx, baseUrl+path, data)
|
|
case "DELETE":
|
|
response = client.DeleteVar(ctx, baseUrl+path, data)
|
|
default:
|
|
return fmt.Errorf("unsupported request method: %s", method)
|
|
}
|
|
|
|
err = response.Scan(resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if response is successful, handle Session expiration
|
|
json := gjson.New(response.Val())
|
|
success := json.Get("success").Bool()
|
|
message := json.Get("message").String()
|
|
if !success {
|
|
if message == "Session expired or logged out" {
|
|
g.Log().Info(ctx, "session expired or logged out, auto refresh token")
|
|
mu.Lock()
|
|
token = ""
|
|
tokenExpire = time.Time{}
|
|
mu.Unlock()
|
|
return doRequest(method, path, data, resp)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetToken Get Token (thread-safe, 24-hour auto-expiration)
|
|
func GetToken() (string, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// 1. Check if Token exists and not expired
|
|
if token != "" && time.Now().Before(tokenExpire) {
|
|
g.Log().Debug(ctx, "token expired at: %s", tokenExpire.Format("2006-01-02 15:04:05"))
|
|
return token, nil
|
|
}
|
|
|
|
g.Log().Info(ctx, "token expired or not exist, auto refresh")
|
|
// 2. Token does not exist/expired, refresh to get
|
|
newToken, err := refreshToken()
|
|
if err != nil {
|
|
g.Log().Error(ctx, "refresh token failed: %v", err)
|
|
return "", err
|
|
}
|
|
|
|
// 3. Update Token and expiration time
|
|
token = newToken
|
|
tokenExpire = time.Now().Add(tokenExpiry) // Record expiration time (current time + 24 hours)
|
|
g.Log().Info(ctx, "refresh token success, expire at: %s", tokenExpire.Format("2006-01-02 15:04:05"))
|
|
|
|
return token, nil
|
|
}
|
|
|
|
func GetNewToken() (string, error) {
|
|
mu.Lock()
|
|
token = ""
|
|
tokenExpire = time.Time{}
|
|
mu.Unlock()
|
|
return GetToken()
|
|
}
|
|
|
|
// DeleteAgent Delete agent
|
|
func DeleteAgent(agentId int) (bool, error) {
|
|
params := g.Map{
|
|
"id": agentId,
|
|
}
|
|
|
|
var resp xiaozhi.XiaoZhiResponse[model.Empty]
|
|
err := doRequest("POST", agentsDelete, params, &resp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "delete agent failed: %v", err)
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func CreateAgent(params g.Map) (*int, error) {
|
|
var resp xiaozhi.XiaoZhiResponse[xiaozhi.CreateAgentResponse]
|
|
err := doRequest("POST", createAgent, params, &resp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "create agent failed: %v", err)
|
|
return nil, err
|
|
}
|
|
return &resp.Data.Id, nil
|
|
}
|
|
|
|
// GetAgentTemplate Get agent template
|
|
func GetAgentTemplate(page int, pageSize int) (*xiaozhi.ListData[xiaozhi.AgentTemplate], error) {
|
|
g.Log().Debug(ctx, "Get agent template, page: ", page, " pageSize: ", pageSize)
|
|
queryMap := g.Map{
|
|
"page": page,
|
|
"pageSize": pageSize,
|
|
}
|
|
|
|
var resp xiaozhi.XiaoZhiResponse[xiaozhi.ListData[xiaozhi.AgentTemplate]]
|
|
err := doRequest("GET", agentTemplatesList, queryMap, &resp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "Get agent template failed: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
g.Log().Info(ctx,
|
|
"Get agent template success, list length: ", len(resp.Data.List),
|
|
" total count: ", resp.Pagination.Total,
|
|
)
|
|
|
|
return resp.Data, nil
|
|
}
|
|
|
|
// SetAgentSetting Update agent settings
|
|
func SetAgentSetting(agentId int, parameters xiaozhi.AgentConfig) (bool, error) {
|
|
path := "api/agents/" + strconv.Itoa(agentId) + "/config"
|
|
url := baseUrl + path
|
|
g.Log().Info(ctx, "Update agent setting, agentId:", agentId, "url: ", url)
|
|
g.Log().Info(ctx, "Request body parameters: ", gjson.MustEncodeString(parameters))
|
|
|
|
var resp xiaozhi.XiaoZhiResponse[model.Empty]
|
|
err := doRequest("POST", path, parameters, &resp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "Update agent setting failed: %v", err)
|
|
return false, err
|
|
}
|
|
|
|
g.Log().Info(ctx, "Update agent setting success, agentId:", agentId)
|
|
return true, nil
|
|
}
|
|
|
|
// GetDevices Get device list
|
|
func GetDevices(
|
|
page *int,
|
|
pageSize *int,
|
|
macAddress *string,
|
|
serialNumber *string,
|
|
productID *int,
|
|
DeviceID *int,
|
|
) (*[]xiaozhi.Device, error) {
|
|
|
|
newMacAddress := formatMac(macAddress)
|
|
|
|
// Added: Request parameter logging
|
|
|
|
queryMap := g.Map{}
|
|
if page != nil {
|
|
queryMap["page"] = *page
|
|
}
|
|
if pageSize != nil {
|
|
queryMap["pageSize"] = *pageSize
|
|
}
|
|
if macAddress != nil {
|
|
queryMap["mac_address"] = *newMacAddress
|
|
}
|
|
if serialNumber != nil {
|
|
queryMap["serial_number"] = *serialNumber
|
|
}
|
|
if productID != nil {
|
|
queryMap["product_id"] = *productID
|
|
}
|
|
if DeviceID != nil {
|
|
queryMap["device_id"] = *DeviceID
|
|
}
|
|
|
|
g.Log().Debug(ctx,
|
|
"Get device list, data:", queryMap,
|
|
)
|
|
|
|
var resp xiaozhi.XiaoZhiResponse[xiaozhi.ListData[xiaozhi.Device]]
|
|
err := doRequest("GET", devices, queryMap, &resp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "Get device list failed: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
g.Log().Info(ctx, "Get device list success, list length:", len(resp.Data.List), " total count:", resp.Pagination.Total)
|
|
|
|
g.Log().Info(ctx, "Get device list success, first device:", resp.Data.List[0])
|
|
|
|
return &resp.Data.List, nil
|
|
}
|
|
|
|
func GetAgents(page *int, pageSize *int, keyword *string) (*[]xiaozhi.Agent, error) {
|
|
// Added: Request parameter logging
|
|
g.Log().Debug(ctx,
|
|
"Get agent list, page:", page,
|
|
" pageSize:", pageSize,
|
|
" keyword:", keyword,
|
|
)
|
|
queryMap := g.Map{}
|
|
if keyword != nil {
|
|
queryMap["keyword"] = *keyword
|
|
}
|
|
if page != nil {
|
|
queryMap["page"] = *page
|
|
}
|
|
if pageSize != nil {
|
|
queryMap["pageSize"] = *pageSize
|
|
}
|
|
var resp xiaozhi.XiaoZhiResponse[[]xiaozhi.Agent]
|
|
err := doRequest("GET", agents, queryMap, &resp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "Get agent list failed: %v", err)
|
|
return nil, err
|
|
}
|
|
g.Log().Info(ctx,
|
|
"Get agent list success, list length:", len(*resp.Data),
|
|
" total count:", resp.Pagination.Total,
|
|
)
|
|
|
|
return resp.Data, nil
|
|
}
|
|
|
|
func formatMac(mac *string) *string {
|
|
if mac == nil {
|
|
return nil
|
|
}
|
|
cleanMac := macSeparatorRegex.ReplaceAllString(*mac, "")
|
|
if !isValidMac(cleanMac) {
|
|
return nil
|
|
}
|
|
cleanMac = strings.ToLower(cleanMac)
|
|
var parts []string
|
|
for i := 0; i < 12; i += 2 {
|
|
parts = append(parts, cleanMac[i:i+2])
|
|
}
|
|
return new(strings.Join(parts, ":"))
|
|
}
|
|
|
|
func isValidMac(cleanMac string) bool {
|
|
return len(cleanMac) == 12
|
|
}
|
|
|
|
type ZhiGetToken struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// refreshToken Refresh Token from server (core logic)
|
|
func refreshToken() (string, error) {
|
|
secretKey := g.Cfg().MustGet(ctx, "xiaozhi.secret_key").String()
|
|
if secretKey == "" {
|
|
g.Log().Error(ctx, "xiaozhi.secret_key is empty, please check config file")
|
|
return "", errors.New("xiaozhi.secret_key is empty")
|
|
}
|
|
|
|
g.Log().Debug(ctx, "refresh token")
|
|
requestData := g.Map{
|
|
"secret_key": secretKey,
|
|
}
|
|
|
|
client := g.Client()
|
|
client.SetTimeout(10 * time.Second)
|
|
client.SetHeader("Content-Type", "application/json")
|
|
|
|
var resp xiaozhi.XiaoZhiResponse[ZhiGetToken]
|
|
err := client.PostVar(ctx, baseUrl+tokenPath, requestData).Scan(&resp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "refresh token failed: %v", err)
|
|
return "", fmt.Errorf("refresh token failed: %w", err)
|
|
}
|
|
|
|
if !resp.Success {
|
|
g.Log().Error(ctx, "refresh token failed: %s", resp.Message)
|
|
return "", fmt.Errorf("refresh token failed: %s", resp.Message)
|
|
}
|
|
|
|
// Generic structure direct value, no need for map assertion!!!
|
|
if resp.Data.Token == "" {
|
|
g.Log().Error(ctx, "refresh token failed: token is empty")
|
|
return "", fmt.Errorf("token is empty")
|
|
}
|
|
|
|
g.Log().Debug(ctx, "refresh token success")
|
|
return resp.Data.Token, nil
|
|
}
|
|
|
|
// UnbindDevice Unbind device from XiaoZhi side
|
|
// @param macAddress Device MAC address
|
|
func UnbindDevice(macAddress *string) (bool, error) {
|
|
g.Log().Debug(ctx, "unbind device")
|
|
|
|
// First query device ID
|
|
devices, err := GetDevices(new(1), new(10), macAddress, nil, nil, nil)
|
|
if err != nil {
|
|
g.Log().Error(ctx, err.Error())
|
|
return false, err
|
|
}
|
|
|
|
if len(*devices) == 0 {
|
|
g.Log().Error(ctx, "unbind device failed: device not found, mac=%s", *macAddress)
|
|
/// Device not found, return true
|
|
return true, nil
|
|
}
|
|
deviceID := (*devices)[0].DeviceID
|
|
|
|
requestData := g.Map{
|
|
"device_id": deviceID,
|
|
}
|
|
|
|
g.Log().Info(ctx, "unbind device, device_id: ", (*devices)[0])
|
|
g.Log().Info(ctx, "request data: ", gjson.MustEncodeString(requestData))
|
|
|
|
var resp xiaozhi.XiaoZhiResponse[model.Empty]
|
|
err = doRequest("POST", deviceUnbind, requestData, &resp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "unbind device failed: %v", err)
|
|
return false, err
|
|
}
|
|
if !resp.Success {
|
|
if resp.Message == "device not found" {
|
|
g.Log().Info(ctx, "unbind device failed: device not found")
|
|
return true, nil
|
|
}
|
|
g.Log().Error(ctx, "unbind device failed: %s", resp.Message)
|
|
return false, nil
|
|
}
|
|
g.Log().Info(ctx, "unbind device success, device_id: ", deviceID)
|
|
g.Log().Info(ctx, resp.Message)
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// UpdateAllDevices / Temporary script code, update mcp tools for all devices
|
|
func UpdateAllDevices() (bool, error) {
|
|
// Initial pagination values
|
|
page := 1
|
|
pageSize := 100
|
|
|
|
// Loop through pages until no more data
|
|
for {
|
|
// Get current page device list
|
|
agents, err := GetAgents(&page, &pageSize, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// If no data on current page, all pages processed, exit loop
|
|
if agents == nil || len(*agents) == 0 {
|
|
g.Log().Info(ctx, "update all devices success")
|
|
break
|
|
}
|
|
|
|
g.Log().Info(ctx, "update all devices, page: ", page, ", total: ", len(*agents))
|
|
|
|
for _, agent := range *agents {
|
|
agentId := agent.ID
|
|
|
|
parameters := xiaozhi.AgentConfig{
|
|
AgentName: agent.AgentName,
|
|
AssistantName: agent.AssistantName,
|
|
LlmModel: agent.LlmModel,
|
|
TtsVoice: agent.TtsVoice,
|
|
TtsSpeechSpeed: agent.TtsSpeechSpeed,
|
|
TtsPitch: agent.TtsPitch,
|
|
AsrSpeed: agent.AsrSpeed,
|
|
Language: agent.Language,
|
|
Character: agent.Character,
|
|
Memory: agent.Memory,
|
|
MemoryType: agent.MemoryType,
|
|
KnowledgeBaseIds: []int{},
|
|
McpEndpoints: nil,
|
|
ProductMcpEndpoints: nil,
|
|
}
|
|
|
|
path := "api/agents/" + strconv.FormatInt(agentId, 10) + "/config"
|
|
url := baseUrl + path
|
|
g.Log().Info(ctx, "update agent config, agentId: ", agentId, "url: ", url)
|
|
g.Log().Info(ctx, "request data: ", gjson.MustEncodeString(parameters))
|
|
|
|
var resp xiaozhi.XiaoZhiResponse[model.Empty]
|
|
err := doRequest("POST", path, parameters, &resp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "update agent config failed: %v", err)
|
|
return false, err
|
|
}
|
|
|
|
if !resp.Success {
|
|
g.Log().Info(ctx, "update agent config failed: %s", resp.Message)
|
|
continue
|
|
}
|
|
|
|
g.Log().Info(ctx, "update agent config success, agentId: ", agentId)
|
|
g.Log().Info(ctx, "update agent config success, agentId: ", agentId)
|
|
}
|
|
|
|
// Page number +1, continue requesting next page
|
|
page++
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func DeleteChats() {
|
|
page := 1
|
|
pageSize := 100
|
|
requestData := g.Map{
|
|
"page": page,
|
|
"size": pageSize,
|
|
}
|
|
|
|
var resp xiaozhi.XiaoZhiResponse[xiaozhi.ListData[xiaozhi.Conversation]]
|
|
err := doRequest("GET", chats, requestData, &resp)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if !resp.Success {
|
|
return
|
|
}
|
|
|
|
list := resp.Data.List
|
|
|
|
for _, item := range list {
|
|
|
|
url := "api/agents/" + strconv.Itoa(item.AgentId) + "/chats/" + strconv.Itoa(item.Id)
|
|
|
|
var deleResp xiaozhi.XiaoZhiResponse[model.Empty]
|
|
err := doRequest("DELETE", url, nil, &deleResp)
|
|
if err != nil {
|
|
g.Log().Error(ctx, "delete chat failed, agentId:", item.AgentId, " chatId:", item.Id, " error:", err)
|
|
continue
|
|
}
|
|
|
|
g.Log().Info(ctx, "delete chat success, agentId:", item.AgentId, " chatId:", item.Id)
|
|
|
|
}
|
|
|
|
}
|