From cd7367e502fc71c47f05a7f8c03b1ace7743f2ee Mon Sep 17 00:00:00 2001 From: GargantuaX Date: Fri, 12 Jun 2026 12:38:41 +0800 Subject: [PATCH 1/3] feat: add aispeech dialog APIs --- aispeech/README.md | 102 +++++++- aispeech/aispeech.go | 60 +++++ aispeech/aispeech_test.go | 59 +++++ aispeech/config/config.go | 23 ++ aispeech/config/config_test.go | 15 ++ aispeech/context/context.go | 12 + aispeech/dialog/api.go | 116 +++++++++ aispeech/dialog/dialog.go | 120 +++++++++ aispeech/dialog/dialog_test.go | 366 +++++++++++++++++++++++++++ aispeech/dialog/integration_test.go | 186 ++++++++++++++ aispeech/dialog/request.go | 48 ++++ aispeech/dialog/response.go | 137 ++++++++++ aispeech/dialog/token.go | 89 +++++++ aispeech/encryptor/encryptor.go | 100 ++++++++ aispeech/encryptor/encryptor_test.go | 33 +++ aispeech/encryptor/signature.go | 20 ++ doc/api/aispeech.md | 11 +- wechat.go | 10 + 18 files changed, 1504 insertions(+), 3 deletions(-) create mode 100644 aispeech/aispeech.go create mode 100644 aispeech/aispeech_test.go create mode 100644 aispeech/config/config.go create mode 100644 aispeech/config/config_test.go create mode 100644 aispeech/context/context.go create mode 100644 aispeech/dialog/api.go create mode 100644 aispeech/dialog/dialog.go create mode 100644 aispeech/dialog/dialog_test.go create mode 100644 aispeech/dialog/integration_test.go create mode 100644 aispeech/dialog/request.go create mode 100644 aispeech/dialog/response.go create mode 100644 aispeech/dialog/token.go create mode 100644 aispeech/encryptor/encryptor.go create mode 100644 aispeech/encryptor/encryptor_test.go create mode 100644 aispeech/encryptor/signature.go diff --git a/aispeech/README.md b/aispeech/README.md index af19677fe..44d12f591 100644 --- a/aispeech/README.md +++ b/aispeech/README.md @@ -1,5 +1,103 @@ # 智能对话 -[官方文档](https://developers.weixin.qq.com/doc/aispeech/platform/INTERFACEDOCUMENT.html) +[官方文档](https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html) -## 快速入门 \ No newline at end of file +## 快速入门 + +```go +import ( + "github.com/silenceper/wechat/v2" + "github.com/silenceper/wechat/v2/aispeech/config" + "github.com/silenceper/wechat/v2/aispeech/dialog" + "github.com/silenceper/wechat/v2/cache" +) + +wc := wechat.NewWechat() +memory := cache.NewMemory() + +ai := wc.GetAISpeech(&config.Config{ + AppID: "xxx", + Token: "xxx", + AESKey: "xxx", + Account: "admin", + Cache: memory, +}) + +dialogClient := ai.GetDialog() + +accessToken, err := dialogClient.GetAccessToken() +if err != nil { + return err +} + +res, err := dialogClient.Query(&dialog.QueryRequest{ + Query: "你好", + Env: "online", + UserID: "user-1", +}) +``` + +## 简单问答导入与发布 + +```go +task, err := dialogClient.ImportJSON(&dialog.ImportJSONRequest{ + Mode: 0, + Data: []dialog.BotIntent{{ + Skill: "售前咨询", + Intent: "查询营业时间", + Disable: false, + Questions: []string{"你们几点开门", "营业时间是什么时候"}, + Answers: []string{"我们的营业时间是周一至周五 9:00-18:00"}, + }}, +}) +if err != nil { + return err +} + +asyncResult, err := dialogClient.FetchAsync(&dialog.FetchAsyncRequest{ + TaskID: task.TaskID, +}) +if err != nil { + return err +} + +publish, err := dialogClient.Publish() +if err != nil { + return err +} + +progress, err := dialogClient.GetEffectiveProgress(&dialog.EffectiveProgressRequest{ + Env: "online", +}) +``` + +当前封装微信智能对话开放接口中“接入智能对话”的 6 个接口: + +- `POST /v2/token` +- `POST /v2/bot/import/json` +- `POST /v2/async/fetch` +- `POST /v2/bot/publish` +- `POST /v2/bot/effective_progress` +- `POST /v2/bot/query` + +## 真实接口验证 + +默认单元测试不会访问真实微信接口。如需验证 token 和 query 只读链路,可设置以下环境变量后运行: + +```bash +AISPEECH_INTEGRATION=1 +AISPEECH_APPID=xxx +AISPEECH_TOKEN=xxx +AISPEECH_AES_KEY=xxx +go test ./aispeech/dialog -run TestIntegrationAccessTokenAndQuery +``` + +如需验证导入、异步任务查询、发布、发布进度查询和命中回答的完整链路,可显式开启变更型测试。该测试会导入一个 `CodexSmokeTest` 临时问答并发布到线上机器人: + +```bash +AISPEECH_INTEGRATION_MUTATION=1 +AISPEECH_APPID=xxx +AISPEECH_TOKEN=xxx +AISPEECH_AES_KEY=xxx +go test ./aispeech/dialog -run TestIntegrationFullDialogFlow -v +``` diff --git a/aispeech/aispeech.go b/aispeech/aispeech.go new file mode 100644 index 000000000..2c39f4671 --- /dev/null +++ b/aispeech/aispeech.go @@ -0,0 +1,60 @@ +package aispeech + +import ( + stdcontext "context" + + "github.com/silenceper/wechat/v2/aispeech/config" + "github.com/silenceper/wechat/v2/aispeech/context" + "github.com/silenceper/wechat/v2/aispeech/dialog" + "github.com/silenceper/wechat/v2/credential" +) + +// AISpeech 微信智能对话相关 API. +type AISpeech struct { + ctx *context.Context + dialog *dialog.Dialog +} + +// NewAISpeech 实例化智能对话 API. +func NewAISpeech(cfg *config.Config) *AISpeech { + ctx := &context.Context{ + Config: cfg, + AccessTokenContextHandle: dialog.NewAccessToken(cfg), + } + return &AISpeech{ctx: ctx} +} + +// GetContext get Context. +func (a *AISpeech) GetContext() *context.Context { + return a.ctx +} + +// SetAccessTokenHandle 自定义 access_token 获取方式. +func (a *AISpeech) SetAccessTokenHandle(accessTokenHandle credential.AccessTokenHandle) { + a.ctx.AccessTokenContextHandle = credential.AccessTokenCompatibleHandle{ + AccessTokenHandle: accessTokenHandle, + } +} + +// SetAccessTokenContextHandle 自定义 access_token 获取方式. +func (a *AISpeech) SetAccessTokenContextHandle(accessTokenContextHandle credential.AccessTokenContextHandle) { + a.ctx.AccessTokenContextHandle = accessTokenContextHandle +} + +// GetAccessToken 获取 access token. +func (a *AISpeech) GetAccessToken() (string, error) { + return a.ctx.GetAccessToken() +} + +// GetAccessTokenContext 获取 access token. +func (a *AISpeech) GetAccessTokenContext(ctx stdcontext.Context) (string, error) { + return a.ctx.GetAccessTokenContext(ctx) +} + +// GetDialog 获取对话平台 API. +func (a *AISpeech) GetDialog() *dialog.Dialog { + if a.dialog == nil { + a.dialog = dialog.NewDialog(a.ctx) + } + return a.dialog +} diff --git a/aispeech/aispeech_test.go b/aispeech/aispeech_test.go new file mode 100644 index 000000000..09371fd58 --- /dev/null +++ b/aispeech/aispeech_test.go @@ -0,0 +1,59 @@ +package aispeech + +import ( + stdcontext "context" + "testing" + + "github.com/silenceper/wechat/v2/aispeech/config" + "github.com/silenceper/wechat/v2/cache" +) + +type staticAccessToken struct { + token string +} + +func (s staticAccessToken) GetAccessToken() (string, error) { + return s.token, nil +} + +type staticAccessTokenContext struct { + token string +} + +func (s staticAccessTokenContext) GetAccessToken() (string, error) { + return s.GetAccessTokenContext(stdcontext.Background()) +} + +func (s staticAccessTokenContext) GetAccessTokenContext(ctx stdcontext.Context) (string, error) { + if v := ctx.Value("token"); v != nil { + return v.(string), nil + } + return s.token, nil +} + +func TestSetAccessTokenHandle(t *testing.T) { + ai := NewAISpeech(&config.Config{Cache: cache.NewMemory()}) + ai.SetAccessTokenHandle(staticAccessToken{token: "custom-token"}) + + token, err := ai.GetAccessToken() + if err != nil { + t.Fatalf("GetAccessToken error: %v", err) + } + if token != "custom-token" { + t.Fatalf("bad token: %s", token) + } +} + +func TestSetAccessTokenContextHandle(t *testing.T) { + ai := NewAISpeech(&config.Config{Cache: cache.NewMemory()}) + ai.SetAccessTokenContextHandle(staticAccessTokenContext{token: "custom-token"}) + + ctx := stdcontext.WithValue(stdcontext.Background(), "token", "context-token") + token, err := ai.GetAccessTokenContext(ctx) + if err != nil { + t.Fatalf("GetAccessTokenContext error: %v", err) + } + if token != "context-token" { + t.Fatalf("bad token: %s", token) + } +} diff --git a/aispeech/config/config.go b/aispeech/config/config.go new file mode 100644 index 000000000..6945a003f --- /dev/null +++ b/aispeech/config/config.go @@ -0,0 +1,23 @@ +package config + +import "github.com/silenceper/wechat/v2/cache" + +const defaultBaseURL = "https://openaiapi.weixin.qq.com" + +// Config for 微信智能对话. +type Config struct { + AppID string `json:"app_id"` + Token string `json:"token"` + AESKey string `json:"aes_key"` + Account string `json:"account"` + BaseURL string `json:"base_url"` + Cache cache.Cache +} + +// GetBaseURL returns the API base URL. +func (cfg *Config) GetBaseURL() string { + if cfg.BaseURL == "" { + return defaultBaseURL + } + return cfg.BaseURL +} diff --git a/aispeech/config/config_test.go b/aispeech/config/config_test.go new file mode 100644 index 000000000..9b6bb09ec --- /dev/null +++ b/aispeech/config/config_test.go @@ -0,0 +1,15 @@ +package config + +import "testing" + +func TestGetBaseURL(t *testing.T) { + cfg := &Config{} + if cfg.GetBaseURL() != defaultBaseURL { + t.Fatalf("bad default base url: %s", cfg.GetBaseURL()) + } + + cfg.BaseURL = "http://example.com" + if cfg.GetBaseURL() != "http://example.com" { + t.Fatalf("bad custom base url: %s", cfg.GetBaseURL()) + } +} diff --git a/aispeech/context/context.go b/aispeech/context/context.go new file mode 100644 index 000000000..a28ef3851 --- /dev/null +++ b/aispeech/context/context.go @@ -0,0 +1,12 @@ +package context + +import ( + "github.com/silenceper/wechat/v2/aispeech/config" + "github.com/silenceper/wechat/v2/credential" +) + +// Context struct +type Context struct { + *config.Config + credential.AccessTokenContextHandle +} diff --git a/aispeech/dialog/api.go b/aispeech/dialog/api.go new file mode 100644 index 000000000..cad602ed2 --- /dev/null +++ b/aispeech/dialog/api.go @@ -0,0 +1,116 @@ +package dialog + +import ( + stdcontext "context" + "encoding/json" + + "github.com/silenceper/wechat/v2/aispeech/encryptor" +) + +// GetAccessToken 获取 access token. +func (d *Dialog) GetAccessToken() (string, error) { + return d.Context.GetAccessToken() +} + +// GetAccessTokenContext 获取 access token. +func (d *Dialog) GetAccessTokenContext(ctx stdcontext.Context) (string, error) { + return d.Context.GetAccessTokenContext(ctx) +} + +// ImportJSON 简单问答导入. +func (d *Dialog) ImportJSON(req *ImportJSONRequest) (*ImportJSONResponse, error) { + return d.ImportJSONContext(stdcontext.Background(), req) +} + +// ImportJSONContext 简单问答导入. +func (d *Dialog) ImportJSONContext(ctx stdcontext.Context, req *ImportJSONRequest) (*ImportJSONResponse, error) { + var res ImportJSONResponse + requestID, err := d.postJSON(ctx, importJSONPath, req, &res, "AISpeechImportJSON") + res.RequestID = requestID + return &res, err +} + +// Publish 发布机器人. +func (d *Dialog) Publish() (*PublishResponse, error) { + return d.PublishContext(stdcontext.Background()) +} + +// PublishContext 发布机器人. +func (d *Dialog) PublishContext(ctx stdcontext.Context) (*PublishResponse, error) { + var res PublishResponse + requestID, err := d.postEmpty(ctx, publishPath, &res, "AISpeechPublish") + res.RequestID = requestID + return &res, err +} + +// GetEffectiveProgress 查询机器人发布进度. +func (d *Dialog) GetEffectiveProgress(req *EffectiveProgressRequest) (*EffectiveProgressResponse, error) { + return d.GetEffectiveProgressContext(stdcontext.Background(), req) +} + +// GetEffectiveProgressContext 查询机器人发布进度. +func (d *Dialog) GetEffectiveProgressContext(ctx stdcontext.Context, req *EffectiveProgressRequest) (*EffectiveProgressResponse, error) { + var res EffectiveProgressResponse + requestID, err := d.postJSON(ctx, effectiveProgressPath, req, &res, "AISpeechGetEffectiveProgress") + res.RequestID = requestID + return &res, err +} + +// FetchAsync 查询异步任务. +func (d *Dialog) FetchAsync(req *FetchAsyncRequest) (*FetchAsyncResponse, error) { + return d.FetchAsyncContext(stdcontext.Background(), req) +} + +// FetchAsyncContext 查询异步任务. +func (d *Dialog) FetchAsyncContext(ctx stdcontext.Context, req *FetchAsyncRequest) (*FetchAsyncResponse, error) { + var res FetchAsyncResponse + requestID, err := d.postJSON(ctx, fetchAsyncPath, req, &res, "AISpeechFetchAsync") + res.RequestID = requestID + return &res, err +} + +// Query 调用智能对话. +func (d *Dialog) Query(req *QueryRequest) (*QueryResponse, error) { + return d.QueryContext(stdcontext.Background(), req) +} + +// QueryContext 调用智能对话. +func (d *Dialog) QueryContext(ctx stdcontext.Context, req *QueryRequest) (*QueryResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + encryptedBody, err := encryptor.Encrypt(d.AESKey, body) + if err != nil { + return nil, err + } + accessToken, err := d.GetAccessTokenContext(ctx) + if err != nil { + return nil, err + } + if accessToken == "" { + return nil, errEmptyAccessToken + } + response, err := post(ctx, d.Config, queryPath, []byte(encryptedBody), "text/plain", accessToken, "") + if err != nil { + return nil, err + } + plainResponse := response + if !json.Valid(response) { + plainResponse, err = encryptor.Decrypt(d.AESKey, string(response)) + if err != nil { + return nil, err + } + } + + var res QueryResponse + requestID, err := decodeResponse(plainResponse, &res, "AISpeechQuery") + if err != nil { + return nil, err + } + res.RequestID = requestID + if json.Valid([]byte(res.Answer)) { + res.RawAnswer = json.RawMessage(res.Answer) + } + return &res, nil +} diff --git a/aispeech/dialog/dialog.go b/aispeech/dialog/dialog.go new file mode 100644 index 000000000..27c990ba7 --- /dev/null +++ b/aispeech/dialog/dialog.go @@ -0,0 +1,120 @@ +package dialog + +import ( + stdcontext "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/silenceper/wechat/v2/aispeech/config" + "github.com/silenceper/wechat/v2/aispeech/context" + "github.com/silenceper/wechat/v2/aispeech/encryptor" + "github.com/silenceper/wechat/v2/util" +) + +const ( + // tokenPath 获取 AccessToken. + // 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html + tokenPath = "/v2/token" + // importJSONPath 简单问答 JSON 导入. + // 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/import.html + importJSONPath = "/v2/bot/import/json" + // fetchAsyncPath 异步任务查询. + // 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/fetch.html + fetchAsyncPath = "/v2/async/fetch" + // publishPath 发布机器人. + // 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/publish.html + publishPath = "/v2/bot/publish" + // effectiveProgressPath 查询机器人发布进度. + // 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/progress.html + effectiveProgressPath = "/v2/bot/effective_progress" + // queryPath 调用智能对话. + // 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/query.html + queryPath = "/v2/bot/query" +) + +// Dialog 智能对话平台. +type Dialog struct { + *context.Context +} + +// NewDialog init. +func NewDialog(ctx *context.Context) *Dialog { + return &Dialog{ctx} +} + +func (d *Dialog) postJSON(ctx stdcontext.Context, path string, req interface{}, res interface{}, apiName string) (string, error) { + body, err := json.Marshal(req) + if err != nil { + return "", err + } + accessToken, err := d.GetAccessTokenContext(ctx) + if err != nil { + return "", err + } + if accessToken == "" { + return "", errEmptyAccessToken + } + response, err := post(ctx, d.Config, path, body, "application/json", accessToken, "") + if err != nil { + return "", err + } + return decodeResponse(response, res, apiName) +} + +func (d *Dialog) postEmpty(ctx stdcontext.Context, path string, res interface{}, apiName string) (string, error) { + accessToken, err := d.GetAccessTokenContext(ctx) + if err != nil { + return "", err + } + if accessToken == "" { + return "", errEmptyAccessToken + } + response, err := post(ctx, d.Config, path, nil, "application/json", accessToken, "") + if err != nil { + return "", err + } + return decodeResponse(response, res, apiName) +} + +func post(ctx stdcontext.Context, cfg *config.Config, path string, body []byte, contentType, accessToken, appID string) ([]byte, error) { + timestamp := time.Now().Unix() + nonce, err := randomString(16) + if err != nil { + return nil, err + } + requestID, err := randomString(32) + if err != nil { + return nil, err + } + + header := map[string]string{ + "request_id": requestID, + "timestamp": fmt.Sprintf("%d", timestamp), + "nonce": nonce, + "sign": encryptor.Sign(cfg.Token, timestamp, nonce, body), + "Content-Type": contentType, + } + if accessToken != "" { + header["X-OPENAI-TOKEN"] = accessToken + } else { + header["X-APPID"] = appID + } + + return util.HTTPPostContext(ctx, buildURL(cfg.GetBaseURL(), path), body, header) +} + +func buildURL(baseURL, path string) string { + return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func randomString(length int) (string, error) { + buf := make([]byte, (length+1)/2) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf)[:length], nil +} diff --git a/aispeech/dialog/dialog_test.go b/aispeech/dialog/dialog_test.go new file mode 100644 index 000000000..d95916ce0 --- /dev/null +++ b/aispeech/dialog/dialog_test.go @@ -0,0 +1,366 @@ +package dialog + +import ( + stdcontext "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/silenceper/wechat/v2/aispeech/config" + aispeechContext "github.com/silenceper/wechat/v2/aispeech/context" + "github.com/silenceper/wechat/v2/aispeech/encryptor" + "github.com/silenceper/wechat/v2/cache" +) + +const testAESKey = "q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz" + +type emptyAccessTokenHandle struct{} + +func (emptyAccessTokenHandle) GetAccessToken() (string, error) { + return "", nil +} + +func (emptyAccessTokenHandle) GetAccessTokenContext(ctx stdcontext.Context) (string, error) { + return "", nil +} + +func TestAccessTokenCache(t *testing.T) { + var tokenRequests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenRequests++ + body := readBody(t, r) + assertSign(t, r, "token", body) + if r.Header.Get("X-APPID") != "appid" { + t.Fatalf("bad X-APPID: %s", r.Header.Get("X-APPID")) + } + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"rid","data":{"access_token":"access-token"}}`)) + })) + defer srv.Close() + + ak := NewAccessToken(&config.Config{ + AppID: "appid", + Token: "token", + Account: "admin", + BaseURL: srv.URL, + Cache: cache.NewMemory(), + }) + + token, err := ak.GetAccessToken() + if err != nil { + t.Fatalf("GetAccessToken error: %v", err) + } + if token != "access-token" { + t.Fatalf("bad token: %s", token) + } + token, err = ak.GetAccessToken() + if err != nil { + t.Fatalf("GetAccessToken second error: %v", err) + } + if token != "access-token" { + t.Fatalf("bad token second: %s", token) + } + if tokenRequests != 1 { + t.Fatalf("token requests = %d, want 1", tokenRequests) + } +} + +func TestAccessTokenCacheByAccount(t *testing.T) { + var tokenRequests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenRequests++ + body := readBody(t, r) + assertSign(t, r, "token", body) + + var req AccessTokenRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("bad token body: %v", err) + } + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"rid","data":{"access_token":"` + req.Account + `-token"}}`)) + })) + defer srv.Close() + + memory := cache.NewMemory() + cfgA := &config.Config{ + AppID: "appid", + Token: "token", + Account: "admin-a", + BaseURL: srv.URL, + Cache: memory, + } + cfgB := &config.Config{ + AppID: "appid", + Token: "token", + Account: "admin-b", + BaseURL: srv.URL, + Cache: memory, + } + + tokenA, err := NewAccessToken(cfgA).GetAccessToken() + if err != nil { + t.Fatalf("GetAccessToken A error: %v", err) + } + tokenB, err := NewAccessToken(cfgB).GetAccessToken() + if err != nil { + t.Fatalf("GetAccessToken B error: %v", err) + } + if tokenA != "admin-a-token" || tokenB != "admin-b-token" { + t.Fatalf("bad tokens: %s %s", tokenA, tokenB) + } + if tokenRequests != 2 { + t.Fatalf("token requests = %d, want 2", tokenRequests) + } +} + +func TestAccessTokenEmptyToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"rid","data":{"access_token":""}}`)) + })) + defer srv.Close() + + ak := NewAccessToken(&config.Config{ + AppID: "appid", + Token: "token", + BaseURL: srv.URL, + Cache: cache.NewMemory(), + }) + + if _, err := ak.GetAccessToken(); err == nil { + t.Fatal("GetAccessToken should return error") + } +} + +func TestDialogAPIs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readBody(t, r) + assertSign(t, r, "token", body) + switch r.URL.Path { + case "/v2/token": + if r.Header.Get("X-APPID") != "appid" { + t.Fatalf("bad X-APPID: %s", r.Header.Get("X-APPID")) + } + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"token-rid","data":{"access_token":"access-token"}}`)) + case "/v2/bot/import/json": + assertToken(t, r) + var req ImportJSONRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("bad import body: %v", err) + } + if len(req.Data) != 1 || req.Data[0].Skill != "售前咨询" { + t.Fatalf("bad import request: %+v", req) + } + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"import-rid","data":{"task_id":"task-import"}}`)) + case "/v2/bot/publish": + assertToken(t, r) + if len(body) != 0 { + t.Fatalf("publish body should be empty: %s", body) + } + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"publish-rid","data":{"task_id":"task-publish"}}`)) + case "/v2/bot/effective_progress": + assertToken(t, r) + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"progress-rid","data":{"end_time":"","progress":100,"status":1}}`)) + case "/v2/async/fetch": + assertToken(t, r) + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"fetch-rid","data":{"state":2,"msg":"","progress":100,"start":1,"end":2,"url":"","success_skill_info":[{"id":1,"name":"AAA","intents":[{"id":2,"name":"BBB"}]}]}}`)) + case "/v2/bot/query": + assertToken(t, r) + if !strings.HasPrefix(r.Header.Get("Content-Type"), "text/plain") { + t.Fatalf("bad content type: %s", r.Header.Get("Content-Type")) + } + plain, err := encryptor.Decrypt(testAESKey, string(body)) + if err != nil { + t.Fatalf("decrypt query body error: %v", err) + } + var req QueryRequest + if err = json.Unmarshal(plain, &req); err != nil { + t.Fatalf("bad query body: %v", err) + } + if req.Query != "你好" { + t.Fatalf("bad query: %+v", req) + } + cipherText, err := encryptor.Encrypt(testAESKey, []byte(`{"code":0,"msg":"success","request_id":"query-rid","data":{"answer":"你好呀","answer_type":"text","skill_name":"skill","intent_name":"intent","msg_id":"msg","status":"FAQ","slots":[{"name":"n","value":"v","norm":"v"}]}}`)) + if err != nil { + t.Fatalf("encrypt response error: %v", err) + } + _, _ = w.Write([]byte(cipherText)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer srv.Close() + + cfg := &config.Config{ + AppID: "appid", + Token: "token", + AESKey: testAESKey, + Account: "admin", + BaseURL: srv.URL, + Cache: cache.NewMemory(), + } + d := NewDialog(&aispeechContext.Context{ + Config: cfg, + AccessTokenContextHandle: NewAccessToken(cfg), + }) + + importRes, err := d.ImportJSON(&ImportJSONRequest{ + Mode: 0, + Data: []BotIntent{{ + Skill: "售前咨询", + Intent: "查询营业时间", + Disable: false, + Questions: []string{"你们几点开门"}, + Answers: []string{"9:00-18:00"}, + }}, + }) + if err != nil || importRes.TaskID != "task-import" || importRes.RequestID != "import-rid" { + t.Fatalf("ImportJSON = %+v, %v", importRes, err) + } + + publishRes, err := d.Publish() + if err != nil || publishRes.TaskID != "task-publish" { + t.Fatalf("Publish = %+v, %v", publishRes, err) + } + + progressRes, err := d.GetEffectiveProgress(&EffectiveProgressRequest{Env: "online"}) + if err != nil || progressRes.Progress != 100 { + t.Fatalf("GetEffectiveProgress = %+v, %v", progressRes, err) + } + + fetchRes, err := d.FetchAsync(&FetchAsyncRequest{TaskID: "task-import"}) + if err != nil || fetchRes.State != 2 || len(fetchRes.SuccessSkillInfo) != 1 { + t.Fatalf("FetchAsync = %+v, %v", fetchRes, err) + } + + queryRes, err := d.Query(&QueryRequest{Query: "你好", Env: "online"}) + if err != nil || queryRes.Answer != "你好呀" || queryRes.RequestID != "query-rid" { + t.Fatalf("Query = %+v, %v", queryRes, err) + } +} + +func TestDialogEmptyAccessToken(t *testing.T) { + d := NewDialog(&aispeechContext.Context{ + Config: &config.Config{ + AESKey: testAESKey, + }, + AccessTokenContextHandle: emptyAccessTokenHandle{}, + }) + + if _, err := d.ImportJSON(&ImportJSONRequest{}); err == nil { + t.Fatal("ImportJSON should return error") + } + if _, err := d.Publish(); err == nil { + t.Fatal("Publish should return error") + } + if _, err := d.Query(&QueryRequest{}); err == nil { + t.Fatal("Query should return error") + } +} + +func TestDialogAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/token": + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"token-rid","data":{"access_token":"access-token"}}`)) + case "/v2/bot/import/json": + _, _ = w.Write([]byte(`{"code":210202,"msg":"权限不足","request_id":"import-rid","data":{"reason":"forbidden"}}`)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer srv.Close() + + cfg := &config.Config{ + AppID: "appid", + Token: "token", + BaseURL: srv.URL, + Cache: cache.NewMemory(), + } + d := NewDialog(&aispeechContext.Context{ + Config: cfg, + AccessTokenContextHandle: NewAccessToken(cfg), + }) + + _, err := d.ImportJSON(&ImportJSONRequest{}) + if err == nil { + t.Fatal("ImportJSON should return error") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("ImportJSON error should be *APIError but %T", err) + } + if apiErr.Code != 210202 || apiErr.Msg != "权限不足" || apiErr.RequestID != "import-rid" { + t.Fatalf("bad api error: %+v", apiErr) + } + if string(apiErr.Data) != `{"reason":"forbidden"}` { + t.Fatalf("bad api error data: %s", apiErr.Data) + } +} + +func TestQueryPlainJSONError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/token": + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"token-rid","data":{"access_token":"access-token"}}`)) + case "/v2/bot/query": + _, _ = w.Write([]byte(`{"code":110002,"msg":"参数错误","request_id":"query-rid"}`)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer srv.Close() + + cfg := &config.Config{ + AppID: "appid", + Token: "token", + AESKey: testAESKey, + BaseURL: srv.URL, + Cache: cache.NewMemory(), + } + d := NewDialog(&aispeechContext.Context{ + Config: cfg, + AccessTokenContextHandle: NewAccessToken(cfg), + }) + + _, err := d.Query(&QueryRequest{Query: "你好"}) + if err == nil { + t.Fatal("Query should return error") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("Query error should be *APIError but %T", err) + } + if apiErr.Code != 110002 || apiErr.RequestID != "query-rid" { + t.Fatalf("bad api error: %+v", apiErr) + } +} + +func readBody(t *testing.T, r *http.Request) []byte { + t.Helper() + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body error: %v", err) + } + return body +} + +func assertToken(t *testing.T, r *http.Request) { + t.Helper() + if r.Header.Get("X-OPENAI-TOKEN") != "access-token" { + t.Fatalf("bad X-OPENAI-TOKEN: %s", r.Header.Get("X-OPENAI-TOKEN")) + } +} + +func assertSign(t *testing.T, r *http.Request, token string, body []byte) { + t.Helper() + timestamp, err := strconv.ParseInt(r.Header.Get("timestamp"), 10, 64) + if err != nil { + t.Fatalf("bad timestamp: %v", err) + } + want := encryptor.Sign(token, timestamp, r.Header.Get("nonce"), body) + if got := r.Header.Get("sign"); got != want { + t.Fatalf("bad sign: got %s want %s body %s", got, want, body) + } +} diff --git a/aispeech/dialog/integration_test.go b/aispeech/dialog/integration_test.go new file mode 100644 index 000000000..d056e58d3 --- /dev/null +++ b/aispeech/dialog/integration_test.go @@ -0,0 +1,186 @@ +package dialog + +import ( + "os" + "testing" + "time" + + "github.com/silenceper/wechat/v2/aispeech/config" + aispeechContext "github.com/silenceper/wechat/v2/aispeech/context" + "github.com/silenceper/wechat/v2/cache" +) + +func TestIntegrationAccessTokenAndQuery(t *testing.T) { + if os.Getenv("AISPEECH_INTEGRATION") != "1" { + t.Skip("set AISPEECH_INTEGRATION=1 to run real aispeech API test") + } + + cfg := &config.Config{ + AppID: os.Getenv("AISPEECH_APPID"), + Token: os.Getenv("AISPEECH_TOKEN"), + AESKey: os.Getenv("AISPEECH_AES_KEY"), + Cache: cache.NewMemory(), + } + if cfg.AppID == "" || cfg.Token == "" || cfg.AESKey == "" { + t.Fatal("AISPEECH_APPID, AISPEECH_TOKEN and AISPEECH_AES_KEY are required") + } + + d := NewDialog(&aispeechContext.Context{ + Config: cfg, + AccessTokenContextHandle: NewAccessToken(cfg), + }) + + accessToken, err := d.GetAccessToken() + if err != nil { + t.Fatalf("GetAccessToken error: %v", err) + } + if accessToken == "" { + t.Fatal("access token is empty") + } + + res, err := d.Query(&QueryRequest{ + Query: "你好", + Env: "online", + UserID: "gowechat-integration-test", + }) + if err != nil { + t.Fatalf("Query error: %v", err) + } + if res.RequestID == "" { + t.Fatal("query request_id is empty") + } +} + +func TestIntegrationFullDialogFlow(t *testing.T) { + if os.Getenv("AISPEECH_INTEGRATION_MUTATION") != "1" { + t.Skip("set AISPEECH_INTEGRATION_MUTATION=1 to run import/publish/query real aispeech API test") + } + + d := newIntegrationDialog(t) + now := time.Now().Unix() + question := "codex-smoke-test-question-" + time.Unix(now, 0).Format("20060102150405") + answer := "codex-smoke-test-answer-" + time.Unix(now, 0).Format("20060102150405") + + importRes, err := d.ImportJSON(&ImportJSONRequest{ + Mode: 0, + Data: []BotIntent{{ + Skill: "CodexSmokeTest", + Intent: question, + Disable: false, + Questions: []string{question}, + Answers: []string{answer}, + }}, + }) + if err != nil { + t.Fatalf("ImportJSON error: %v", err) + } + if importRes.TaskID == "" { + t.Fatal("ImportJSON task_id is empty") + } + t.Logf("ImportJSON request_id=%s task_id=%s", importRes.RequestID, importRes.TaskID) + + importTask := waitAsyncTask(t, d, importRes.TaskID, 2*time.Minute) + if importTask.State != 2 { + t.Fatalf("import task failed: state=%d msg=%s progress=%d", importTask.State, importTask.Msg, importTask.Progress) + } + t.Logf("FetchAsync import request_id=%s state=%d progress=%d", importTask.RequestID, importTask.State, importTask.Progress) + + publishRes, err := d.Publish() + if err != nil { + t.Fatalf("Publish error: %v", err) + } + if publishRes.TaskID == "" { + t.Fatal("Publish task_id is empty") + } + t.Logf("Publish request_id=%s task_id=%s", publishRes.RequestID, publishRes.TaskID) + + progress := waitEffectiveProgress(t, d, "online", 3*time.Minute) + if progress.Status != 1 { + t.Fatalf("publish progress failed: status=%d progress=%d", progress.Status, progress.Progress) + } + t.Logf("GetEffectiveProgress request_id=%s status=%d progress=%d", progress.RequestID, progress.Status, progress.Progress) + + queryRes := waitQueryAnswer(t, d, question, answer, 2*time.Minute) + t.Logf("Query request_id=%s status=%s answer_type=%s answer=%s", queryRes.RequestID, queryRes.Status, queryRes.AnswerType, queryRes.Answer) +} + +func newIntegrationDialog(t *testing.T) *Dialog { + t.Helper() + + cfg := &config.Config{ + AppID: os.Getenv("AISPEECH_APPID"), + Token: os.Getenv("AISPEECH_TOKEN"), + AESKey: os.Getenv("AISPEECH_AES_KEY"), + Account: os.Getenv("AISPEECH_ACCOUNT"), + Cache: cache.NewMemory(), + } + if cfg.AppID == "" || cfg.Token == "" || cfg.AESKey == "" { + t.Fatal("AISPEECH_APPID, AISPEECH_TOKEN and AISPEECH_AES_KEY are required") + } + + return NewDialog(&aispeechContext.Context{ + Config: cfg, + AccessTokenContextHandle: NewAccessToken(cfg), + }) +} + +func waitAsyncTask(t *testing.T, d *Dialog, taskID string, timeout time.Duration) *FetchAsyncResponse { + t.Helper() + + deadline := time.Now().Add(timeout) + for { + res, err := d.FetchAsync(&FetchAsyncRequest{TaskID: taskID}) + if err != nil { + t.Fatalf("FetchAsync error: %v", err) + } + if res.State == 2 || res.State == 3 { + return res + } + if time.Now().After(deadline) { + t.Fatalf("FetchAsync timeout: state=%d progress=%d msg=%s", res.State, res.Progress, res.Msg) + } + time.Sleep(3 * time.Second) + } +} + +func waitEffectiveProgress(t *testing.T, d *Dialog, env string, timeout time.Duration) *EffectiveProgressResponse { + t.Helper() + + deadline := time.Now().Add(timeout) + for { + res, err := d.GetEffectiveProgress(&EffectiveProgressRequest{Env: env}) + if err != nil { + t.Fatalf("GetEffectiveProgress error: %v", err) + } + if res.Status == 1 || res.Status == 2 { + return res + } + if time.Now().After(deadline) { + t.Fatalf("GetEffectiveProgress timeout: status=%d progress=%d", res.Status, res.Progress) + } + time.Sleep(5 * time.Second) + } +} + +func waitQueryAnswer(t *testing.T, d *Dialog, question, answer string, timeout time.Duration) *QueryResponse { + t.Helper() + + deadline := time.Now().Add(timeout) + for { + res, err := d.Query(&QueryRequest{ + Query: question, + Env: "online", + UserID: "gowechat-full-integration-test", + }) + if err != nil { + t.Fatalf("Query error: %v", err) + } + if res.Answer == answer { + return res + } + if time.Now().After(deadline) { + t.Fatalf("Query timeout: status=%s answer=%s want=%s", res.Status, res.Answer, answer) + } + time.Sleep(5 * time.Second) + } +} diff --git a/aispeech/dialog/request.go b/aispeech/dialog/request.go new file mode 100644 index 000000000..b1c0a3728 --- /dev/null +++ b/aispeech/dialog/request.go @@ -0,0 +1,48 @@ +package dialog + +// AccessTokenRequest 获取 access token 请求. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html +type AccessTokenRequest struct { + Account string `json:"account,omitempty"` +} + +// ImportJSONRequest 简单问答导入请求. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/import.html +type ImportJSONRequest struct { + Mode int `json:"mode"` + Data []BotIntent `json:"data"` +} + +// BotIntent 简单问答意图. +type BotIntent struct { + Skill string `json:"skill"` + Intent string `json:"intent"` + Threshold string `json:"threshold,omitempty"` + Disable bool `json:"disable"` + Questions []string `json:"questions"` + Answers []string `json:"answers"` +} + +// EffectiveProgressRequest 发布进度查询请求. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/progress.html +type EffectiveProgressRequest struct { + Env string `json:"env"` +} + +// FetchAsyncRequest 异步任务查询请求. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/fetch.html +type FetchAsyncRequest struct { + TaskID string `json:"task_id"` +} + +// QueryRequest 调用智能对话请求. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/query.html +type QueryRequest struct { + Query string `json:"query"` + Env string `json:"env,omitempty"` + FirstPrioritySkills []string `json:"first_priority_skills,omitempty"` + SecondPrioritySkills []string `json:"second_priority_skills,omitempty"` + UserName string `json:"user_name,omitempty"` + Avatar string `json:"avatar,omitempty"` + UserID string `json:"userid,omitempty"` +} diff --git a/aispeech/dialog/response.go b/aispeech/dialog/response.go new file mode 100644 index 000000000..3b4fbcb30 --- /dev/null +++ b/aispeech/dialog/response.go @@ -0,0 +1,137 @@ +package dialog + +import ( + "encoding/json" + "fmt" +) + +type apiResponse struct { + RequestID string `json:"request_id"` + Code int64 `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +// APIError 智能对话接口错误. +type APIError struct { + APIName string + Code int64 + Msg string + RequestID string + Data json.RawMessage +} + +func (e *APIError) Error() string { + return fmt.Sprintf("%s Error , code=%d , msg=%s , request_id=%s", e.APIName, e.Code, e.Msg, e.RequestID) +} + +type accessTokenData struct { + AccessToken string `json:"access_token"` +} + +// ImportJSONResponse 简单问答导入响应. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/import.html +type ImportJSONResponse struct { + RequestID string `json:"-"` + TaskID string `json:"task_id"` +} + +// PublishResponse 发布响应. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/publish.html +type PublishResponse struct { + RequestID string `json:"-"` + TaskID string `json:"task_id"` +} + +// EffectiveProgressResponse 发布进度查询响应. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/progress.html +type EffectiveProgressResponse struct { + RequestID string `json:"-"` + EndTime string `json:"end_time"` + Progress int `json:"progress"` + Status int `json:"status"` +} + +// FetchAsyncResponse 异步任务查询响应. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/fetch.html +type FetchAsyncResponse struct { + RequestID string `json:"-"` + State int `json:"state"` + Msg string `json:"msg"` + Progress int `json:"progress"` + Start int64 `json:"start"` + End int64 `json:"end"` + URL string `json:"url"` + TotalCount int `json:"total_count"` + SuccessCount int `json:"success_count"` + FailCount int `json:"fail_count"` + ReplaceCount int `json:"replace_count"` + SuccessUploadCount int `json:"success_upload_count"` + SuccessSkillInfo []SkillInfo `json:"success_skill_info"` + FailSkillInfo []SkillInfo `json:"fail_skill_info"` +} + +// SkillInfo 技能信息. +type SkillInfo struct { + ID int64 `json:"id"` + Name string `json:"name"` + Intents []IntentInfo `json:"intents"` +} + +// IntentInfo 意图信息. +type IntentInfo struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// QueryResponse 调用智能对话响应. +// 官方文档: https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/bot/query.html +type QueryResponse struct { + RequestID string `json:"-"` + Answer string `json:"answer"` + RawAnswer json.RawMessage `json:"-"` + AnswerType string `json:"answer_type"` + SkillName string `json:"skill_name"` + IntentName string `json:"intent_name"` + MsgID string `json:"msg_id"` + Options []Option `json:"options"` + Status string `json:"status"` + Slots []SlotDetail `json:"slots"` +} + +// Option 推荐问题. +type Option struct { + AnsNodeName string `json:"ans_node_name"` + Title string `json:"title"` + Answer string `json:"answer"` + Confidence float64 `json:"confidence"` +} + +// SlotDetail 槽位数据. +type SlotDetail struct { + Name string `json:"name"` + Value string `json:"value"` + Norm string `json:"norm"` +} + +func decodeResponse(response []byte, data interface{}, apiName string) (string, error) { + var res apiResponse + if err := json.Unmarshal(response, &res); err != nil { + return "", fmt.Errorf("json Unmarshal Error, err=%v", err) + } + if res.Code != 0 { + return "", &APIError{ + APIName: apiName, + Code: res.Code, + Msg: res.Msg, + RequestID: res.RequestID, + Data: res.Data, + } + } + if data != nil && len(res.Data) > 0 && string(res.Data) != "null" { + if err := json.Unmarshal(res.Data, data); err != nil { + return "", fmt.Errorf("json Unmarshal Data Error, err=%v", err) + } + } + return res.RequestID, nil +} diff --git a/aispeech/dialog/token.go b/aispeech/dialog/token.go new file mode 100644 index 000000000..5f2299593 --- /dev/null +++ b/aispeech/dialog/token.go @@ -0,0 +1,89 @@ +package dialog + +import ( + stdcontext "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/silenceper/wechat/v2/aispeech/config" + "github.com/silenceper/wechat/v2/cache" +) + +const accessTokenCacheKeyPrefix = "gowechat_aispeech_" + +var errEmptyAccessToken = errors.New("aispeech access_token is empty") + +// AccessToken 微信智能对话 access token. +type AccessToken struct { + cfg *config.Config + accessTokenLock *sync.Mutex +} + +// NewAccessToken new AccessToken. +func NewAccessToken(cfg *config.Config) *AccessToken { + if cfg.Cache == nil { + panic("cache is needed") + } + return &AccessToken{ + cfg: cfg, + accessTokenLock: new(sync.Mutex), + } +} + +// GetAccessToken 获取 access token. +func (ak *AccessToken) GetAccessToken() (string, error) { + return ak.GetAccessTokenContext(stdcontext.Background()) +} + +// GetAccessTokenContext 获取 access token. +func (ak *AccessToken) GetAccessTokenContext(ctx stdcontext.Context) (string, error) { + cacheKey := fmt.Sprintf("%s_access_token_%s_%s", accessTokenCacheKeyPrefix, ak.cfg.AppID, ak.cfg.Account) + if val := cache.GetContext(ctx, ak.cfg.Cache, cacheKey); val != nil { + if accessToken, ok := val.(string); ok && accessToken != "" { + return accessToken, nil + } + } + + ak.accessTokenLock.Lock() + defer ak.accessTokenLock.Unlock() + + if val := cache.GetContext(ctx, ak.cfg.Cache, cacheKey); val != nil { + if accessToken, ok := val.(string); ok && accessToken != "" { + return accessToken, nil + } + } + + accessToken, err := ak.getAccessTokenFromServer(ctx) + if err != nil { + return "", err + } + if err = cache.SetContext(ctx, ak.cfg.Cache, cacheKey, accessToken, 110*time.Minute); err != nil { + return "", err + } + return accessToken, nil +} + +func (ak *AccessToken) getAccessTokenFromServer(ctx stdcontext.Context) (string, error) { + req := &AccessTokenRequest{Account: ak.cfg.Account} + body, err := json.Marshal(req) + if err != nil { + return "", err + } + + response, err := post(ctx, ak.cfg, tokenPath, body, "application/json", "", ak.cfg.AppID) + if err != nil { + return "", err + } + + var res accessTokenData + if _, err = decodeResponse(response, &res, "AISpeechGetAccessToken"); err != nil { + return "", err + } + if res.AccessToken == "" { + return "", errEmptyAccessToken + } + return res.AccessToken, nil +} diff --git a/aispeech/encryptor/encryptor.go b/aispeech/encryptor/encryptor.go new file mode 100644 index 000000000..f844e282e --- /dev/null +++ b/aispeech/encryptor/encryptor.go @@ -0,0 +1,100 @@ +package encryptor + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "errors" + "fmt" +) + +var ( + // ErrInvalidBlockSize block size 不合法. + ErrInvalidBlockSize = errors.New("invalid block size") + // ErrInvalidPKCS7Data PKCS7 数据不合法. + ErrInvalidPKCS7Data = errors.New("invalid PKCS7 data") + // ErrInvalidPKCS7Padding padding 不合法. + ErrInvalidPKCS7Padding = errors.New("invalid PKCS7 padding") +) + +// Encrypt encrypts plaintext with AES-CBC and returns base64 ciphertext. +func Encrypt(aesKey string, plaintext []byte) (string, error) { + key, err := decodeAESKey(aesKey) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + plaintext = pkcs7Pad(plaintext, block.BlockSize()) + ciphertext := make([]byte, len(plaintext)) + mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize]) + mode.CryptBlocks(ciphertext, plaintext) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts base64 AES-CBC ciphertext. +func Decrypt(aesKey, ciphertext string) ([]byte, error) { + key, err := decodeAESKey(aesKey) + if err != nil { + return nil, err + } + + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return nil, err + } + if len(data) == 0 || len(data)%aes.BlockSize != 0 { + return nil, ErrInvalidPKCS7Data + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + plaintext := make([]byte, len(data)) + mode := cipher.NewCBCDecrypter(block, key[:aes.BlockSize]) + mode.CryptBlocks(plaintext, data) + return pkcs7Unpad(plaintext, block.BlockSize()) +} + +func decodeAESKey(encodingAESKey string) ([]byte, error) { + key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + if err != nil { + return nil, err + } + if len(key) != 32 { + return nil, fmt.Errorf("encodingAESKey invalid") + } + return key, nil +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if blockSize <= 0 { + return nil, ErrInvalidBlockSize + } + if len(data) == 0 || len(data)%blockSize != 0 { + return nil, ErrInvalidPKCS7Data + } + padding := int(data[len(data)-1]) + if padding == 0 || padding > blockSize || padding > len(data) { + return nil, ErrInvalidPKCS7Padding + } + for i := len(data) - padding; i < len(data); i++ { + if int(data[i]) != padding { + return nil, ErrInvalidPKCS7Padding + } + } + return data[:len(data)-padding], nil +} diff --git a/aispeech/encryptor/encryptor_test.go b/aispeech/encryptor/encryptor_test.go new file mode 100644 index 000000000..0c6c48084 --- /dev/null +++ b/aispeech/encryptor/encryptor_test.go @@ -0,0 +1,33 @@ +package encryptor + +import "testing" + +func TestSign(t *testing.T) { + sign := Sign("YV78Pyj1VvqdNGpMJ1pHic0bIBOWMv", 1711001766, "abc", nil) + if sign != "fff8dae1356e7867ea98743439f0e9f8" { + t.Fatalf("bad sign: %s", sign) + } +} + +func TestEncryptDecrypt(t *testing.T) { + aesKey := "q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz" + plainText := []byte(`{"query":"hello"}`) + + cipherText, err := Encrypt(aesKey, plainText) + if err != nil { + t.Fatalf("Encrypt error: %v", err) + } + got, err := Decrypt(aesKey, cipherText) + if err != nil { + t.Fatalf("Decrypt error: %v", err) + } + if string(got) != string(plainText) { + t.Fatalf("bad plaintext: %s", got) + } +} + +func TestDecryptInvalidKey(t *testing.T) { + if _, err := Encrypt("bad-key", []byte("hello")); err == nil { + t.Fatal("Encrypt should return error") + } +} diff --git a/aispeech/encryptor/signature.go b/aispeech/encryptor/signature.go new file mode 100644 index 000000000..1ebb35bf0 --- /dev/null +++ b/aispeech/encryptor/signature.go @@ -0,0 +1,20 @@ +package encryptor + +import ( + "crypto/md5" + "encoding/hex" + "strconv" +) + +// Sign calculates the aispeech request signature. +func Sign(token string, timestamp int64, nonce string, body []byte) string { + bodyMD5 := md5.Sum(body) + bodySign := hex.EncodeToString(bodyMD5[:]) + + h := md5.New() + h.Write([]byte(token)) + h.Write([]byte(strconv.FormatInt(timestamp, 10))) + h.Write([]byte(nonce)) + h.Write([]byte(bodySign)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/doc/api/aispeech.md b/doc/api/aispeech.md index 6d6120bb6..bdefad09e 100644 --- a/doc/api/aispeech.md +++ b/doc/api/aispeech.md @@ -1,3 +1,12 @@ # 智能对话 -TODO +## 接入智能对话 + +- 获取 Accesstoken:`POST /v2/token` +- 简单问答导入:`POST /v2/bot/import/json` +- 异步任务查询:`POST /v2/async/fetch` +- 发布机器人:`POST /v2/bot/publish` +- 查询机器人发布进度:`POST /v2/bot/effective_progress` +- 调用智能对话:`POST /v2/bot/query` + +官方文档:https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html diff --git a/wechat.go b/wechat.go index dc2585fea..29638d0a0 100644 --- a/wechat.go +++ b/wechat.go @@ -6,6 +6,8 @@ import ( log "github.com/sirupsen/logrus" + "github.com/silenceper/wechat/v2/aispeech" + aispeechConfig "github.com/silenceper/wechat/v2/aispeech/config" "github.com/silenceper/wechat/v2/cache" "github.com/silenceper/wechat/v2/miniprogram" miniConfig "github.com/silenceper/wechat/v2/miniprogram/config" @@ -84,6 +86,14 @@ func (wc *Wechat) GetWork(cfg *workConfig.Config) *work.Work { return work.NewWork(cfg) } +// GetAISpeech 获取微信智能对话的实例 +func (wc *Wechat) GetAISpeech(cfg *aispeechConfig.Config) *aispeech.AISpeech { + if cfg.Cache == nil { + cfg.Cache = wc.cache + } + return aispeech.NewAISpeech(cfg) +} + // SetHTTPClient 设置HTTPClient func (wc *Wechat) SetHTTPClient(client *http.Client) { util.DefaultHTTPClient = client From 01486234a2037d9a5e0473585b488767e652f5c9 Mon Sep 17 00:00:00 2001 From: GargantuaX Date: Fri, 12 Jun 2026 16:52:07 +0800 Subject: [PATCH 2/3] test: fix aispeech lint failures --- aispeech/aispeech_test.go | 6 +- aispeech/dialog/dialog_test.go | 380 +++++++++++++++++++-------------- 2 files changed, 222 insertions(+), 164 deletions(-) diff --git a/aispeech/aispeech_test.go b/aispeech/aispeech_test.go index 09371fd58..4833156a0 100644 --- a/aispeech/aispeech_test.go +++ b/aispeech/aispeech_test.go @@ -20,12 +20,14 @@ type staticAccessTokenContext struct { token string } +type contextTokenKey struct{} + func (s staticAccessTokenContext) GetAccessToken() (string, error) { return s.GetAccessTokenContext(stdcontext.Background()) } func (s staticAccessTokenContext) GetAccessTokenContext(ctx stdcontext.Context) (string, error) { - if v := ctx.Value("token"); v != nil { + if v := ctx.Value(contextTokenKey{}); v != nil { return v.(string), nil } return s.token, nil @@ -48,7 +50,7 @@ func TestSetAccessTokenContextHandle(t *testing.T) { ai := NewAISpeech(&config.Config{Cache: cache.NewMemory()}) ai.SetAccessTokenContextHandle(staticAccessTokenContext{token: "custom-token"}) - ctx := stdcontext.WithValue(stdcontext.Background(), "token", "context-token") + ctx := stdcontext.WithValue(stdcontext.Background(), contextTokenKey{}, "context-token") token, err := ai.GetAccessTokenContext(ctx) if err != nil { t.Fatalf("GetAccessTokenContext error: %v", err) diff --git a/aispeech/dialog/dialog_test.go b/aispeech/dialog/dialog_test.go index d95916ce0..df094aac6 100644 --- a/aispeech/dialog/dialog_test.go +++ b/aispeech/dialog/dialog_test.go @@ -16,7 +16,20 @@ import ( "github.com/silenceper/wechat/v2/cache" ) -const testAESKey = "q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz" +const ( + testAccessToken = "access-token" + testAccount = "admin" + testAESKey = "q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz" + testAppID = "appid" + testImportRequest = "import-rid" + testImportTaskID = "task-import" + testPublishTaskID = "task-publish" + testQuery = "hello" + testQueryAnswer = "hello answer" + testQueryRequestID = "query-rid" + testToken = "token" + testTokenRequestID = "token-rid" +) type emptyAccessTokenHandle struct{} @@ -33,18 +46,18 @@ func TestAccessTokenCache(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenRequests++ body := readBody(t, r) - assertSign(t, r, "token", body) - if r.Header.Get("X-APPID") != "appid" { + assertSign(t, r, testToken, body) + if r.Header.Get("X-APPID") != testAppID { t.Fatalf("bad X-APPID: %s", r.Header.Get("X-APPID")) } - _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"rid","data":{"access_token":"access-token"}}`)) + _, _ = w.Write([]byte(accessTokenResponse("rid"))) })) defer srv.Close() ak := NewAccessToken(&config.Config{ - AppID: "appid", - Token: "token", - Account: "admin", + AppID: testAppID, + Token: testToken, + Account: testAccount, BaseURL: srv.URL, Cache: cache.NewMemory(), }) @@ -53,14 +66,14 @@ func TestAccessTokenCache(t *testing.T) { if err != nil { t.Fatalf("GetAccessToken error: %v", err) } - if token != "access-token" { + if token != testAccessToken { t.Fatalf("bad token: %s", token) } token, err = ak.GetAccessToken() if err != nil { t.Fatalf("GetAccessToken second error: %v", err) } - if token != "access-token" { + if token != testAccessToken { t.Fatalf("bad token second: %s", token) } if tokenRequests != 1 { @@ -73,31 +86,23 @@ func TestAccessTokenCacheByAccount(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenRequests++ body := readBody(t, r) - assertSign(t, r, "token", body) + assertSign(t, r, testToken, body) var req AccessTokenRequest if err := json.Unmarshal(body, &req); err != nil { t.Fatalf("bad token body: %v", err) } - _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"rid","data":{"access_token":"` + req.Account + `-token"}}`)) + _, _ = w.Write([]byte(accountAccessTokenResponse(req.Account))) })) defer srv.Close() memory := cache.NewMemory() - cfgA := &config.Config{ - AppID: "appid", - Token: "token", - Account: "admin-a", - BaseURL: srv.URL, - Cache: memory, - } - cfgB := &config.Config{ - AppID: "appid", - Token: "token", - Account: "admin-b", - BaseURL: srv.URL, - Cache: memory, - } + cfgA := testDialogConfig(srv.URL) + cfgA.Account = "admin-a" + cfgA.Cache = memory + cfgB := testDialogConfig(srv.URL) + cfgB.Account = "admin-b" + cfgB.Cache = memory tokenA, err := NewAccessToken(cfgA).GetAccessToken() if err != nil { @@ -122,8 +127,8 @@ func TestAccessTokenEmptyToken(t *testing.T) { defer srv.Close() ak := NewAccessToken(&config.Config{ - AppID: "appid", - Token: "token", + AppID: testAppID, + Token: testToken, BaseURL: srv.URL, Cache: cache.NewMemory(), }) @@ -134,110 +139,15 @@ func TestAccessTokenEmptyToken(t *testing.T) { } func TestDialogAPIs(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body := readBody(t, r) - assertSign(t, r, "token", body) - switch r.URL.Path { - case "/v2/token": - if r.Header.Get("X-APPID") != "appid" { - t.Fatalf("bad X-APPID: %s", r.Header.Get("X-APPID")) - } - _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"token-rid","data":{"access_token":"access-token"}}`)) - case "/v2/bot/import/json": - assertToken(t, r) - var req ImportJSONRequest - if err := json.Unmarshal(body, &req); err != nil { - t.Fatalf("bad import body: %v", err) - } - if len(req.Data) != 1 || req.Data[0].Skill != "售前咨询" { - t.Fatalf("bad import request: %+v", req) - } - _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"import-rid","data":{"task_id":"task-import"}}`)) - case "/v2/bot/publish": - assertToken(t, r) - if len(body) != 0 { - t.Fatalf("publish body should be empty: %s", body) - } - _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"publish-rid","data":{"task_id":"task-publish"}}`)) - case "/v2/bot/effective_progress": - assertToken(t, r) - _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"progress-rid","data":{"end_time":"","progress":100,"status":1}}`)) - case "/v2/async/fetch": - assertToken(t, r) - _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"fetch-rid","data":{"state":2,"msg":"","progress":100,"start":1,"end":2,"url":"","success_skill_info":[{"id":1,"name":"AAA","intents":[{"id":2,"name":"BBB"}]}]}}`)) - case "/v2/bot/query": - assertToken(t, r) - if !strings.HasPrefix(r.Header.Get("Content-Type"), "text/plain") { - t.Fatalf("bad content type: %s", r.Header.Get("Content-Type")) - } - plain, err := encryptor.Decrypt(testAESKey, string(body)) - if err != nil { - t.Fatalf("decrypt query body error: %v", err) - } - var req QueryRequest - if err = json.Unmarshal(plain, &req); err != nil { - t.Fatalf("bad query body: %v", err) - } - if req.Query != "你好" { - t.Fatalf("bad query: %+v", req) - } - cipherText, err := encryptor.Encrypt(testAESKey, []byte(`{"code":0,"msg":"success","request_id":"query-rid","data":{"answer":"你好呀","answer_type":"text","skill_name":"skill","intent_name":"intent","msg_id":"msg","status":"FAQ","slots":[{"name":"n","value":"v","norm":"v"}]}}`)) - if err != nil { - t.Fatalf("encrypt response error: %v", err) - } - _, _ = w.Write([]byte(cipherText)) - default: - t.Fatalf("unexpected path: %s", r.URL.Path) - } - })) + srv := newDialogAPITestServer(t) defer srv.Close() - cfg := &config.Config{ - AppID: "appid", - Token: "token", - AESKey: testAESKey, - Account: "admin", - BaseURL: srv.URL, - Cache: cache.NewMemory(), - } - d := NewDialog(&aispeechContext.Context{ - Config: cfg, - AccessTokenContextHandle: NewAccessToken(cfg), - }) - - importRes, err := d.ImportJSON(&ImportJSONRequest{ - Mode: 0, - Data: []BotIntent{{ - Skill: "售前咨询", - Intent: "查询营业时间", - Disable: false, - Questions: []string{"你们几点开门"}, - Answers: []string{"9:00-18:00"}, - }}, - }) - if err != nil || importRes.TaskID != "task-import" || importRes.RequestID != "import-rid" { - t.Fatalf("ImportJSON = %+v, %v", importRes, err) - } - - publishRes, err := d.Publish() - if err != nil || publishRes.TaskID != "task-publish" { - t.Fatalf("Publish = %+v, %v", publishRes, err) - } - - progressRes, err := d.GetEffectiveProgress(&EffectiveProgressRequest{Env: "online"}) - if err != nil || progressRes.Progress != 100 { - t.Fatalf("GetEffectiveProgress = %+v, %v", progressRes, err) - } - - fetchRes, err := d.FetchAsync(&FetchAsyncRequest{TaskID: "task-import"}) - if err != nil || fetchRes.State != 2 || len(fetchRes.SuccessSkillInfo) != 1 { - t.Fatalf("FetchAsync = %+v, %v", fetchRes, err) - } - - queryRes, err := d.Query(&QueryRequest{Query: "你好", Env: "online"}) - if err != nil || queryRes.Answer != "你好呀" || queryRes.RequestID != "query-rid" { - t.Fatalf("Query = %+v, %v", queryRes, err) - } + d := newTestDialog(srv.URL) + assertDialogImportJSON(t, d) + assertDialogPublish(t, d) + assertDialogProgress(t, d) + assertDialogFetchAsync(t, d) + assertDialogQuery(t, d) } func TestDialogEmptyAccessToken(t *testing.T) { @@ -262,26 +172,17 @@ func TestDialogEmptyAccessToken(t *testing.T) { func TestDialogAPIError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/v2/token": - _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"token-rid","data":{"access_token":"access-token"}}`)) - case "/v2/bot/import/json": - _, _ = w.Write([]byte(`{"code":210202,"msg":"权限不足","request_id":"import-rid","data":{"reason":"forbidden"}}`)) + case tokenPath: + _, _ = w.Write([]byte(accessTokenResponse(testTokenRequestID))) + case importJSONPath: + _, _ = w.Write([]byte(`{"code":210202,"msg":"forbidden","request_id":"import-rid","data":{"reason":"forbidden"}}`)) default: t.Fatalf("unexpected path: %s", r.URL.Path) } })) defer srv.Close() - cfg := &config.Config{ - AppID: "appid", - Token: "token", - BaseURL: srv.URL, - Cache: cache.NewMemory(), - } - d := NewDialog(&aispeechContext.Context{ - Config: cfg, - AccessTokenContextHandle: NewAccessToken(cfg), - }) + d := newTestDialog(srv.URL) _, err := d.ImportJSON(&ImportJSONRequest{}) if err == nil { @@ -291,7 +192,7 @@ func TestDialogAPIError(t *testing.T) { if !ok { t.Fatalf("ImportJSON error should be *APIError but %T", err) } - if apiErr.Code != 210202 || apiErr.Msg != "权限不足" || apiErr.RequestID != "import-rid" { + if apiErr.Code != 210202 || apiErr.Msg != "forbidden" || apiErr.RequestID != testImportRequest { t.Fatalf("bad api error: %+v", apiErr) } if string(apiErr.Data) != `{"reason":"forbidden"}` { @@ -302,29 +203,19 @@ func TestDialogAPIError(t *testing.T) { func TestQueryPlainJSONError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/v2/token": - _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"token-rid","data":{"access_token":"access-token"}}`)) - case "/v2/bot/query": - _, _ = w.Write([]byte(`{"code":110002,"msg":"参数错误","request_id":"query-rid"}`)) + case tokenPath: + _, _ = w.Write([]byte(accessTokenResponse(testTokenRequestID))) + case queryPath: + _, _ = w.Write([]byte(`{"code":110002,"msg":"bad param","request_id":"query-rid"}`)) default: t.Fatalf("unexpected path: %s", r.URL.Path) } })) defer srv.Close() - cfg := &config.Config{ - AppID: "appid", - Token: "token", - AESKey: testAESKey, - BaseURL: srv.URL, - Cache: cache.NewMemory(), - } - d := NewDialog(&aispeechContext.Context{ - Config: cfg, - AccessTokenContextHandle: NewAccessToken(cfg), - }) + d := newTestDialog(srv.URL) - _, err := d.Query(&QueryRequest{Query: "你好"}) + _, err := d.Query(&QueryRequest{Query: testQuery}) if err == nil { t.Fatal("Query should return error") } @@ -332,11 +223,176 @@ func TestQueryPlainJSONError(t *testing.T) { if !ok { t.Fatalf("Query error should be *APIError but %T", err) } - if apiErr.Code != 110002 || apiErr.RequestID != "query-rid" { + if apiErr.Code != 110002 || apiErr.RequestID != testQueryRequestID { t.Fatalf("bad api error: %+v", apiErr) } } +func newDialogAPITestServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := readBody(t, r) + assertSign(t, r, testToken, body) + switch r.URL.Path { + case tokenPath: + handleDialogToken(t, w, r) + case importJSONPath: + handleDialogImport(t, w, r, body) + case publishPath: + handleDialogPublish(t, w, r, body) + case effectiveProgressPath: + assertToken(t, r) + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"progress-rid","data":{"end_time":"","progress":100,"status":1}}`)) + case fetchAsyncPath: + assertToken(t, r) + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"fetch-rid","data":{"state":2,"msg":"","progress":100,"start":1,"end":2,"url":"","success_skill_info":[{"id":1,"name":"AAA","intents":[{"id":2,"name":"BBB"}]}]}}`)) + case queryPath: + handleDialogQuery(t, w, r, body) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) +} + +func handleDialogToken(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + if r.Header.Get("X-APPID") != testAppID { + t.Fatalf("bad X-APPID: %s", r.Header.Get("X-APPID")) + } + _, _ = w.Write([]byte(accessTokenResponse(testTokenRequestID))) +} + +func handleDialogImport(t *testing.T, w http.ResponseWriter, r *http.Request, body []byte) { + t.Helper() + assertToken(t, r) + var req ImportJSONRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("bad import body: %v", err) + } + if len(req.Data) != 1 || req.Data[0].Skill != "pre-sale" { + t.Fatalf("bad import request: %+v", req) + } + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"import-rid","data":{"task_id":"task-import"}}`)) +} + +func handleDialogPublish(t *testing.T, w http.ResponseWriter, r *http.Request, body []byte) { + t.Helper() + assertToken(t, r) + if len(body) != 0 { + t.Fatalf("publish body should be empty: %s", body) + } + _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"publish-rid","data":{"task_id":"task-publish"}}`)) +} + +func handleDialogQuery(t *testing.T, w http.ResponseWriter, r *http.Request, body []byte) { + t.Helper() + assertToken(t, r) + if !strings.HasPrefix(r.Header.Get("Content-Type"), "text/plain") { + t.Fatalf("bad content type: %s", r.Header.Get("Content-Type")) + } + plain, err := encryptor.Decrypt(testAESKey, string(body)) + if err != nil { + t.Fatalf("decrypt query body error: %v", err) + } + var req QueryRequest + if err = json.Unmarshal(plain, &req); err != nil { + t.Fatalf("bad query body: %v", err) + } + if req.Query != testQuery { + t.Fatalf("bad query: %+v", req) + } + _, _ = w.Write([]byte(encryptQueryResponse(t))) +} + +func assertDialogImportJSON(t *testing.T, d *Dialog) { + t.Helper() + res, err := d.ImportJSON(&ImportJSONRequest{ + Mode: 0, + Data: []BotIntent{{ + Skill: "pre-sale", + Intent: "business-hours", + Disable: false, + Questions: []string{"when are you open"}, + Answers: []string{"9:00-18:00"}, + }}, + }) + if err != nil || res.TaskID != testImportTaskID || res.RequestID != testImportRequest { + t.Fatalf("ImportJSON = %+v, %v", res, err) + } +} + +func assertDialogPublish(t *testing.T, d *Dialog) { + t.Helper() + res, err := d.Publish() + if err != nil || res.TaskID != testPublishTaskID { + t.Fatalf("Publish = %+v, %v", res, err) + } +} + +func assertDialogProgress(t *testing.T, d *Dialog) { + t.Helper() + res, err := d.GetEffectiveProgress(&EffectiveProgressRequest{Env: "online"}) + if err != nil || res.Progress != 100 { + t.Fatalf("GetEffectiveProgress = %+v, %v", res, err) + } +} + +func assertDialogFetchAsync(t *testing.T, d *Dialog) { + t.Helper() + res, err := d.FetchAsync(&FetchAsyncRequest{TaskID: testImportTaskID}) + if err != nil || res.State != 2 || len(res.SuccessSkillInfo) != 1 { + t.Fatalf("FetchAsync = %+v, %v", res, err) + } +} + +func assertDialogQuery(t *testing.T, d *Dialog) { + t.Helper() + res, err := d.Query(&QueryRequest{Query: testQuery, Env: "online"}) + if err != nil || res.Answer != testQueryAnswer || res.RequestID != testQueryRequestID { + t.Fatalf("Query = %+v, %v", res, err) + } +} + +func newTestDialog(baseURL string) *Dialog { + cfg := testDialogConfig(baseURL) + return NewDialog(&aispeechContext.Context{ + Config: cfg, + AccessTokenContextHandle: NewAccessToken(cfg), + }) +} + +func testDialogConfig(baseURL string) *config.Config { + return &config.Config{ + AppID: testAppID, + Token: testToken, + AESKey: testAESKey, + Account: testAccount, + BaseURL: baseURL, + Cache: cache.NewMemory(), + } +} + +func accountAccessTokenResponse(account string) string { + return `{"code":0,"msg":"success","request_id":"rid","data":{"access_token":"` + account + `-token"}}` +} + +func accessTokenResponse(requestID string) string { + return `{"code":0,"msg":"success","request_id":"` + requestID + `","data":{"access_token":"` + testAccessToken + `"}}` +} + +func encryptQueryResponse(t *testing.T) string { + t.Helper() + cipherText, err := encryptor.Encrypt(testAESKey, []byte(queryResponse())) + if err != nil { + t.Fatalf("encrypt response error: %v", err) + } + return cipherText +} + +func queryResponse() string { + return `{"code":0,"msg":"success","request_id":"query-rid","data":{"answer":"hello answer","answer_type":"text","skill_name":"skill","intent_name":"intent","msg_id":"msg","status":"FAQ","slots":[{"name":"n","value":"v","norm":"v"}]}}` +} + func readBody(t *testing.T, r *http.Request) []byte { t.Helper() body, err := io.ReadAll(r.Body) @@ -348,7 +404,7 @@ func readBody(t *testing.T, r *http.Request) []byte { func assertToken(t *testing.T, r *http.Request) { t.Helper() - if r.Header.Get("X-OPENAI-TOKEN") != "access-token" { + if r.Header.Get("X-OPENAI-TOKEN") != testAccessToken { t.Fatalf("bad X-OPENAI-TOKEN: %s", r.Header.Get("X-OPENAI-TOKEN")) } } From dd112063e1a488a83c2a656ad8de6dd17e34c018 Mon Sep 17 00:00:00 2001 From: GargantuaX Date: Fri, 12 Jun 2026 16:54:39 +0800 Subject: [PATCH 3/3] test: satisfy aispeech interfacer lint --- aispeech/dialog/dialog_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aispeech/dialog/dialog_test.go b/aispeech/dialog/dialog_test.go index df094aac6..078fa03cf 100644 --- a/aispeech/dialog/dialog_test.go +++ b/aispeech/dialog/dialog_test.go @@ -254,7 +254,7 @@ func newDialogAPITestServer(t *testing.T) *httptest.Server { })) } -func handleDialogToken(t *testing.T, w http.ResponseWriter, r *http.Request) { +func handleDialogToken(t *testing.T, w io.Writer, r *http.Request) { t.Helper() if r.Header.Get("X-APPID") != testAppID { t.Fatalf("bad X-APPID: %s", r.Header.Get("X-APPID")) @@ -262,7 +262,7 @@ func handleDialogToken(t *testing.T, w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(accessTokenResponse(testTokenRequestID))) } -func handleDialogImport(t *testing.T, w http.ResponseWriter, r *http.Request, body []byte) { +func handleDialogImport(t *testing.T, w io.Writer, r *http.Request, body []byte) { t.Helper() assertToken(t, r) var req ImportJSONRequest @@ -275,7 +275,7 @@ func handleDialogImport(t *testing.T, w http.ResponseWriter, r *http.Request, bo _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"import-rid","data":{"task_id":"task-import"}}`)) } -func handleDialogPublish(t *testing.T, w http.ResponseWriter, r *http.Request, body []byte) { +func handleDialogPublish(t *testing.T, w io.Writer, r *http.Request, body []byte) { t.Helper() assertToken(t, r) if len(body) != 0 { @@ -284,7 +284,7 @@ func handleDialogPublish(t *testing.T, w http.ResponseWriter, r *http.Request, b _, _ = w.Write([]byte(`{"code":0,"msg":"success","request_id":"publish-rid","data":{"task_id":"task-publish"}}`)) } -func handleDialogQuery(t *testing.T, w http.ResponseWriter, r *http.Request, body []byte) { +func handleDialogQuery(t *testing.T, w io.Writer, r *http.Request, body []byte) { t.Helper() assertToken(t, r) if !strings.HasPrefix(r.Header.Get("Content-Type"), "text/plain") {