Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ environment.yaml
temp.go

*.log
docs/
docs/
.gomodcache/
.gocache/
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
10 changes: 10 additions & 0 deletions src/work/agent/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
8 changes: 8 additions & 0 deletions src/work/agent/request/requestAgentSetScope.go
Original file line number Diff line number Diff line change
@@ -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"`
}
9 changes: 9 additions & 0 deletions src/work/agent/response/responseAgentSetScope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package response

import (
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/response"
)

type ResponseAgentSetScope struct {
response.ResponseWork
}
271 changes: 271 additions & 0 deletions src/work/aibot/client.go
Original file line number Diff line number Diff line change
@@ -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, ""))
}
7 changes: 7 additions & 0 deletions src/work/aibot/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package aibot

import "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"

func RegisterProvider(app kernel.ApplicationInterface) (*Client, error) {
return NewClient(app)
}
59 changes: 59 additions & 0 deletions src/work/aibot/request/requestLongConnection.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading