Quellcode durchsuchen

feat: add aispeech dialog APIs (#887)

* feat: add aispeech dialog APIs

* test: fix aispeech lint failures

* test: satisfy aispeech interfacer lint
GargantuaX vor 1 Monat
Ursprung
Commit
2bc22dc4ae

+ 100 - 2
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)
 
-## 快速入门
+## 快速入门
+
+```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
+```

+ 60 - 0
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
+}

+ 61 - 0
aispeech/aispeech_test.go

@@ -0,0 +1,61 @@
+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
+}
+
+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(contextTokenKey{}); 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(), contextTokenKey{}, "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)
+	}
+}

+ 23 - 0
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
+}

+ 15 - 0
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())
+	}
+}

+ 12 - 0
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
+}

+ 116 - 0
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
+}

+ 120 - 0
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
+}

+ 422 - 0
aispeech/dialog/dialog_test.go

@@ -0,0 +1,422 @@
+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 (
+	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{}
+
+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, testToken, body)
+		if r.Header.Get("X-APPID") != testAppID {
+			t.Fatalf("bad X-APPID: %s", r.Header.Get("X-APPID"))
+		}
+		_, _ = w.Write([]byte(accessTokenResponse("rid")))
+	}))
+	defer srv.Close()
+
+	ak := NewAccessToken(&config.Config{
+		AppID:   testAppID,
+		Token:   testToken,
+		Account: testAccount,
+		BaseURL: srv.URL,
+		Cache:   cache.NewMemory(),
+	})
+
+	token, err := ak.GetAccessToken()
+	if err != nil {
+		t.Fatalf("GetAccessToken error: %v", err)
+	}
+	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 != testAccessToken {
+		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, testToken, body)
+
+		var req AccessTokenRequest
+		if err := json.Unmarshal(body, &req); err != nil {
+			t.Fatalf("bad token body: %v", err)
+		}
+		_, _ = w.Write([]byte(accountAccessTokenResponse(req.Account)))
+	}))
+	defer srv.Close()
+
+	memory := cache.NewMemory()
+	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 {
+		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:   testAppID,
+		Token:   testToken,
+		BaseURL: srv.URL,
+		Cache:   cache.NewMemory(),
+	})
+
+	if _, err := ak.GetAccessToken(); err == nil {
+		t.Fatal("GetAccessToken should return error")
+	}
+}
+
+func TestDialogAPIs(t *testing.T) {
+	srv := newDialogAPITestServer(t)
+	defer srv.Close()
+
+	d := newTestDialog(srv.URL)
+	assertDialogImportJSON(t, d)
+	assertDialogPublish(t, d)
+	assertDialogProgress(t, d)
+	assertDialogFetchAsync(t, d)
+	assertDialogQuery(t, d)
+}
+
+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 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()
+
+	d := newTestDialog(srv.URL)
+
+	_, 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 != "forbidden" || apiErr.RequestID != testImportRequest {
+		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 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()
+
+	d := newTestDialog(srv.URL)
+
+	_, err := d.Query(&QueryRequest{Query: testQuery})
+	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 != 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 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"))
+	}
+	_, _ = w.Write([]byte(accessTokenResponse(testTokenRequestID)))
+}
+
+func handleDialogImport(t *testing.T, w io.Writer, 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 io.Writer, 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 io.Writer, 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)
+	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") != testAccessToken {
+		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)
+	}
+}

+ 186 - 0
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)
+	}
+}

+ 48 - 0
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"`
+}

+ 137 - 0
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
+}

+ 89 - 0
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
+}

+ 100 - 0
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
+}

+ 33 - 0
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")
+	}
+}

+ 20 - 0
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))
+}

+ 10 - 1
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

+ 10 - 0
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