diff --git a/.gitignore b/.gitignore index a11581e2..068498b3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ environment.yaml temp.go *.log -docs/ \ No newline at end of file +docs/ +.gomodcache/ +.gocache/ diff --git a/go.mod b/go.mod index e75f38c0..80b97ac5 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 github.com/ArtisanCloud/PowerSocialite/v3 v3.0.10 github.com/go-playground/assert/v2 v2.0.1 + github.com/gorilla/websocket v1.5.3 + github.com/gorilla/websocket v1.5.3 github.com/pkg/errors v0.9.1 github.com/redis/go-redis/v9 v9.6.3 github.com/stretchr/testify v1.9.0 diff --git a/go.sum b/go.sum index 0225f370..84452969 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/src/work/agent/client.go b/src/work/agent/client.go index 45435724..5b7c228b 100644 --- a/src/work/agent/client.go +++ b/src/work/agent/client.go @@ -54,3 +54,13 @@ func (comp *Client) List(ctx context.Context) (*response.ResponseAgentList, erro return result, err } + +// https://developer.work.weixin.qq.com/document/path/90583 +func (comp *Client) SetScope(ctx context.Context, data *request.RequestAgentSetScope) (*response.ResponseAgentSetScope, error) { + + result := &response.ResponseAgentSetScope{} + + _, err := comp.BaseClient.HttpPostJson(ctx, "cgi-bin/agent/set_scope", data, nil, nil, result) + + return result, err +} diff --git a/src/work/agent/request/requestAgentSetScope.go b/src/work/agent/request/requestAgentSetScope.go new file mode 100644 index 00000000..7bbe9d71 --- /dev/null +++ b/src/work/agent/request/requestAgentSetScope.go @@ -0,0 +1,8 @@ +package request + +type RequestAgentSetScope struct { + AgentID int `json:"agentid"` + AllowUser []string `json:"allow_user,omitempty"` + AllowParty []int `json:"allow_party,omitempty"` + AllowTag []int `json:"allow_tag,omitempty"` +} diff --git a/src/work/agent/response/responseAgentSetScope.go b/src/work/agent/response/responseAgentSetScope.go new file mode 100644 index 00000000..41e5133a --- /dev/null +++ b/src/work/agent/response/responseAgentSetScope.go @@ -0,0 +1,9 @@ +package response + +import ( + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/response" +) + +type ResponseAgentSetScope struct { + response.ResponseWork +} diff --git a/src/work/aibot/client.go b/src/work/aibot/client.go new file mode 100644 index 00000000..89d2760c --- /dev/null +++ b/src/work/aibot/client.go @@ -0,0 +1,271 @@ +package aibot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" + "github.com/ArtisanCloud/PowerWeChat/v3/src/work/aibot/request" + "github.com/ArtisanCloud/PowerWeChat/v3/src/work/aibot/response" + "github.com/gorilla/websocket" +) + +const defaultAIBotLongConnEndpoint = "wss://openws.work.weixin.qq.com" + +type MessageHandler func(ctx context.Context, payload *response.ResponseLongConnection) error + +type Client struct { + App kernel.ApplicationInterface + Endpoint string + Dialer *websocket.Dialer + + connMu sync.RWMutex + conn *websocket.Conn + writeMu sync.Mutex +} + +func NewClient(app kernel.ApplicationInterface) (*Client, error) { + if app == nil { + return nil, errors.New("application is nil") + } + endpoint := strings.TrimSpace(readConfigString(app, "aibot.long_connection.url")) + if endpoint == "" { + endpoint = defaultAIBotLongConnEndpoint + } + return &Client{ + App: app, + Endpoint: endpoint, + Dialer: &websocket.Dialer{ + HandshakeTimeout: 8 * time.Second, + }, + }, nil +} + +func (comp *Client) Connect(ctx context.Context) error { + if comp == nil { + return errors.New("aibot client is nil") + } + endpoint := strings.TrimSpace(comp.Endpoint) + if endpoint == "" { + return errors.New("aibot endpoint is empty") + } + dialer := comp.Dialer + if dialer == nil { + dialer = &websocket.Dialer{HandshakeTimeout: 8 * time.Second} + } + conn, _, err := dialer.DialContext(ctx, endpoint, nil) + if err != nil { + return err + } + comp.connMu.Lock() + if comp.conn != nil { + _ = comp.conn.Close() + } + comp.conn = conn + comp.connMu.Unlock() + return nil +} + +func (comp *Client) Close() error { + if comp == nil { + return nil + } + comp.connMu.Lock() + defer comp.connMu.Unlock() + if comp.conn == nil { + return nil + } + err := comp.conn.Close() + comp.conn = nil + return err +} + +func (comp *Client) Subscribe(ctx context.Context, botID, secret string) (*response.ResponseLongConnection, error) { + if comp == nil { + return nil, errors.New("aibot client is nil") + } + botID = strings.TrimSpace(botID) + secret = strings.TrimSpace(secret) + if botID == "" || secret == "" { + return nil, errors.New("botID and secret are required") + } + if err := comp.Connect(ctx); err != nil { + return nil, err + } + cmd := request.NewSubscribe(botID, secret, buildReqID()) + if err := comp.Send(ctx, cmd); err != nil { + return nil, err + } + resp, err := comp.Read(ctx) + if err != nil { + return nil, err + } + if resp != nil && resp.IsError() { + return resp, errors.New(resp.ErrMsg) + } + return resp, nil +} + +func (comp *Client) Send(ctx context.Context, cmd *request.RequestLongConnection) error { + if comp == nil { + return errors.New("aibot client is nil") + } + if cmd == nil { + return errors.New("command is nil") + } + conn, err := comp.mustConn() + if err != nil { + return err + } + if dl, ok := ctx.Deadline(); ok { + _ = conn.SetWriteDeadline(dl) + } + comp.writeMu.Lock() + defer comp.writeMu.Unlock() + return conn.WriteJSON(cmd) +} + +func (comp *Client) Read(ctx context.Context) (*response.ResponseLongConnection, error) { + conn, err := comp.mustConn() + if err != nil { + return nil, err + } + if dl, ok := ctx.Deadline(); ok { + _ = conn.SetReadDeadline(dl) + } + _, raw, err := conn.ReadMessage() + if err != nil { + return nil, err + } + resp := &response.ResponseLongConnection{} + if err := json.Unmarshal(raw, resp); err != nil { + return nil, err + } + return resp, nil +} + +func (comp *Client) Heartbeat(ctx context.Context) error { + cmd := request.NewPing(buildReqID()) + return comp.Send(ctx, cmd) +} + +func (comp *Client) Listen(ctx context.Context, handler MessageHandler) error { + if comp == nil { + return errors.New("aibot client is nil") + } + if handler == nil { + return errors.New("handler is nil") + } + for { + select { + case <-ctx.Done(): + return nil + default: + } + message, err := comp.Read(ctx) + if err != nil { + return err + } + if message == nil { + continue + } + if strings.EqualFold(strings.TrimSpace(message.Cmd), "pong") { + continue + } + if handleErr := handler(ctx, message); handleErr != nil { + return handleErr + } + } +} + +func (comp *Client) SubscribeAndServe(ctx context.Context, botID, secret string, handler MessageHandler) error { + if _, err := comp.Subscribe(ctx, botID, secret); err != nil { + return err + } + + heartbeatTicker := time.NewTicker(30 * time.Second) + defer heartbeatTicker.Stop() + + errCh := make(chan error, 2) + go func() { + errCh <- comp.Listen(ctx, handler) + }() + go func() { + for { + select { + case <-ctx.Done(): + errCh <- nil + return + case <-heartbeatTicker.C: + hbCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + hbErr := comp.Heartbeat(hbCtx) + cancel() + if hbErr != nil { + errCh <- hbErr + return + } + } + } + }() + + select { + case <-ctx.Done(): + _ = comp.Close() + return nil + case err := <-errCh: + _ = comp.Close() + return err + } +} + +func (comp *Client) SendCommand(ctx context.Context, cmd string, body map[string]interface{}) error { + command := request.NewCommand(cmd, buildReqID(), body) + if strings.TrimSpace(command.Cmd) == "" { + return errors.New("command cmd is empty") + } + return comp.Send(ctx, command) +} + +func (comp *Client) RespondWelcomeMessage(ctx context.Context, body map[string]interface{}) error { + return comp.SendCommand(ctx, request.CmdRespondWelcome, body) +} + +func (comp *Client) RespondMessage(ctx context.Context, body map[string]interface{}) error { + return comp.SendCommand(ctx, request.CmdRespondMessage, body) +} + +func (comp *Client) RespondUpdateMessage(ctx context.Context, body map[string]interface{}) error { + return comp.SendCommand(ctx, request.CmdRespondUpdateMsg, body) +} + +func (comp *Client) SendMessage(ctx context.Context, body map[string]interface{}) error { + return comp.SendCommand(ctx, request.CmdSendMessage, body) +} + +func (comp *Client) mustConn() (*websocket.Conn, error) { + if comp == nil { + return nil, errors.New("aibot client is nil") + } + comp.connMu.RLock() + defer comp.connMu.RUnlock() + if comp.conn == nil { + return nil, errors.New("aibot websocket is not connected") + } + return comp.conn, nil +} + +func buildReqID() string { + return fmt.Sprintf("req-%d", time.Now().UnixNano()) +} + +func readConfigString(app kernel.ApplicationInterface, key string) string { + if app == nil || app.GetConfig() == nil { + return "" + } + return strings.TrimSpace(app.GetConfig().GetString(key, "")) +} diff --git a/src/work/aibot/provider.go b/src/work/aibot/provider.go new file mode 100644 index 00000000..83f9ed60 --- /dev/null +++ b/src/work/aibot/provider.go @@ -0,0 +1,7 @@ +package aibot + +import "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" + +func RegisterProvider(app kernel.ApplicationInterface) (*Client, error) { + return NewClient(app) +} diff --git a/src/work/aibot/request/requestLongConnection.go b/src/work/aibot/request/requestLongConnection.go new file mode 100644 index 00000000..25a70263 --- /dev/null +++ b/src/work/aibot/request/requestLongConnection.go @@ -0,0 +1,59 @@ +package request + +import "strings" + +const ( + CmdSubscribe = "aibot_subscribe" + CmdPing = "ping" + CmdRespondWelcome = "aibot_respond_welcome_msg" + CmdRespondMessage = "aibot_respond_msg" + CmdRespondUpdateMsg = "aibot_respond_update_msg" + CmdSendMessage = "aibot_send_msg" +) + +type LongConnectionHeaders struct { + ReqID string `json:"req_id,omitempty"` +} + +type RequestLongConnection struct { + Cmd string `json:"cmd"` + Headers *LongConnectionHeaders `json:"headers,omitempty"` + Body map[string]interface{} `json:"body,omitempty"` +} + +func NewSubscribe(botID, secret, reqID string) *RequestLongConnection { + return &RequestLongConnection{ + Cmd: CmdSubscribe, + Headers: &LongConnectionHeaders{ + ReqID: reqID, + }, + Body: map[string]interface{}{ + "bot_id": botID, + "secret": secret, + }, + } +} + +func NewPing(reqID string) *RequestLongConnection { + return &RequestLongConnection{ + Cmd: CmdPing, + Headers: &LongConnectionHeaders{ + ReqID: reqID, + }, + } +} + +func NewCommand(cmd, reqID string, body map[string]interface{}) *RequestLongConnection { + req := &RequestLongConnection{ + Cmd: strings.TrimSpace(cmd), + } + if reqID != "" { + req.Headers = &LongConnectionHeaders{ + ReqID: reqID, + } + } + if len(body) > 0 { + req.Body = body + } + return req +} diff --git a/src/work/aibot/response/responseLongConnection.go b/src/work/aibot/response/responseLongConnection.go new file mode 100644 index 00000000..7ebefdd1 --- /dev/null +++ b/src/work/aibot/response/responseLongConnection.go @@ -0,0 +1,20 @@ +package response + +type LongConnectionHeaders struct { + ReqID string `json:"req_id,omitempty"` +} + +type ResponseLongConnection struct { + Cmd string `json:"cmd,omitempty"` + Headers *LongConnectionHeaders `json:"headers,omitempty"` + Body map[string]any `json:"body,omitempty"` + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` +} + +func (resp *ResponseLongConnection) IsError() bool { + if resp == nil { + return true + } + return resp.ErrCode != 0 +} diff --git a/src/work/application.go b/src/work/application.go index 62e33529..778a9c4e 100644 --- a/src/work/application.go +++ b/src/work/application.go @@ -16,6 +16,7 @@ import ( tag3 "github.com/ArtisanCloud/PowerWeChat/v3/src/work/accountService/tag" "github.com/ArtisanCloud/PowerWeChat/v3/src/work/agent" "github.com/ArtisanCloud/PowerWeChat/v3/src/work/agent/workbench" + "github.com/ArtisanCloud/PowerWeChat/v3/src/work/aibot" "github.com/ArtisanCloud/PowerWeChat/v3/src/work/auth" "github.com/ArtisanCloud/PowerWeChat/v3/src/work/base" "github.com/ArtisanCloud/PowerWeChat/v3/src/work/corpgroup" @@ -144,6 +145,8 @@ type Work struct { GroupRobot *groupRobot.Client GroupRobotMessenger *groupRobot.Messager + AIBotLongConnection *aibot.Client + IdConvert *idConvert.Client Logger *logger.Logger @@ -380,6 +383,11 @@ func NewWork(config *UserConfig) (*Work, error) { return nil, err } + app.AIBotLongConnection, err = aibot.RegisterProvider(app) + if err != nil { + return nil, err + } + //-------------- register Idconvert -------------- app.IdConvert, err = idConvert.RegisterProvider(app) if err != nil { @@ -513,6 +521,9 @@ func (app *Work) GetComponent(name string) interface{} { case "GroupRobotMessenger": return app.GroupRobotMessenger + case "AIBotLongConnection": + return app.AIBotLongConnection + case "IdConvert": return app.IdConvert