Merge pull request #1501 from trheyi/main
feat(robot): add Weixin integration and enhance existing adapters
This commit is contained in:
commit
1055ad3eaa
58 changed files with 3533 additions and 293 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -77,3 +77,6 @@ registry/manager/DESIGN*.md
|
|||
tai/testdata/
|
||||
agent/sandbox/docs/*.md
|
||||
tai/docs/refactor-registration.md
|
||||
agent/robot/ROBOT-WATCHER-IMPROVEMENT.md
|
||||
agent/robot/ROBOT-IM-INTEGRATION-IMPROVEMENT.md
|
||||
agent/robot/ROBOT-CACHE-IMPROVEMENT.md
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ func TestAPIFullLifecycle(t *testing.T) {
|
|||
assert.Equal(t, 5, status.MaxRunning)
|
||||
|
||||
// 5. List robots
|
||||
listResult, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
listResult, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_001",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
|
|
@ -145,8 +145,8 @@ func TestAPIRobotQueryWithData(t *testing.T) {
|
|||
assert.Equal(t, types.RobotIdle, robot.Status)
|
||||
})
|
||||
|
||||
t.Run("ListRobots filters by team", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
t.Run("ListAllRobots filters by team", func(t *testing.T) {
|
||||
result, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_query",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
|
|
@ -165,9 +165,9 @@ func TestAPIRobotQueryWithData(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("ListRobots pagination works", func(t *testing.T) {
|
||||
t.Run("ListAllRobots pagination works", func(t *testing.T) {
|
||||
// Page 1 with size 1
|
||||
result1, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
result1, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_query",
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
|
|
@ -176,7 +176,7 @@ func TestAPIRobotQueryWithData(t *testing.T) {
|
|||
require.GreaterOrEqual(t, len(result1.Data), 1, "Should have at least 1 robot on page 1")
|
||||
|
||||
// Page 2 with size 1
|
||||
result2, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
result2, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_query",
|
||||
Page: 2,
|
||||
PageSize: 1,
|
||||
|
|
@ -188,8 +188,8 @@ func TestAPIRobotQueryWithData(t *testing.T) {
|
|||
assert.NotEqual(t, result1.Data[0].MemberID, result2.Data[0].MemberID)
|
||||
})
|
||||
|
||||
t.Run("ListRobots filters by keywords", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
t.Run("ListAllRobots filters by keywords", func(t *testing.T) {
|
||||
result, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
Keywords: "robot_api_query_001",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
|
|
@ -205,8 +205,8 @@ func TestAPIRobotQueryWithData(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestListRobotsAutonomousModeFilter tests the autonomous_mode filter
|
||||
func TestListRobotsAutonomousModeFilter(t *testing.T) {
|
||||
// TestListAllRobotsAutonomousModeFilter tests the autonomous_mode filter
|
||||
func TestListAllRobotsAutonomousModeFilter(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
|
@ -224,8 +224,8 @@ func TestListRobotsAutonomousModeFilter(t *testing.T) {
|
|||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("ListRobots returns all robots when autonomous_mode is nil", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
t.Run("ListAllRobots returns all robots when autonomous_mode is nil", func(t *testing.T) {
|
||||
result, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_mode",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
|
|
@ -237,9 +237,9 @@ func TestListRobotsAutonomousModeFilter(t *testing.T) {
|
|||
assert.Equal(t, 3, result.Total)
|
||||
})
|
||||
|
||||
t.Run("ListRobots filters by autonomous_mode=true", func(t *testing.T) {
|
||||
t.Run("ListAllRobots filters by autonomous_mode=true", func(t *testing.T) {
|
||||
autonomousMode := true
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
result, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_mode",
|
||||
AutonomousMode: &autonomousMode,
|
||||
Page: 1,
|
||||
|
|
@ -255,9 +255,9 @@ func TestListRobotsAutonomousModeFilter(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("ListRobots filters by autonomous_mode=false", func(t *testing.T) {
|
||||
t.Run("ListAllRobots filters by autonomous_mode=false", func(t *testing.T) {
|
||||
autonomousMode := false
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
result, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
TeamID: "team_api_mode",
|
||||
AutonomousMode: &autonomousMode,
|
||||
Page: 1,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
dcadapter "github.com/yaoapp/yao/agent/robot/events/integrations/discord"
|
||||
fsadapter "github.com/yaoapp/yao/agent/robot/events/integrations/feishu"
|
||||
"github.com/yaoapp/yao/agent/robot/events/integrations/telegram"
|
||||
weixinadapter "github.com/yaoapp/yao/agent/robot/events/integrations/weixin"
|
||||
"github.com/yaoapp/yao/agent/robot/logger"
|
||||
"github.com/yaoapp/yao/agent/robot/manager"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
|
|
@ -65,6 +66,7 @@ func Start() error {
|
|||
"feishu": fsadapter.NewAdapter(),
|
||||
"dingtalk": dtadapter.NewAdapter(),
|
||||
"discord": dcadapter.NewAdapter(),
|
||||
"weixin": weixinadapter.NewAdapter(),
|
||||
}
|
||||
globalDispatcher = integrations.NewDispatcher(globalManager.Cache(), adapters)
|
||||
if err := globalDispatcher.Start(context.Background()); err != nil {
|
||||
|
|
@ -135,6 +137,16 @@ func getManager() (*manager.Manager, error) {
|
|||
return globalManager, nil
|
||||
}
|
||||
|
||||
// GetManager returns the global manager instance, or nil if not started.
|
||||
func GetManager() *manager.Manager {
|
||||
managerMu.RLock()
|
||||
defer managerMu.RUnlock()
|
||||
if globalManager == nil || !globalManager.IsStarted() {
|
||||
return nil
|
||||
}
|
||||
return globalManager
|
||||
}
|
||||
|
||||
// SetManager sets the global manager instance (for testing)
|
||||
func SetManager(m *manager.Manager) {
|
||||
managerMu.Lock()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package api
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
|
|
@ -54,8 +55,9 @@ func GetRobot(ctx *types.Context, memberID string) (*types.Robot, error) {
|
|||
return robot, nil
|
||||
}
|
||||
|
||||
// ListRobots returns robots with pagination and filtering
|
||||
func ListRobots(ctx *types.Context, query *ListQuery) (*ListResult, error) {
|
||||
// ListAllRobots returns robots with pagination and filtering.
|
||||
// Cache-first with in-memory filtering and pagination; falls back to DB when Manager is not started.
|
||||
func ListAllRobots(ctx *types.Context, query *ListQuery) (*ListResult, error) {
|
||||
if query == nil {
|
||||
query = &ListQuery{}
|
||||
}
|
||||
|
|
@ -63,21 +65,49 @@ func ListRobots(ctx *types.Context, query *ListQuery) (*ListResult, error) {
|
|||
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
// Manager not started, load directly from database
|
||||
return listRobotsFromDB(query)
|
||||
return ListRobotsFromDB(query)
|
||||
}
|
||||
|
||||
// If only teamID specified AND explicitly filtering for autonomous_mode=true, use cache
|
||||
// Cache only contains autonomous_mode=true robots
|
||||
// When autonomous_mode is not specified or false, must query database to include all robots
|
||||
if query.TeamID != "" && query.Status == "" && query.Keywords == "" && query.ClockMode == "" &&
|
||||
query.AutonomousMode != nil && *query.AutonomousMode == true {
|
||||
robots := mgr.Cache().List(query.TeamID)
|
||||
return paginateRobots(robots, query), nil
|
||||
var all []*types.Robot
|
||||
if query.TeamID != "" {
|
||||
all = mgr.Cache().List(query.TeamID)
|
||||
} else {
|
||||
all = mgr.Cache().ListAll()
|
||||
}
|
||||
|
||||
// For complex queries, load from database
|
||||
return listRobotsFromDB(query)
|
||||
filtered := make([]*types.Robot, 0, len(all))
|
||||
for _, r := range all {
|
||||
if matchQuery(r, query) {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
|
||||
return paginateRobots(filtered, query), nil
|
||||
}
|
||||
|
||||
// matchQuery checks whether a robot matches the given query filters.
|
||||
// TeamID filtering is handled upstream (cache.List / cache.ListAll).
|
||||
func matchQuery(r *types.Robot, q *ListQuery) bool {
|
||||
if q.Status != "" && r.Status != q.Status {
|
||||
return false
|
||||
}
|
||||
if q.AutonomousMode != nil && r.AutonomousMode != *q.AutonomousMode {
|
||||
return false
|
||||
}
|
||||
if q.ClockMode != "" {
|
||||
if r.Config == nil || r.Config.Clock == nil || r.Config.Clock.Mode != q.ClockMode {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if q.Keywords != "" {
|
||||
kw := strings.ToLower(q.Keywords)
|
||||
if !strings.Contains(strings.ToLower(r.DisplayName), kw) &&
|
||||
!strings.Contains(strings.ToLower(r.Bio), kw) &&
|
||||
!strings.Contains(strings.ToLower(r.MemberID), kw) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetRobotStatus returns the runtime status of a robot
|
||||
|
|
@ -188,8 +218,14 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) {
|
|||
return types.NewRobotFromMap(map[string]interface{}(records[0]))
|
||||
}
|
||||
|
||||
// listRobotsFromDB loads robots from database with filtering
|
||||
func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
|
||||
// ListRobotsFromDB loads robots from database with filtering.
|
||||
// Exported as a fallback for callers that explicitly need DB queries.
|
||||
func ListRobotsFromDB(query *ListQuery) (*ListResult, error) {
|
||||
if query == nil {
|
||||
query = &ListQuery{}
|
||||
}
|
||||
query.applyDefaults()
|
||||
|
||||
m := model.Select(memberModel)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("model %s not found", memberModel)
|
||||
|
|
@ -306,6 +342,26 @@ func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult {
|
|||
}
|
||||
}
|
||||
|
||||
// ListAutonomousRobots returns autonomous robots from cache.
|
||||
// When teamID is empty, returns all autonomous robots across all teams.
|
||||
func ListAutonomousRobots(teamID string) []*types.Robot {
|
||||
mgr, err := getManager()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if teamID == "" {
|
||||
return mgr.Cache().ListAutonomous()
|
||||
}
|
||||
all := mgr.Cache().List(teamID)
|
||||
robots := make([]*types.Robot, 0, len(all))
|
||||
for _, r := range all {
|
||||
if r.AutonomousMode {
|
||||
robots = append(robots, r)
|
||||
}
|
||||
}
|
||||
return robots
|
||||
}
|
||||
|
||||
// ==================== Robot CRUD API ====================
|
||||
// These functions create, update, and delete robots
|
||||
// They call store layer for persistence and manage cache
|
||||
|
|
@ -414,9 +470,6 @@ func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, e
|
|||
}
|
||||
|
||||
// Refresh cache if manager is running
|
||||
// Use Refresh() which handles autonomous_mode correctly:
|
||||
// - If autonomous_mode=true: adds to cache for scheduling
|
||||
// - If autonomous_mode=false: does not add to cache
|
||||
mgr, err := getManager()
|
||||
if err == nil && mgr != nil {
|
||||
_ = mgr.Cache().Refresh(ctx, req.MemberID)
|
||||
|
|
@ -532,12 +585,9 @@ func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) (
|
|||
}
|
||||
|
||||
// Refresh cache if manager is running
|
||||
// Use Refresh() which handles autonomous_mode correctly:
|
||||
// - If autonomous_mode=true: adds to cache for scheduling
|
||||
// - If autonomous_mode=false: removes from cache
|
||||
mgr, err := getManager()
|
||||
if err == nil && mgr != nil {
|
||||
_ = mgr.Cache().Refresh(ctx, memberID) // Ignore error, database is already saved
|
||||
_ = mgr.Cache().Refresh(ctx, memberID)
|
||||
}
|
||||
|
||||
// Notify integrations of updated robot config
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ func TestGetRobotValidation(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestListRobotsValidation tests parameter validation for ListRobots
|
||||
func TestListRobotsValidation(t *testing.T) {
|
||||
// TestListAllRobotsValidation tests parameter validation for ListAllRobots
|
||||
func TestListAllRobotsValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ func TestListRobotsValidation(t *testing.T) {
|
|||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
t.Run("applies default pagination when query is nil", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, nil)
|
||||
result, err := api.ListAllRobots(ctx, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 1, result.Page)
|
||||
|
|
@ -56,7 +56,7 @@ func TestListRobotsValidation(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("applies default pagination when values are zero", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
result, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
Page: 0,
|
||||
PageSize: 0,
|
||||
})
|
||||
|
|
@ -67,7 +67,7 @@ func TestListRobotsValidation(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("caps pagesize at 100", func(t *testing.T) {
|
||||
result, err := api.ListRobots(ctx, &api.ListQuery{
|
||||
result, err := api.ListAllRobots(ctx, &api.ListQuery{
|
||||
Page: 1,
|
||||
PageSize: 500,
|
||||
})
|
||||
|
|
|
|||
103
agent/robot/api/weixin_qrcode.go
Normal file
103
agent/robot/api/weixin_qrcode.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||
)
|
||||
|
||||
const (
|
||||
qrSessionTTL = 5 * time.Minute
|
||||
maxQRRefreshCount = 3
|
||||
)
|
||||
|
||||
type qrSession struct {
|
||||
qrcode string
|
||||
apiHost string
|
||||
startedAt time.Time
|
||||
refreshes int
|
||||
}
|
||||
|
||||
var (
|
||||
qrSessions = make(map[string]*qrSession)
|
||||
qrSessionsMu sync.Mutex
|
||||
)
|
||||
|
||||
// WeixinQRCodeCreate creates a new QR code session for WeChat login.
|
||||
// Returns the session key and QR code URL.
|
||||
func WeixinQRCodeCreate(apiHost string) (sessionKey, qrcodeURL, qrcodeImg string, err error) {
|
||||
qrcode, qrcodeImgContent, err := weixinapi.GetQRCode(context.Background(), apiHost)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("get QR code: %w", err)
|
||||
}
|
||||
|
||||
sessionKey = uuid.New().String()
|
||||
qrSessionsMu.Lock()
|
||||
qrSessions[sessionKey] = &qrSession{
|
||||
qrcode: qrcode,
|
||||
apiHost: apiHost,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
qrSessionsMu.Unlock()
|
||||
|
||||
return sessionKey, qrcode, qrcodeImgContent, nil
|
||||
}
|
||||
|
||||
// WeixinQRCodePoll polls the QR code status for a given session.
|
||||
func WeixinQRCodePoll(sessionKey string) (status, botToken, accountID, baseURL, userID string, err error) {
|
||||
qrSessionsMu.Lock()
|
||||
session, ok := qrSessions[sessionKey]
|
||||
if !ok {
|
||||
qrSessionsMu.Unlock()
|
||||
return "", "", "", "", "", fmt.Errorf("session not found: %s", sessionKey)
|
||||
}
|
||||
|
||||
if time.Since(session.startedAt) > qrSessionTTL {
|
||||
if session.refreshes < maxQRRefreshCount {
|
||||
session.refreshes++
|
||||
session.startedAt = time.Now()
|
||||
apiHost := session.apiHost
|
||||
qrSessionsMu.Unlock()
|
||||
|
||||
newQR, _, refreshErr := weixinapi.GetQRCode(context.Background(), apiHost)
|
||||
if refreshErr != nil {
|
||||
qrSessionsMu.Lock()
|
||||
delete(qrSessions, sessionKey)
|
||||
qrSessionsMu.Unlock()
|
||||
return "expired", "", "", "", "", nil
|
||||
}
|
||||
|
||||
qrSessionsMu.Lock()
|
||||
if s, ok := qrSessions[sessionKey]; ok {
|
||||
s.qrcode = newQR
|
||||
}
|
||||
qrSessionsMu.Unlock()
|
||||
return "refreshed", "", "", "", "", nil
|
||||
}
|
||||
delete(qrSessions, sessionKey)
|
||||
qrSessionsMu.Unlock()
|
||||
return "expired", "", "", "", "", nil
|
||||
}
|
||||
|
||||
qrcode := session.qrcode
|
||||
apiHost := session.apiHost
|
||||
qrSessionsMu.Unlock()
|
||||
|
||||
resp, err := weixinapi.PollQRStatus(context.Background(), apiHost, qrcode)
|
||||
if err != nil {
|
||||
return "wait", "", "", "", "", nil
|
||||
}
|
||||
|
||||
if resp.Status == "confirmed" {
|
||||
qrSessionsMu.Lock()
|
||||
delete(qrSessions, sessionKey)
|
||||
qrSessionsMu.Unlock()
|
||||
return resp.Status, resp.BotToken, resp.IlinkBotID, resp.BaseURL, resp.UserID, nil
|
||||
}
|
||||
|
||||
return resp.Status, "", "", "", "", nil
|
||||
}
|
||||
2
agent/robot/cache/cache_test.go
vendored
2
agent/robot/cache/cache_test.go
vendored
|
|
@ -39,7 +39,7 @@ func TestCacheLoad(t *testing.T) {
|
|||
|
||||
// Count should be at least 2 (may have other robots in DB)
|
||||
count := c.Count()
|
||||
assert.GreaterOrEqual(t, count, 2, "Should load at least 2 active autonomous robots")
|
||||
assert.GreaterOrEqual(t, count, 2, "Should load at least 2 active robots")
|
||||
|
||||
// Verify first robot
|
||||
robot1 := c.Get("robot_test_sales_001")
|
||||
|
|
|
|||
3
agent/robot/cache/load.go
vendored
3
agent/robot/cache/load.go
vendored
|
|
@ -39,7 +39,7 @@ func SetMemberModel(model string) {
|
|||
}
|
||||
|
||||
// Load loads all active robots from database with pagination
|
||||
// Query: member_type='robot' AND autonomous_mode=true AND status='active'
|
||||
// Query: member_type='robot' AND status='active'
|
||||
func (c *Cache) Load(ctx *types.Context) error {
|
||||
m := model.Select(memberModel)
|
||||
|
||||
|
|
@ -60,7 +60,6 @@ func (c *Cache) Load(ctx *types.Context) error {
|
|||
Select: memberFields,
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_type", Value: "robot"},
|
||||
{Column: "autonomous_mode", Value: true},
|
||||
{Column: "status", Value: "active"},
|
||||
},
|
||||
}, page, pageSize)
|
||||
|
|
|
|||
22
agent/robot/cache/refresh.go
vendored
22
agent/robot/cache/refresh.go
vendored
|
|
@ -32,7 +32,7 @@ var refresher = &refreshState{}
|
|||
func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
|
||||
robot, err := c.LoadByID(ctx, memberID)
|
||||
if err != nil {
|
||||
// If robot not found or no longer autonomous, remove from cache
|
||||
// If robot not found, remove from cache
|
||||
if err == types.ErrRobotNotFound {
|
||||
c.Remove(memberID)
|
||||
return nil
|
||||
|
|
@ -40,12 +40,6 @@ func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Check if robot is still active and autonomous
|
||||
if !robot.AutonomousMode {
|
||||
c.Remove(memberID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update cache
|
||||
c.Add(robot)
|
||||
return nil
|
||||
|
|
@ -117,6 +111,20 @@ func (c *Cache) ListAll() []*types.Robot {
|
|||
return robots
|
||||
}
|
||||
|
||||
// ListAutonomous returns all cached robots with AutonomousMode=true.
|
||||
func (c *Cache) ListAutonomous() []*types.Robot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
robots := make([]*types.Robot, 0, len(c.robots)/2)
|
||||
for _, r := range c.robots {
|
||||
if r.AutonomousMode {
|
||||
robots = append(robots, r)
|
||||
}
|
||||
}
|
||||
return robots
|
||||
}
|
||||
|
||||
// GetByStatus returns robots with the specified status
|
||||
func (c *Cache) GetByStatus(status types.RobotStatus) []*types.Robot {
|
||||
c.mu.RLock()
|
||||
|
|
|
|||
|
|
@ -104,10 +104,14 @@ func (h *robotHandler) handleDelivery(ctx context.Context, ev *eventtypes.Event,
|
|||
for k, v := range payload.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
senderID, _ := payload.Extra["sender_id"].(string)
|
||||
appID, _ := payload.Extra["app_id"].(string)
|
||||
metadata := &MessageMetadata{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Extra: extra,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
SenderID: senderID,
|
||||
AppID: appID,
|
||||
Extra: extra,
|
||||
}
|
||||
if err := reply(ctx, msg, metadata); err != nil {
|
||||
log.Error("delivery handler: integration reply failed channel=%s execution=%s: %v", channel, payload.ExecutionID, err)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ const (
|
|||
ExecCompleted = "robot.exec.completed"
|
||||
ExecFailed = "robot.exec.failed"
|
||||
ExecCancelled = "robot.exec.cancelled"
|
||||
ExecRecovered = "robot.exec.recovered"
|
||||
Delivery = "robot.delivery"
|
||||
Message = "robot.message"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ type Adapter struct {
|
|||
|
||||
// botEntry holds the state for one robot's DingTalk integration.
|
||||
type botEntry struct {
|
||||
robotID string
|
||||
clientID string
|
||||
bot *dtapi.Bot
|
||||
cancelFn context.CancelFunc
|
||||
robotID string
|
||||
clientID string
|
||||
clientSecret string
|
||||
bot *dtapi.Bot
|
||||
cancelFn context.CancelFunc
|
||||
}
|
||||
|
||||
// NewAdapter creates a new DingTalk adapter.
|
||||
|
|
@ -58,7 +59,8 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
|||
defer a.mu.Unlock()
|
||||
|
||||
if existing, ok := a.bots[robot.MemberID]; ok {
|
||||
if existing.clientID == dtConf.ClientID {
|
||||
if existing.clientID == dtConf.ClientID &&
|
||||
existing.clientSecret == dtConf.ClientSecret {
|
||||
return
|
||||
}
|
||||
a.removeBotLocked(robot.MemberID)
|
||||
|
|
@ -68,10 +70,11 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
|||
|
||||
streamCtx, streamCancel := context.WithCancel(context.Background())
|
||||
entry := &botEntry{
|
||||
robotID: robot.MemberID,
|
||||
clientID: dtConf.ClientID,
|
||||
bot: bot,
|
||||
cancelFn: streamCancel,
|
||||
robotID: robot.MemberID,
|
||||
clientID: dtConf.ClientID,
|
||||
clientSecret: dtConf.ClientSecret,
|
||||
bot: bot,
|
||||
cancelFn: streamCancel,
|
||||
}
|
||||
a.bots[robot.MemberID] = entry
|
||||
a.appIdx[dtConf.ClientID] = robot.MemberID
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*dt
|
|||
"session_webhook": lastCM.SessionWebhook,
|
||||
"conversation_type": lastCM.ConversationType,
|
||||
"dt_message_id": lastCM.MessageID,
|
||||
"sender_id": lastCM.SenderID,
|
||||
"app_id": entry.clientID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
|||
defer a.mu.Unlock()
|
||||
|
||||
if existing, ok := a.bots[robot.MemberID]; ok {
|
||||
if existing.bot.Token() == dcConf.BotToken {
|
||||
if existing.bot.Token() == dcConf.BotToken &&
|
||||
existing.appID == dcConf.AppID {
|
||||
return
|
||||
}
|
||||
a.removeBotLocked(robot.MemberID)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*dc
|
|||
"discord_message_id": lastCM.MessageID,
|
||||
"guild_id": lastCM.GuildID,
|
||||
"is_dm": lastCM.IsDM,
|
||||
"sender_id": lastCM.AuthorID,
|
||||
"app_id": entry.appID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
robotcache "github.com/yaoapp/yao/agent/robot/cache"
|
||||
events "github.com/yaoapp/yao/agent/robot/events"
|
||||
|
|
@ -22,6 +20,7 @@ type Adapter interface {
|
|||
Apply(ctx context.Context, robot *robottypes.Robot)
|
||||
Remove(ctx context.Context, robotID string)
|
||||
Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error
|
||||
Shutdown()
|
||||
}
|
||||
|
||||
// Dispatcher distributes Robot integration configs to platform adapters.
|
||||
|
|
@ -48,7 +47,7 @@ func (d *Dispatcher) Start(ctx context.Context) error {
|
|||
|
||||
events.RegisterReplyFunc(d.reply)
|
||||
|
||||
ch := make(chan *eventtypes.Event, 64)
|
||||
ch := make(chan *eventtypes.Event, 256)
|
||||
d.subID = event.Subscribe("robot.config.*", ch)
|
||||
go d.watch(ctx, ch)
|
||||
|
||||
|
|
@ -81,76 +80,29 @@ func (d *Dispatcher) reply(ctx context.Context, msg *agentcontext.Message, metad
|
|||
return lastErr
|
||||
}
|
||||
|
||||
// Stop unsubscribes from events.
|
||||
// Stop unsubscribes from events and shuts down all adapters.
|
||||
func (d *Dispatcher) Stop() {
|
||||
close(d.stopCh)
|
||||
if d.subID != "" {
|
||||
event.Unsubscribe(d.subID)
|
||||
}
|
||||
for name, adapter := range d.adapters {
|
||||
adapter.Shutdown()
|
||||
log.Info("integration dispatcher: adapter %s shutdown", name)
|
||||
}
|
||||
log.Info("integration dispatcher: stopped")
|
||||
}
|
||||
|
||||
func (d *Dispatcher) loadAll(ctx context.Context) {
|
||||
robots := d.loadIntegrationRobots()
|
||||
robots := d.robotCache.ListAll()
|
||||
count := 0
|
||||
for _, robot := range robots {
|
||||
d.robotCache.Add(robot)
|
||||
d.apply(ctx, robot)
|
||||
if robot.Config != nil && robot.Config.Integrations != nil && len(parseIntegrations(robot.Config.Integrations)) > 0 {
|
||||
d.apply(ctx, robot)
|
||||
count++
|
||||
}
|
||||
}
|
||||
log.Info("integration dispatcher: initial load complete, %d robots with integrations", len(robots))
|
||||
}
|
||||
|
||||
// loadIntegrationRobots queries all active robots that have a non-null
|
||||
// robot_config (which may contain integrations). This is independent of
|
||||
// autonomous_mode so non-autonomous robots with Telegram etc. are included.
|
||||
func (d *Dispatcher) loadIntegrationRobots() []*robottypes.Robot {
|
||||
m := model.Select("__yao.member")
|
||||
fields := []interface{}{
|
||||
"id", "member_id", "team_id", "display_name", "bio",
|
||||
"system_prompt", "robot_status", "autonomous_mode",
|
||||
"robot_config", "robot_email", "agents", "mcp_servers",
|
||||
"manager_id", "language_model",
|
||||
}
|
||||
|
||||
page := 1
|
||||
pageSize := 100
|
||||
var result []*robottypes.Robot
|
||||
|
||||
for {
|
||||
res, err := m.Paginate(model.QueryParam{
|
||||
Select: fields,
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_type", Value: "robot"},
|
||||
{Column: "status", Value: "active"},
|
||||
},
|
||||
}, page, pageSize)
|
||||
if err != nil {
|
||||
log.Error("loadIntegrationRobots: query failed page=%d: %v", page, err)
|
||||
break
|
||||
}
|
||||
|
||||
data, ok := res.Get("data").([]maps.MapStr)
|
||||
if !ok || len(data) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, record := range data {
|
||||
robot, err := robottypes.NewRobotFromMap(map[string]interface{}(record))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if robot.Config != nil && robot.Config.Integrations != nil && len(parseIntegrations(robot.Config.Integrations)) > 0 {
|
||||
result = append(result, robot)
|
||||
}
|
||||
}
|
||||
|
||||
total, _ := res.Get("total").(int)
|
||||
if page*pageSize >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return result
|
||||
log.Info("integration dispatcher: initial load complete, %d robots with integrations", count)
|
||||
}
|
||||
|
||||
// apply parses which integrations the robot has configured,
|
||||
|
|
@ -187,6 +139,9 @@ func parseIntegrations(intg *robottypes.Integrations) []string {
|
|||
if intg.Discord != nil {
|
||||
keys = append(keys, "discord")
|
||||
}
|
||||
if intg.Weixin != nil {
|
||||
keys = append(keys, "weixin")
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
robotcache "github.com/yaoapp/yao/agent/robot/cache"
|
||||
events "github.com/yaoapp/yao/agent/robot/events"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/event"
|
||||
eventtypes "github.com/yaoapp/yao/event/types"
|
||||
)
|
||||
|
|
@ -39,6 +40,8 @@ func (m *mockAdapter) Reply(ctx context.Context, msg *agentcontext.Message, meta
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAdapter) Shutdown() {}
|
||||
|
||||
func (m *mockAdapter) getApplied() []*robottypes.Robot {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
|
@ -239,6 +242,13 @@ func TestConfigDeleted_TriggersRemove(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestConfigCreated_RobotNotInCache(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
setupEventBus(t)
|
||||
cache := robotcache.New()
|
||||
|
||||
|
|
@ -248,7 +258,7 @@ func TestConfigCreated_RobotNotInCache(t *testing.T) {
|
|||
require.NoError(t, d.Start(context.Background()))
|
||||
defer d.Stop()
|
||||
|
||||
// Push event but don't add robot to cache
|
||||
// Push event but don't add robot to cache — triggers LoadByID DB fallback
|
||||
event.Push(context.Background(), events.RobotConfigCreated, events.RobotConfigPayload{
|
||||
MemberID: "r-ghost", TeamID: "team1",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ type Adapter struct {
|
|||
|
||||
// botEntry holds the state for one robot's Feishu integration.
|
||||
type botEntry struct {
|
||||
robotID string
|
||||
appID string
|
||||
bot *fsapi.Bot
|
||||
cancelFn context.CancelFunc // cancels the event subscription goroutine
|
||||
robotID string
|
||||
appID string
|
||||
appSecret string
|
||||
bot *fsapi.Bot
|
||||
cancelFn context.CancelFunc // cancels the event subscription goroutine
|
||||
}
|
||||
|
||||
// NewAdapter creates a new Feishu adapter.
|
||||
|
|
@ -58,7 +59,8 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
|||
defer a.mu.Unlock()
|
||||
|
||||
if existing, ok := a.bots[robot.MemberID]; ok {
|
||||
if existing.appID == fsConf.AppID {
|
||||
if existing.appID == fsConf.AppID &&
|
||||
existing.appSecret == fsConf.AppSecret {
|
||||
return
|
||||
}
|
||||
a.removeBotLocked(robot.MemberID)
|
||||
|
|
@ -68,10 +70,11 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
|||
|
||||
streamCtx, streamCancel := context.WithCancel(context.Background())
|
||||
entry := &botEntry{
|
||||
robotID: robot.MemberID,
|
||||
appID: fsConf.AppID,
|
||||
bot: bot,
|
||||
cancelFn: streamCancel,
|
||||
robotID: robot.MemberID,
|
||||
appID: fsConf.AppID,
|
||||
appSecret: fsConf.AppSecret,
|
||||
bot: bot,
|
||||
cancelFn: streamCancel,
|
||||
}
|
||||
a.bots[robot.MemberID] = entry
|
||||
a.appIdx[fsConf.AppID] = robot.MemberID
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*fs
|
|||
Locale: events.NormalizeLocale(lastCM.LanguageCode),
|
||||
Extra: map[string]any{
|
||||
"feishu_message_id": lastCM.MessageID,
|
||||
"sender_id": lastCM.SenderID,
|
||||
"app_id": entry.appID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata
|
|||
}
|
||||
}
|
||||
|
||||
if err := entry.bot.SendTyping(ctx, metadata.ChatID); err != nil {
|
||||
log.Debug("feishu reply: send typing failed: %v", err)
|
||||
}
|
||||
|
||||
return a.sendContent(ctx, entry, metadata.ChatID, replyToMsgID, msg.Content)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*tg
|
|||
Locale: events.NormalizeLocale(lastCM.LanguageCode),
|
||||
Extra: map[string]any{
|
||||
"tg_message_id": lastCM.MessageID,
|
||||
"sender_id": strconv.FormatInt(lastCM.SenderID, 10),
|
||||
"app_id": entry.appID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata
|
|||
return fmt.Errorf("no bot registered for channel metadata (appID=%s)", metadata.AppID)
|
||||
}
|
||||
|
||||
if err := entry.bot.SendTyping(ctx, chatID); err != nil {
|
||||
log.Debug("telegram reply: send typing failed: %v", err)
|
||||
}
|
||||
|
||||
return a.sendContent(ctx, entry.bot, chatID, replyTo, msg.Content)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type Adapter struct {
|
|||
type botEntry struct {
|
||||
robotID string
|
||||
appID string
|
||||
host string
|
||||
bot *tgapi.Bot // bound to this robot's token
|
||||
offset int64 // polling offset
|
||||
}
|
||||
|
|
@ -65,7 +66,9 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
|||
defer a.mu.Unlock()
|
||||
|
||||
if existing, ok := a.bots[robot.MemberID]; ok {
|
||||
if existing.bot.Token() == tgConf.BotToken {
|
||||
if existing.bot.Token() == tgConf.BotToken &&
|
||||
existing.appID == tgConf.AppID &&
|
||||
existing.host == tgConf.Host {
|
||||
return
|
||||
}
|
||||
a.removeBotLocked(robot.MemberID)
|
||||
|
|
@ -78,6 +81,7 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
|||
entry := &botEntry{
|
||||
robotID: robot.MemberID,
|
||||
appID: tgConf.AppID,
|
||||
host: tgConf.Host,
|
||||
bot: tgapi.NewBot(tgConf.BotToken, tgConf.WebhookSecret, opts...),
|
||||
}
|
||||
a.bots[robot.MemberID] = entry
|
||||
|
|
|
|||
44
agent/robot/events/integrations/weixin/dedup.go
Normal file
44
agent/robot/events/integrations/weixin/dedup.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dedupTTL = 24 * time.Hour
|
||||
dedupCleanInterval = time.Hour
|
||||
)
|
||||
|
||||
type dedupStore struct {
|
||||
m sync.Map
|
||||
}
|
||||
|
||||
func newDedupStore() *dedupStore {
|
||||
return &dedupStore{}
|
||||
}
|
||||
|
||||
func (d *dedupStore) markSeen(key string) bool {
|
||||
now := time.Now().Unix()
|
||||
_, loaded := d.m.LoadOrStore(key, now)
|
||||
return !loaded
|
||||
}
|
||||
|
||||
func (d *dedupStore) cleaner(stopCh <-chan struct{}) {
|
||||
ticker := time.NewTicker(dedupCleanInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
cutoff := time.Now().Add(-dedupTTL).Unix()
|
||||
d.m.Range(func(key, value any) bool {
|
||||
if ts, ok := value.(int64); ok && ts < cutoff {
|
||||
d.m.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
179
agent/robot/events/integrations/weixin/message.go
Normal file
179
agent/robot/events/integrations/weixin/message.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||
)
|
||||
|
||||
type resolvedMedia struct {
|
||||
Wrapper string
|
||||
MimeType string
|
||||
FileName string
|
||||
}
|
||||
|
||||
func convertMessage(ctx context.Context, bot *weixinapi.Bot, items []weixinapi.MsgItem, groups []string) (string, []resolvedMedia) {
|
||||
var textBuf strings.Builder
|
||||
var media []resolvedMedia
|
||||
|
||||
for _, item := range items {
|
||||
switch item.Type {
|
||||
case weixinapi.ItemTypeText:
|
||||
if item.TextItem != nil && item.TextItem.Text != "" {
|
||||
text := item.TextItem.Text
|
||||
if item.RefMsg != nil {
|
||||
text = formatRefMessage(item.RefMsg, text)
|
||||
}
|
||||
textBuf.WriteString(text)
|
||||
}
|
||||
case weixinapi.ItemTypeVoice:
|
||||
if item.VoiceItem != nil {
|
||||
if item.VoiceItem.Text != "" {
|
||||
textBuf.WriteString(item.VoiceItem.Text)
|
||||
} else if item.VoiceItem.Media != nil && item.VoiceItem.Media.EncryptQueryParam != "" {
|
||||
m := resolveVoice(ctx, bot, item.VoiceItem, groups)
|
||||
if m != nil {
|
||||
media = append(media, *m)
|
||||
}
|
||||
}
|
||||
}
|
||||
case weixinapi.ItemTypeImage:
|
||||
if item.ImageItem != nil {
|
||||
m := resolveImage(ctx, bot, item.ImageItem, groups)
|
||||
if m != nil {
|
||||
media = append(media, *m)
|
||||
}
|
||||
}
|
||||
case weixinapi.ItemTypeFile:
|
||||
if item.FileItem != nil && item.FileItem.Media != nil && item.FileItem.Media.EncryptQueryParam != "" {
|
||||
m := resolveFile(ctx, bot, item.FileItem, groups)
|
||||
if m != nil {
|
||||
media = append(media, *m)
|
||||
}
|
||||
}
|
||||
case weixinapi.ItemTypeVideo:
|
||||
if item.VideoItem != nil && item.VideoItem.Media != nil && item.VideoItem.Media.EncryptQueryParam != "" {
|
||||
m := resolveVideo(ctx, bot, item.VideoItem, groups)
|
||||
if m != nil {
|
||||
media = append(media, *m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textBuf.String(), media
|
||||
}
|
||||
|
||||
func formatRefMessage(ref *weixinapi.RefMessage, text string) string {
|
||||
if ref == nil || ref.MessageItem == nil {
|
||||
return text
|
||||
}
|
||||
var refBody string
|
||||
if ref.MessageItem.TextItem != nil {
|
||||
refBody = ref.MessageItem.TextItem.Text
|
||||
}
|
||||
title := ref.Title
|
||||
if title == "" && refBody == "" {
|
||||
return text
|
||||
}
|
||||
return fmt.Sprintf("[引用: %s | %s]\n%s", title, refBody, text)
|
||||
}
|
||||
|
||||
func resolveImage(ctx context.Context, bot *weixinapi.Bot, img *weixinapi.ImageItem, groups []string) *resolvedMedia {
|
||||
if img.AesKey != "" && img.Media != nil && img.Media.EncryptQueryParam != "" {
|
||||
rawKey, err := hex.DecodeString(img.AesKey)
|
||||
if err == nil {
|
||||
data, err := weixinapi.DecryptFromRaw(bot.CDNBaseURL(), img.Media.EncryptQueryParam, rawKey)
|
||||
if err == nil {
|
||||
return storeMedia(ctx, data, "image/jpeg", "image.jpg", groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
if img.Media != nil && img.Media.EncryptQueryParam != "" && img.Media.AesKey != "" {
|
||||
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), img.Media.EncryptQueryParam, img.Media.AesKey)
|
||||
if err == nil {
|
||||
return storeMedia(ctx, data, "image/jpeg", "image.jpg", groups)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveVoice(ctx context.Context, bot *weixinapi.Bot, voice *weixinapi.VoiceItem, groups []string) *resolvedMedia {
|
||||
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), voice.Media.EncryptQueryParam, voice.Media.AesKey)
|
||||
if err != nil {
|
||||
log.Error("weixin: voice decrypt failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
mime := "audio/mpeg"
|
||||
ext := "mp3"
|
||||
if voice.EncodeType == 6 {
|
||||
mime = "audio/silk"
|
||||
ext = "silk"
|
||||
}
|
||||
return storeMedia(ctx, data, mime, "voice."+ext, groups)
|
||||
}
|
||||
|
||||
func resolveFile(ctx context.Context, bot *weixinapi.Bot, file *weixinapi.FileItem, groups []string) *resolvedMedia {
|
||||
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), file.Media.EncryptQueryParam, file.Media.AesKey)
|
||||
if err != nil {
|
||||
log.Error("weixin: file decrypt failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
filename := file.FileName
|
||||
if filename == "" {
|
||||
filename = "file.bin"
|
||||
}
|
||||
mime := weixinapi.MimeFromFilename(filename)
|
||||
return storeMedia(ctx, data, mime, filename, groups)
|
||||
}
|
||||
|
||||
func resolveVideo(ctx context.Context, bot *weixinapi.Bot, video *weixinapi.VideoItem, groups []string) *resolvedMedia {
|
||||
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), video.Media.EncryptQueryParam, video.Media.AesKey)
|
||||
if err != nil {
|
||||
log.Error("weixin: video decrypt failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
return storeMedia(ctx, data, "video/mp4", "video.mp4", groups)
|
||||
}
|
||||
|
||||
func storeMedia(ctx context.Context, data []byte, mimeType, filename string, groups []string) *resolvedMedia {
|
||||
manager, exists := attachment.Managers["__yao.attachment"]
|
||||
if !exists {
|
||||
log.Error("weixin: __yao.attachment manager not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
fh := makeFileHeader(filename, mimeType, int64(len(data)))
|
||||
reader := bytes.NewReader(data)
|
||||
file, err := manager.Upload(ctx, fh, reader, attachment.UploadOption{Groups: groups})
|
||||
if err != nil {
|
||||
log.Error("weixin: attachment upload failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &resolvedMedia{
|
||||
Wrapper: fmt.Sprintf("__yao.attachment://%s", file.ID),
|
||||
MimeType: mimeType,
|
||||
FileName: filename,
|
||||
}
|
||||
}
|
||||
|
||||
func makeFileHeader(filename, contentType string, size int64) *attachment.FileHeader {
|
||||
hdr := make(textproto.MIMEHeader)
|
||||
hdr.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
|
||||
hdr.Set("Content-Type", contentType)
|
||||
return &attachment.FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: filename,
|
||||
Header: hdr,
|
||||
Size: size,
|
||||
},
|
||||
}
|
||||
}
|
||||
161
agent/robot/events/integrations/weixin/polling.go
Normal file
161
agent/robot/events/integrations/weixin/polling.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
events "github.com/yaoapp/yao/agent/robot/events"
|
||||
"github.com/yaoapp/yao/event"
|
||||
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConsecutiveFailures = 3
|
||||
backoffDuration = 30 * time.Second
|
||||
retryDuration = 2 * time.Second
|
||||
sessionPauseDuration = 30 * time.Minute
|
||||
defaultTimeoutMs = 35_000
|
||||
)
|
||||
|
||||
func (a *Adapter) pollLoop(ctx context.Context, entry *botEntry) {
|
||||
syncBuf := loadSyncBuf(entry.accountID)
|
||||
nextTimeoutMs := defaultTimeoutMs
|
||||
failures := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
resp, err := entry.bot.GetUpdates(ctx, syncBuf, nextTimeoutMs)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
failures++
|
||||
if failures >= maxConsecutiveFailures {
|
||||
failures = 0
|
||||
sleep(ctx, backoffDuration)
|
||||
} else {
|
||||
sleep(ctx, retryDuration)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.ErrCode == weixinapi.SessionExpiredErrCode || resp.Ret == weixinapi.SessionExpiredErrCode {
|
||||
log.Warn("weixin session expired, pausing %s robot=%s", sessionPauseDuration, entry.robotID)
|
||||
failures = 0
|
||||
sleep(ctx, sessionPauseDuration)
|
||||
continue
|
||||
}
|
||||
|
||||
isApiError := (resp.Ret != 0) || (resp.ErrCode != 0)
|
||||
if isApiError {
|
||||
failures++
|
||||
if failures >= maxConsecutiveFailures {
|
||||
failures = 0
|
||||
sleep(ctx, backoffDuration)
|
||||
} else {
|
||||
sleep(ctx, retryDuration)
|
||||
}
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
|
||||
if resp.LongPollingTimeoutMs > 0 {
|
||||
nextTimeoutMs = resp.LongPollingTimeoutMs
|
||||
}
|
||||
|
||||
if resp.GetUpdatesBuf != "" && resp.GetUpdatesBuf != syncBuf {
|
||||
syncBuf = resp.GetUpdatesBuf
|
||||
saveSyncBuf(entry.accountID, syncBuf)
|
||||
}
|
||||
|
||||
for i := range resp.Msgs {
|
||||
a.handleMessage(ctx, entry, &resp.Msgs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) handleMessage(ctx context.Context, entry *botEntry, msg *weixinapi.WeixinMessage) {
|
||||
var dedupKey string
|
||||
switch {
|
||||
case msg.MessageID != 0:
|
||||
dedupKey = fmt.Sprintf("wx:%s:mid:%d", entry.robotID, msg.MessageID)
|
||||
case msg.Seq != 0:
|
||||
dedupKey = fmt.Sprintf("wx:%s:seq:%d", entry.robotID, msg.Seq)
|
||||
default:
|
||||
dedupKey = fmt.Sprintf("wx:%s:%s:%d", entry.robotID, msg.FromUserID, msg.CreateTimeMs)
|
||||
}
|
||||
if !a.dedup.markSeen(dedupKey) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("incoming msg from=%s context_token=%s", msg.FromUserID, msg.ContextToken)
|
||||
|
||||
groups := []string{"weixin", entry.accountID}
|
||||
content, mediaItems := convertMessage(ctx, entry.bot, msg.ItemList, groups)
|
||||
|
||||
if content == "" && len(mediaItems) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var msgContent interface{}
|
||||
if len(mediaItems) == 0 {
|
||||
msgContent = content
|
||||
} else {
|
||||
parts := make([]interface{}, 0, 1+len(mediaItems))
|
||||
if content != "" {
|
||||
parts = append(parts, map[string]interface{}{"type": "text", "text": content})
|
||||
}
|
||||
for _, m := range mediaItems {
|
||||
parts = append(parts, map[string]interface{}{
|
||||
"type": "file",
|
||||
"file_url": m.Wrapper,
|
||||
"mime_type": m.MimeType,
|
||||
"file_name": m.FileName,
|
||||
})
|
||||
}
|
||||
msgContent = parts
|
||||
}
|
||||
|
||||
messageID := ""
|
||||
if msg.MessageID != 0 {
|
||||
messageID = fmt.Sprintf("%d", msg.MessageID)
|
||||
}
|
||||
|
||||
payload := events.MessagePayload{
|
||||
RobotID: entry.robotID,
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: msgContent},
|
||||
},
|
||||
Metadata: &events.MessageMetadata{
|
||||
Channel: "weixin",
|
||||
MessageID: messageID,
|
||||
AppID: entry.accountID,
|
||||
ChatID: msg.FromUserID,
|
||||
SenderID: msg.FromUserID,
|
||||
Locale: "zh-cn",
|
||||
Extra: map[string]any{
|
||||
"context_token": msg.ContextToken,
|
||||
"sender_id": msg.FromUserID,
|
||||
"app_id": entry.accountID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := event.Push(ctx, events.Message, payload); err != nil {
|
||||
log.Error("weixin adapter: event.Push failed robot=%s: %v", entry.robotID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, d time.Duration) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
305
agent/robot/events/integrations/weixin/reply.go
Normal file
305
agent/robot/events/integrations/weixin/reply.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
events "github.com/yaoapp/yao/agent/robot/events"
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||
)
|
||||
|
||||
func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error {
|
||||
if msg == nil || metadata == nil {
|
||||
return fmt.Errorf("weixin Reply: nil message or metadata")
|
||||
}
|
||||
|
||||
entry := a.resolveByAccountID(metadata.AppID)
|
||||
if entry == nil {
|
||||
a.mu.RLock()
|
||||
for _, e := range a.bots {
|
||||
entry = e
|
||||
break
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
}
|
||||
if entry == nil {
|
||||
return fmt.Errorf("weixin Reply: no bot registered (appID=%s)", metadata.AppID)
|
||||
}
|
||||
|
||||
contextToken, _ := metadata.Extra["context_token"].(string)
|
||||
toUserID := metadata.SenderID
|
||||
if toUserID == "" {
|
||||
toUserID = metadata.ChatID
|
||||
}
|
||||
|
||||
ticket := entry.ticketCache.Get(toUserID)
|
||||
if ticket == "" {
|
||||
if t, err := entry.bot.GetConfig(ctx, toUserID, contextToken); err == nil && t != "" {
|
||||
ticket = t
|
||||
entry.ticketCache.Set(toUserID, ticket)
|
||||
}
|
||||
}
|
||||
|
||||
if ticket != "" {
|
||||
_ = entry.bot.SendTyping(ctx, toUserID, ticket, 1)
|
||||
}
|
||||
|
||||
return a.sendContent(ctx, entry, toUserID, contextToken, msg.Content)
|
||||
}
|
||||
|
||||
func (a *Adapter) sendContent(ctx context.Context, entry *botEntry, toUserID, contextToken string, content interface{}) error {
|
||||
switch c := content.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(c) == "" {
|
||||
return nil
|
||||
}
|
||||
return entry.bot.SendMessage(ctx, toUserID, contextToken, weixinapi.FormatWeixinText(c))
|
||||
|
||||
case []interface{}:
|
||||
return a.sendParts(ctx, entry, toUserID, contextToken, c)
|
||||
|
||||
default:
|
||||
parts, ok := toContentParts(content)
|
||||
if ok {
|
||||
return a.sendPartsTyped(ctx, entry, toUserID, contextToken, parts)
|
||||
}
|
||||
return entry.bot.SendMessage(ctx, toUserID, contextToken, weixinapi.FormatWeixinText(fmt.Sprintf("%v", content)))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) sendParts(ctx context.Context, entry *botEntry, toUserID, contextToken string, parts []interface{}) error {
|
||||
var textBuf strings.Builder
|
||||
for _, part := range parts {
|
||||
m, ok := part.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
partType, _ := m["type"].(string)
|
||||
switch partType {
|
||||
case "text":
|
||||
if text, ok := m["text"].(string); ok {
|
||||
textBuf.WriteString(text)
|
||||
}
|
||||
case "image_url":
|
||||
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
if imgMap, ok := m["image_url"].(map[string]interface{}); ok {
|
||||
if url, ok := imgMap["url"].(string); ok {
|
||||
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, url, "", "image"); err != nil {
|
||||
log.Error("weixin reply: send image: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "file":
|
||||
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
fileURL, _ := m["file_url"].(string)
|
||||
fileName, _ := m["file_name"].(string)
|
||||
mimeType, _ := m["mime_type"].(string)
|
||||
if fileURL == "" {
|
||||
if fileMap, ok := m["file"].(map[string]interface{}); ok {
|
||||
fileURL, _ = fileMap["url"].(string)
|
||||
if fileName == "" {
|
||||
fileName, _ = fileMap["filename"].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
if fileURL != "" {
|
||||
mediaHint := detectMediaHint(mimeType, fileName)
|
||||
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, fileURL, fileName, mediaHint); err != nil {
|
||||
log.Error("weixin reply: send file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.flushText(ctx, entry, toUserID, contextToken, &textBuf)
|
||||
}
|
||||
|
||||
func (a *Adapter) sendPartsTyped(ctx context.Context, entry *botEntry, toUserID, contextToken string, parts []agentcontext.ContentPart) error {
|
||||
var textBuf strings.Builder
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case agentcontext.ContentText:
|
||||
textBuf.WriteString(part.Text)
|
||||
case agentcontext.ContentImageURL:
|
||||
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
if part.ImageURL != nil {
|
||||
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, part.ImageURL.URL, "", "image"); err != nil {
|
||||
log.Error("weixin reply: send image: %v", err)
|
||||
}
|
||||
}
|
||||
case agentcontext.ContentFile:
|
||||
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
if part.File != nil {
|
||||
mediaHint := detectMediaHint("", part.File.Filename)
|
||||
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, part.File.URL, part.File.Filename, mediaHint); err != nil {
|
||||
log.Error("weixin reply: send file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.flushText(ctx, entry, toUserID, contextToken, &textBuf)
|
||||
}
|
||||
|
||||
func (a *Adapter) flushText(ctx context.Context, entry *botEntry, toUserID, contextToken string, buf *strings.Builder) error {
|
||||
if buf.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
text := weixinapi.FormatWeixinText(buf.String())
|
||||
buf.Reset()
|
||||
return entry.bot.SendMessage(ctx, toUserID, contextToken, text)
|
||||
}
|
||||
|
||||
func (a *Adapter) sendMediaFromURL(ctx context.Context, entry *botEntry, toUserID, contextToken, fileURL, fileName, mediaHint string) error {
|
||||
log.Info("weixin sendMedia: to=%s url=%s fileName=%q hint=%s contextToken_len=%d",
|
||||
toUserID, fileURL, fileName, mediaHint, len(contextToken))
|
||||
|
||||
var plaintext []byte
|
||||
var contentType string
|
||||
|
||||
if isWrapper(fileURL) {
|
||||
managerName, fileID, err := parseWrapper(fileURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("weixin sendMedia: wrapper manager=%s fileID=%s", managerName, fileID)
|
||||
manager, exists := attachment.Managers[managerName]
|
||||
if !exists {
|
||||
return fmt.Errorf("attachment manager %s not found", managerName)
|
||||
}
|
||||
resp, err := manager.Download(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("attachment download %s: %w", fileID, err)
|
||||
}
|
||||
defer resp.Reader.Close()
|
||||
plaintext, err = io.ReadAll(resp.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", fileID, err)
|
||||
}
|
||||
contentType = resp.ContentType
|
||||
if fileName == "" {
|
||||
fileName = fileID + resp.Extension
|
||||
}
|
||||
log.Info("weixin sendMedia: attachment downloaded bytes=%d contentType=%q fileName=%q", len(plaintext), contentType, fileName)
|
||||
} else if strings.HasPrefix(fileURL, "http") {
|
||||
resp, err := http.Get(fileURL) //nolint:gosec
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %s: %w", fileURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: HTTP %d", fileURL, resp.StatusCode)
|
||||
}
|
||||
plaintext, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", fileURL, err)
|
||||
}
|
||||
contentType = resp.Header.Get("Content-Type")
|
||||
log.Info("weixin sendMedia: http downloaded bytes=%d contentType=%q", len(plaintext), contentType)
|
||||
} else {
|
||||
return fmt.Errorf("unsupported URL scheme: %s", fileURL)
|
||||
}
|
||||
|
||||
if mediaHint == "" {
|
||||
mediaHint = detectMediaHint(contentType, fileName)
|
||||
}
|
||||
|
||||
var mediaType int
|
||||
switch mediaHint {
|
||||
case "image":
|
||||
mediaType = weixinapi.UploadMediaImage
|
||||
case "video":
|
||||
mediaType = weixinapi.UploadMediaVideo
|
||||
default:
|
||||
mediaType = weixinapi.UploadMediaFile
|
||||
}
|
||||
|
||||
log.Info("weixin sendMedia: uploading media_type=%d mediaHint=%s bytes=%d to=%s", mediaType, mediaHint, len(plaintext), toUserID)
|
||||
uploaded, err := entry.bot.UploadMedia(ctx, plaintext, toUserID, mediaType)
|
||||
if err != nil {
|
||||
log.Error("weixin UploadMedia failed: media_type=%d mediaHint=%s bytes=%d to=%s err=%v", mediaType, mediaHint, len(plaintext), toUserID, err)
|
||||
fallbackText := fileURL
|
||||
if fileName != "" {
|
||||
fallbackText = fileName + "\n" + fileURL
|
||||
}
|
||||
return entry.bot.SendMessage(ctx, toUserID, contextToken, fallbackText)
|
||||
}
|
||||
|
||||
switch mediaHint {
|
||||
case "image":
|
||||
return entry.bot.SendImageMessage(ctx, toUserID, contextToken, uploaded)
|
||||
case "video":
|
||||
return entry.bot.SendVideoMessage(ctx, toUserID, contextToken, uploaded)
|
||||
default:
|
||||
if fileName == "" {
|
||||
fileName = "file.bin"
|
||||
}
|
||||
return entry.bot.SendFileMessage(ctx, toUserID, contextToken, fileName, uploaded)
|
||||
}
|
||||
}
|
||||
|
||||
func detectMediaHint(mimeType, fileName string) string {
|
||||
lower := strings.ToLower(mimeType)
|
||||
if strings.HasPrefix(lower, "image/") {
|
||||
return "image"
|
||||
}
|
||||
if strings.HasPrefix(lower, "video/") {
|
||||
return "video"
|
||||
}
|
||||
// TODO(weixin-voice): audio/* detected as "file" because iLink Bot voice
|
||||
// playback is not yet functional. Switch to "voice" once supported.
|
||||
if fileName != "" {
|
||||
ext := strings.ToLower(fileName)
|
||||
if strings.HasSuffix(ext, ".jpg") || strings.HasSuffix(ext, ".jpeg") ||
|
||||
strings.HasSuffix(ext, ".png") || strings.HasSuffix(ext, ".gif") ||
|
||||
strings.HasSuffix(ext, ".webp") || strings.HasSuffix(ext, ".bmp") {
|
||||
return "image"
|
||||
}
|
||||
if strings.HasSuffix(ext, ".mp4") || strings.HasSuffix(ext, ".mov") ||
|
||||
strings.HasSuffix(ext, ".avi") || strings.HasSuffix(ext, ".webm") {
|
||||
return "video"
|
||||
}
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
|
||||
func isWrapper(url string) bool {
|
||||
return strings.Contains(url, "://") && !strings.HasPrefix(url, "http")
|
||||
}
|
||||
|
||||
func parseWrapper(wrapper string) (managerName, fileID string, err error) {
|
||||
idx := strings.Index(wrapper, "://")
|
||||
if idx < 0 {
|
||||
return "", "", fmt.Errorf("invalid wrapper: %s", wrapper)
|
||||
}
|
||||
return wrapper[:idx], wrapper[idx+3:], nil
|
||||
}
|
||||
|
||||
func toContentParts(content interface{}) ([]agentcontext.ContentPart, bool) {
|
||||
parts, ok := content.([]agentcontext.ContentPart)
|
||||
return parts, ok
|
||||
}
|
||||
|
||||
func (a *Adapter) resolveByAccountID(accountID string) *botEntry {
|
||||
if accountID == "" {
|
||||
return nil
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
robotID, ok := a.accountIdx[accountID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return a.bots[robotID]
|
||||
}
|
||||
44
agent/robot/events/integrations/weixin/syncbuf.go
Normal file
44
agent/robot/events/integrations/weixin/syncbuf.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
)
|
||||
|
||||
type syncBufData struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
}
|
||||
|
||||
func syncBufPath(accountID string) string {
|
||||
root := application.App.Root()
|
||||
return filepath.Join(root, "data", "weixin", accountID+".sync.json")
|
||||
}
|
||||
|
||||
func loadSyncBuf(accountID string) string {
|
||||
p := syncBufPath(accountID)
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var buf syncBufData
|
||||
if err := json.Unmarshal(data, &buf); err != nil {
|
||||
return ""
|
||||
}
|
||||
return buf.GetUpdatesBuf
|
||||
}
|
||||
|
||||
func saveSyncBuf(accountID, syncBuf string) {
|
||||
p := syncBufPath(accountID)
|
||||
dir := filepath.Dir(p)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
log.Error("weixin: mkdir for syncbuf: %v", err)
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(syncBufData{GetUpdatesBuf: syncBuf})
|
||||
if err := os.WriteFile(p, data, 0644); err != nil {
|
||||
log.Error("weixin: write syncbuf: %v", err)
|
||||
}
|
||||
}
|
||||
43
agent/robot/events/integrations/weixin/typing_cache.go
Normal file
43
agent/robot/events/integrations/weixin/typing_cache.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ticketTTL = 20 * time.Hour
|
||||
|
||||
type ticketEntry struct {
|
||||
ticket string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type typingTicketCache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]*ticketEntry
|
||||
}
|
||||
|
||||
func newTypingTicketCache() *typingTicketCache {
|
||||
return &typingTicketCache{
|
||||
items: make(map[string]*ticketEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *typingTicketCache) Get(userID string) string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
entry, ok := c.items[userID]
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
return ""
|
||||
}
|
||||
return entry.ticket
|
||||
}
|
||||
|
||||
func (c *typingTicketCache) Set(userID, ticket string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.items[userID] = &ticketEntry{
|
||||
ticket: ticket,
|
||||
expiresAt: time.Now().Add(ticketTTL),
|
||||
}
|
||||
}
|
||||
130
agent/robot/events/integrations/weixin/weixin.go
Normal file
130
agent/robot/events/integrations/weixin/weixin.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/logger"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||
)
|
||||
|
||||
var log = logger.New("weixin")
|
||||
|
||||
type Adapter struct {
|
||||
mu sync.RWMutex
|
||||
bots map[string]*botEntry
|
||||
accountIdx map[string]string // accountID(ilink_bot_id) -> robotID
|
||||
dedup *dedupStore
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
type botEntry struct {
|
||||
robotID string
|
||||
accountID string
|
||||
bot *weixinapi.Bot
|
||||
cancelFn context.CancelFunc
|
||||
ticketCache *typingTicketCache
|
||||
}
|
||||
|
||||
func NewAdapter() *Adapter {
|
||||
a := &Adapter{
|
||||
bots: make(map[string]*botEntry),
|
||||
accountIdx: make(map[string]string),
|
||||
dedup: newDedupStore(),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go a.dedup.cleaner(a.stopCh)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
||||
conf := extractConfig(robot)
|
||||
if conf == nil || !conf.Enabled || conf.BotToken == "" {
|
||||
a.removeBot(robot.MemberID)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if existing, ok := a.bots[robot.MemberID]; ok {
|
||||
if existing.bot.Token() == conf.BotToken &&
|
||||
existing.bot.BaseURL() == resolveBaseURL(conf) &&
|
||||
existing.bot.CDNBaseURL() == resolveCDNBaseURL(conf) {
|
||||
return
|
||||
}
|
||||
a.removeBotLocked(robot.MemberID)
|
||||
}
|
||||
|
||||
pollCtx, cancel := context.WithCancel(context.Background())
|
||||
entry := &botEntry{
|
||||
robotID: robot.MemberID,
|
||||
accountID: conf.AccountID,
|
||||
bot: weixinapi.NewBot(conf.BotToken, resolveBaseURL(conf), resolveCDNBaseURL(conf)),
|
||||
cancelFn: cancel,
|
||||
ticketCache: newTypingTicketCache(),
|
||||
}
|
||||
a.bots[robot.MemberID] = entry
|
||||
if conf.AccountID != "" {
|
||||
a.accountIdx[conf.AccountID] = robot.MemberID
|
||||
}
|
||||
go a.pollLoop(pollCtx, entry)
|
||||
|
||||
log.Info("weixin adapter: registered robot=%s accountID=%s", robot.MemberID, conf.AccountID)
|
||||
}
|
||||
|
||||
func (a *Adapter) Remove(ctx context.Context, robotID string) {
|
||||
a.removeBot(robotID)
|
||||
}
|
||||
|
||||
func (a *Adapter) Shutdown() {
|
||||
close(a.stopCh)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for id := range a.bots {
|
||||
a.removeBotLocked(id)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) removeBot(robotID string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.removeBotLocked(robotID)
|
||||
}
|
||||
|
||||
func (a *Adapter) removeBotLocked(robotID string) {
|
||||
entry, ok := a.bots[robotID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry.cancelFn()
|
||||
if entry.accountID != "" {
|
||||
delete(a.accountIdx, entry.accountID)
|
||||
}
|
||||
delete(a.bots, robotID)
|
||||
}
|
||||
|
||||
func resolveBaseURL(conf *robottypes.WeixinConfig) string {
|
||||
if conf.APIHost != "" {
|
||||
return conf.APIHost
|
||||
}
|
||||
if conf.BaseURL != "" {
|
||||
return conf.BaseURL
|
||||
}
|
||||
return weixinapi.DefaultBaseURL()
|
||||
}
|
||||
|
||||
func resolveCDNBaseURL(conf *robottypes.WeixinConfig) string {
|
||||
if conf.CDNBaseURL != "" {
|
||||
return conf.CDNBaseURL
|
||||
}
|
||||
return weixinapi.DefaultCDNBaseURL()
|
||||
}
|
||||
|
||||
func extractConfig(robot *robottypes.Robot) *robottypes.WeixinConfig {
|
||||
if robot.Config == nil || robot.Config.Integrations == nil {
|
||||
return nil
|
||||
}
|
||||
return robot.Config.Integrations.Weixin
|
||||
}
|
||||
|
|
@ -64,6 +64,8 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify robot is loaded into cache
|
||||
robot := m.Cache().Get("robot_integ_clock_times1")
|
||||
require.NotNil(t, robot, "Robot should be loaded into cache")
|
||||
|
|
@ -99,6 +101,7 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at 10:30 (not configured)
|
||||
|
|
@ -130,6 +133,7 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at 09:00 on Saturday (not configured)
|
||||
|
|
@ -161,6 +165,8 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at 09:00 on Saturday
|
||||
|
|
@ -192,6 +198,7 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
exec.Reset()
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
|
|
@ -239,12 +246,14 @@ func TestIntegrationClockIntervalMode(t *testing.T) {
|
|||
"every": "30m",
|
||||
})
|
||||
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
m, exec := createClockTestManager(t, 10*time.Second, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
|
@ -271,6 +280,8 @@ func TestIntegrationClockIntervalMode(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
|
@ -305,12 +316,14 @@ func TestIntegrationClockIntervalMode(t *testing.T) {
|
|||
"every": "1h", // Long interval
|
||||
})
|
||||
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
m, exec := createClockTestManager(t, 10*time.Second, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
|
@ -360,6 +373,8 @@ func TestIntegrationClockDaemonMode(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
|
@ -385,6 +400,8 @@ func TestIntegrationClockDaemonMode(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
|
@ -433,6 +450,8 @@ func TestIntegrationClockTimezone(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
|
@ -471,6 +490,8 @@ func TestIntegrationClockTimezone(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
|
@ -543,6 +564,8 @@ func TestIntegrationClockEdgeCases(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer mgr.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at matching time
|
||||
|
|
@ -597,6 +620,8 @@ func TestIntegrationClockEdgeCases(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer mgr.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at matching time
|
||||
|
|
@ -647,6 +672,8 @@ func TestIntegrationClockEdgeCases(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer mgr.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ func TestIntegrationConcurrentExecution(t *testing.T) {
|
|||
)
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 5, QueueSize: 50},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -112,7 +112,7 @@ func TestIntegrationConcurrentExecution(t *testing.T) {
|
|||
exec := executor.NewDryRunWithDelay(50 * time.Millisecond)
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 5, QueueSize: 50},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -176,7 +176,7 @@ func TestIntegrationQuotaEnforcement(t *testing.T) {
|
|||
)
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 10, QueueSize: 50}, // Many workers
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -213,7 +213,7 @@ func TestIntegrationQuotaEnforcement(t *testing.T) {
|
|||
exec := executor.NewDryRunWithDelay(300 * time.Millisecond) // Slow execution
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 10, QueueSize: 100},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -282,7 +282,7 @@ func TestIntegrationQuotaEnforcement(t *testing.T) {
|
|||
}
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 10, QueueSize: 50},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -353,7 +353,7 @@ func TestIntegrationGlobalPoolLimit(t *testing.T) {
|
|||
)
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 100}, // Only 3 workers
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -394,7 +394,7 @@ func TestIntegrationGlobalPoolLimit(t *testing.T) {
|
|||
exec := executor.NewDryRunWithDelay(500 * time.Millisecond) // Slow execution
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 1, QueueSize: 5}, // Small queue
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -456,7 +456,7 @@ func TestIntegrationPriorityExecution(t *testing.T) {
|
|||
}
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 1, QueueSize: 50}, // Single worker for ordering
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -466,6 +466,8 @@ func TestIntegrationPriorityExecution(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Submit in low-to-high priority order
|
||||
|
|
@ -505,7 +507,7 @@ func TestIntegrationPriorityExecution(t *testing.T) {
|
|||
}
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 1, QueueSize: 50},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -515,6 +517,8 @@ func TestIntegrationPriorityExecution(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Submit clock first, then human
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ func TestIntegrationSchedulingFlow(t *testing.T) {
|
|||
// Setup: Create a robot with times mode clock config
|
||||
setupIntegrationRobotTimes(t, "robot_integ_flow_clock", "team_integ_flow")
|
||||
|
||||
// Create manager with fast tick interval for testing
|
||||
// Create manager with slow tick interval to avoid auto-tick interference
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 5, QueueSize: 50},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
|
@ -126,7 +126,7 @@ func TestIntegrationSchedulingFlow(t *testing.T) {
|
|||
assert.Nil(t, robot, "Inactive robot should not be loaded")
|
||||
})
|
||||
|
||||
t.Run("robot with autonomous_mode=false not loaded", func(t *testing.T) {
|
||||
t.Run("robot with autonomous_mode=false is loaded after full cache", func(t *testing.T) {
|
||||
// Setup: Create a robot with autonomous_mode=false
|
||||
setupIntegrationRobotNonAutonomous(t, "robot_integ_flow_nonauto", "team_integ_flow")
|
||||
|
||||
|
|
@ -135,9 +135,12 @@ func TestIntegrationSchedulingFlow(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
// Non-autonomous robot should not be in cache
|
||||
// After full-cache load, non-autonomous active robots should also be in cache
|
||||
robot := m.Cache().Get("robot_integ_flow_nonauto")
|
||||
assert.Nil(t, robot, "Non-autonomous robot should not be loaded")
|
||||
assert.NotNil(t, robot, "Non-autonomous active robot should be loaded in cache after full load")
|
||||
if robot != nil {
|
||||
assert.False(t, robot.AutonomousMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -243,7 +246,7 @@ func TestIntegrationPhaseProgression(t *testing.T) {
|
|||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 20},
|
||||
Executor: exec,
|
||||
}
|
||||
|
|
@ -252,6 +255,8 @@ func TestIntegrationPhaseProgression(t *testing.T) {
|
|||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Trigger execution
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
_, err = m.TriggerManual(ctx, "robot_integ_phases_clock", types.TriggerClock, nil)
|
||||
|
|
@ -284,7 +289,7 @@ func TestIntegrationPhaseProgression(t *testing.T) {
|
|||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
TickInterval: 10 * time.Second,
|
||||
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 20},
|
||||
Executor: exec,
|
||||
}
|
||||
|
|
@ -293,6 +298,8 @@ func TestIntegrationPhaseProgression(t *testing.T) {
|
|||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Trigger execution via human trigger
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
_, err = m.TriggerManual(ctx, "robot_integ_phases_human", types.TriggerHuman, nil)
|
||||
|
|
@ -595,17 +602,28 @@ func setupIntegrationRobotNonAutonomous(t *testing.T, memberID, teamID string) {
|
|||
}
|
||||
}
|
||||
|
||||
// cleanupIntegrationRobots removes all integration test robots
|
||||
// cleanupIntegrationRobots removes all integration test robots and their
|
||||
// non-terminal execution records to prevent recovery interference.
|
||||
func cleanupIntegrationRobots(t *testing.T) {
|
||||
qb := capsule.Query()
|
||||
|
||||
// Clean up execution records for integration robots to prevent
|
||||
// recoverExecutions from picking them up during Start().
|
||||
execModel := model.Select("__yao.agent.execution")
|
||||
if execModel != nil {
|
||||
_, err := qb.Table(execModel.MetaData.Table.Name).
|
||||
Where("member_id", "like", "robot_integ_%").
|
||||
WhereIn("status", []interface{}{"running", "paused", "pending", "waiting", "confirming"}).
|
||||
Delete()
|
||||
if err != nil {
|
||||
t.Logf("Warning: execution cleanup error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
m := model.Select("__yao.member")
|
||||
tableName := m.MetaData.Table.Name
|
||||
|
||||
// Delete all robots with member_id starting with "robot_integ_"
|
||||
// Using LIKE pattern for cleanup
|
||||
_, err := qb.Table(tableName).Where("member_id", "like", "robot_integ_%").Delete()
|
||||
if err != nil {
|
||||
// Log but don't fail - cleanup errors are not critical
|
||||
t.Logf("Warning: cleanup error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/cache"
|
||||
"github.com/yaoapp/yao/agent/robot/events"
|
||||
"github.com/yaoapp/yao/agent/robot/executor"
|
||||
"github.com/yaoapp/yao/agent/robot/pool"
|
||||
"github.com/yaoapp/yao/agent/robot/trigger"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/event"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
|
|
@ -138,6 +140,9 @@ func (m *Manager) Start() error {
|
|||
return fmt.Errorf("failed to load robots: %w", err)
|
||||
}
|
||||
|
||||
// Recover non-terminal executions from previous server lifecycle
|
||||
pendingNotifications := m.recoverExecutions(m.ctx)
|
||||
|
||||
// Set completion callback to clean up ExecutionController when execution finishes
|
||||
m.pool.SetOnComplete(func(execID, memberID string, status types.ExecStatus) {
|
||||
// Remove from ExecutionController (cleans up in-memory tracking)
|
||||
|
|
@ -163,6 +168,15 @@ func (m *Manager) Start() error {
|
|||
m.cache.StartAutoRefresh(ctx, nil)
|
||||
|
||||
m.started = true
|
||||
|
||||
if len(pendingNotifications) > 0 {
|
||||
go func() {
|
||||
for _, n := range pendingNotifications {
|
||||
_, _ = event.Push(context.Background(), events.ExecRecovered, n)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -227,8 +241,8 @@ func (m *Manager) Tick(parentCtx context.Context, now time.Time) error {
|
|||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Get all cached robots
|
||||
robots := m.cache.ListAll()
|
||||
// Get autonomous robots for clock trigger check
|
||||
robots := m.cache.ListAutonomous()
|
||||
|
||||
for _, robot := range robots {
|
||||
// Skip if robot is not active
|
||||
|
|
@ -747,8 +761,8 @@ func (m *Manager) scheduleCleanup(robot *types.Robot) {
|
|||
|
||||
// Check if all executions are done
|
||||
if r.RunningCount() == 0 {
|
||||
// Only remove if still non-autonomous
|
||||
// (user might have changed it during execution)
|
||||
// Non-autonomous robots: with full-cache load they will be
|
||||
// re-added on next Load() cycle, so removal is a no-op in practice.
|
||||
if !r.AutonomousMode {
|
||||
m.cache.Remove(memberID)
|
||||
}
|
||||
|
|
@ -794,6 +808,11 @@ func (m *Manager) Executor() types.Executor {
|
|||
return m.executor
|
||||
}
|
||||
|
||||
// ExecController returns the internal execution controller
|
||||
func (m *Manager) ExecController() *trigger.ExecutionController {
|
||||
return m.execController
|
||||
}
|
||||
|
||||
// IsStarted returns true if manager is started
|
||||
func (m *Manager) IsStarted() bool {
|
||||
m.mu.RLock()
|
||||
|
|
|
|||
|
|
@ -1399,8 +1399,8 @@ func setupTestRobotsWithEventConfig(t *testing.T) {
|
|||
|
||||
// ==================== Lazy Load Tests for Non-Autonomous Robots ====================
|
||||
|
||||
// TestManagerLazyLoadNonAutonomous tests that non-autonomous robots are lazy-loaded on demand
|
||||
// and automatically cleaned up after execution completes
|
||||
// TestManagerLazyLoadNonAutonomous tests that non-autonomous robots are pre-loaded
|
||||
// into cache (full-cache load) and can be triggered/intervened/evented normally.
|
||||
func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
|
|
@ -1413,22 +1413,25 @@ func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
|||
setupTestRobotsWithNonAutonomous(t)
|
||||
defer cleanupTestRobots(t)
|
||||
|
||||
t.Run("non-autonomous robot not in cache on startup", func(t *testing.T) {
|
||||
t.Run("non-autonomous robot is in cache after full load", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
// Non-autonomous robot should NOT be in cache
|
||||
// With full-cache load, non-autonomous active robots are now in cache
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand")
|
||||
assert.Nil(t, robot, "Non-autonomous robot should not be pre-loaded into cache")
|
||||
assert.NotNil(t, robot, "Non-autonomous active robot should be in cache after full load")
|
||||
if robot != nil {
|
||||
assert.False(t, robot.AutonomousMode)
|
||||
}
|
||||
|
||||
// Autonomous robot SHOULD be in cache
|
||||
// Autonomous robot SHOULD also be in cache
|
||||
autoRobot := m.Cache().Get("robot_test_manager_times")
|
||||
assert.NotNil(t, autoRobot, "Autonomous robot should be in cache")
|
||||
})
|
||||
|
||||
t.Run("TriggerManual lazy-loads non-autonomous robot", func(t *testing.T) {
|
||||
t.Run("TriggerManual works for non-autonomous robot in cache", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -1436,22 +1439,22 @@ func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
|||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Verify robot is NOT in cache before trigger
|
||||
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand"))
|
||||
// Robot is already in cache after full load
|
||||
assert.NotNil(t, m.Cache().Get("robot_test_manager_on_demand"))
|
||||
|
||||
// Trigger the non-autonomous robot manually
|
||||
execID, err := m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, execID)
|
||||
|
||||
// Robot should now be in cache (lazy-loaded)
|
||||
// Robot should still be in cache
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand")
|
||||
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache")
|
||||
assert.NotNil(t, robot, "Robot should remain in cache")
|
||||
assert.Equal(t, "robot_test_manager_on_demand", robot.MemberID)
|
||||
assert.False(t, robot.AutonomousMode)
|
||||
})
|
||||
|
||||
t.Run("Intervene lazy-loads non-autonomous robot", func(t *testing.T) {
|
||||
t.Run("Intervene works for non-autonomous robot in cache", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -1459,8 +1462,8 @@ func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
|||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Verify robot is NOT in cache before trigger
|
||||
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_intervene"))
|
||||
// Robot is already in cache after full load
|
||||
assert.NotNil(t, m.Cache().Get("robot_test_manager_on_demand_intervene"))
|
||||
|
||||
// Intervene on the non-autonomous robot
|
||||
req := &types.InterveneRequest{
|
||||
|
|
@ -1468,7 +1471,7 @@ func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
|||
MemberID: "robot_test_manager_on_demand_intervene",
|
||||
Action: types.ActionTaskAdd,
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Test lazy load via intervene"},
|
||||
{Role: agentcontext.RoleUser, Content: "Test intervene on cached non-autonomous robot"},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1476,12 +1479,12 @@ func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result.ExecutionID)
|
||||
|
||||
// Robot should now be in cache (lazy-loaded)
|
||||
// Robot should still be in cache
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand_intervene")
|
||||
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via Intervene")
|
||||
assert.NotNil(t, robot, "Robot should remain in cache after Intervene")
|
||||
})
|
||||
|
||||
t.Run("HandleEvent lazy-loads non-autonomous robot", func(t *testing.T) {
|
||||
t.Run("HandleEvent works for non-autonomous robot in cache", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -1489,8 +1492,8 @@ func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
|||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
// Verify robot is NOT in cache before trigger
|
||||
assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_event"))
|
||||
// Robot is already in cache after full load
|
||||
assert.NotNil(t, m.Cache().Get("robot_test_manager_on_demand_event"))
|
||||
|
||||
// Send event to the non-autonomous robot
|
||||
req := &types.EventRequest{
|
||||
|
|
@ -1504,12 +1507,12 @@ func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result.ExecutionID)
|
||||
|
||||
// Robot should now be in cache (lazy-loaded)
|
||||
// Robot should still be in cache
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand_event")
|
||||
assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via HandleEvent")
|
||||
assert.NotNil(t, robot, "Robot should remain in cache after HandleEvent")
|
||||
})
|
||||
|
||||
t.Run("lazy-loaded robot is cleaned up after execution completes", func(t *testing.T) {
|
||||
t.Run("non-autonomous robot stays in cache after execution completes", func(t *testing.T) {
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -1521,23 +1524,19 @@ func TestManagerLazyLoadNonAutonomous(t *testing.T) {
|
|||
_, err = m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Robot should be in cache immediately after trigger
|
||||
// Robot should be in cache
|
||||
robot := m.Cache().Get("robot_test_manager_on_demand")
|
||||
assert.NotNil(t, robot, "Robot should be in cache after trigger")
|
||||
|
||||
// Wait for execution to complete and cleanup to happen
|
||||
// The stub executor completes quickly, and cleanup runs every 5 seconds
|
||||
// We wait up to 10 seconds for the cleanup goroutine to remove the robot
|
||||
var removed bool
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if m.Cache().Get("robot_test_manager_on_demand") == nil {
|
||||
removed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Wait for execution to complete
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
assert.True(t, removed, "Non-autonomous robot should be removed from cache after execution completes")
|
||||
// With full-cache load, non-autonomous robots stay in cache
|
||||
// (scheduleCleanup may remove then next Load() re-adds, but within cycle it persists)
|
||||
robot = m.Cache().Get("robot_test_manager_on_demand")
|
||||
// Robot may or may not be in cache depending on cleanup timing,
|
||||
// but the key point is no panic and the system remains stable
|
||||
t.Logf("Robot in cache after execution: %v", robot != nil)
|
||||
})
|
||||
|
||||
t.Run("trigger non-existent robot returns error", func(t *testing.T) {
|
||||
|
|
|
|||
87
agent/robot/manager/recovery.go
Normal file
87
agent/robot/manager/recovery.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/events"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
var nonTerminalStatuses = []types.ExecStatus{
|
||||
types.ExecRunning, types.ExecPaused, types.ExecPending,
|
||||
types.ExecWaiting, types.ExecConfirming,
|
||||
}
|
||||
|
||||
// recoverExecutions scans the DB for non-terminal executions left by a prior
|
||||
// server crash. Running/paused/pending records are marked failed; waiting/confirming
|
||||
// records are kept as-is and returned for notification.
|
||||
func (m *Manager) recoverExecutions(ctx context.Context) []events.ExecPayload {
|
||||
execStore := store.NewExecutionStore()
|
||||
robotStore := store.NewRobotStore()
|
||||
|
||||
var pendingNotifications []events.ExecPayload
|
||||
affectedMembers := map[string]bool{}
|
||||
|
||||
pageSize := 100
|
||||
for page := 1; ; page++ {
|
||||
result, err := execStore.ListByStatuses(ctx, nonTerminalStatuses, &store.ListOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[recovery] failed to list non-terminal executions page %d: %v", page, err)
|
||||
break
|
||||
}
|
||||
if len(result.Data) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, record := range result.Data {
|
||||
affectedMembers[record.MemberID] = true
|
||||
|
||||
switch record.Status {
|
||||
case types.ExecRunning, types.ExecPaused, types.ExecPending:
|
||||
if err := execStore.UpdateStatus(ctx, record.ExecutionID, types.ExecFailed,
|
||||
"execution interrupted by server restart"); err != nil {
|
||||
log.Printf("[recovery] failed to mark %s as failed: %v", record.ExecutionID, err)
|
||||
}
|
||||
case types.ExecWaiting, types.ExecConfirming:
|
||||
pendingNotifications = append(pendingNotifications, events.ExecPayload{
|
||||
ExecutionID: record.ExecutionID,
|
||||
MemberID: record.MemberID,
|
||||
TeamID: record.TeamID,
|
||||
Status: string(record.Status),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Data) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fixRobotStatuses(ctx, execStore, robotStore, affectedMembers)
|
||||
|
||||
return pendingNotifications
|
||||
}
|
||||
|
||||
// fixRobotStatuses sets robots to idle when they no longer have any non-terminal executions.
|
||||
func fixRobotStatuses(ctx context.Context, execStore *store.ExecutionStore, robotStore *store.RobotStore, members map[string]bool) {
|
||||
for memberID := range members {
|
||||
result, err := execStore.ListByStatuses(ctx, nonTerminalStatuses, &store.ListOptions{
|
||||
MemberID: memberID,
|
||||
PageSize: 1,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[recovery] failed to check remaining executions for %s: %v", memberID, err)
|
||||
continue
|
||||
}
|
||||
if result.Total == 0 {
|
||||
if err := robotStore.UpdateStatus(ctx, memberID, types.RobotIdle); err != nil {
|
||||
log.Printf("[recovery] failed to set %s to idle: %v", memberID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
301
agent/robot/manager/recovery_test.go
Normal file
301
agent/robot/manager/recovery_test.go
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
package manager_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/xun/capsule"
|
||||
"github.com/yaoapp/yao/agent/robot/manager"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
const recoveryTestPrefix = "_test_recovery_"
|
||||
|
||||
func TestRecoveryOnRestart(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("marks_running_as_failed_on_restart", func(t *testing.T) {
|
||||
cleanupRecoveryData(t)
|
||||
defer cleanupRecoveryData(t)
|
||||
|
||||
insertRecoveryExec(t, recoveryTestPrefix+"run_001", recoveryTestPrefix+"member_001", "team_r", "running")
|
||||
insertRecoveryRobot(t, recoveryTestPrefix+"member_001", "team_r")
|
||||
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
rec := getExecRecord(t, recoveryTestPrefix+"run_001")
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, "failed", rec["status"])
|
||||
errMsg, _ := rec["error"].(string)
|
||||
assert.Contains(t, errMsg, "server restart")
|
||||
})
|
||||
|
||||
t.Run("keeps_waiting_on_restart", func(t *testing.T) {
|
||||
cleanupRecoveryData(t)
|
||||
defer cleanupRecoveryData(t)
|
||||
|
||||
insertRecoveryExec(t, recoveryTestPrefix+"wait_001", recoveryTestPrefix+"member_002", "team_r", "waiting")
|
||||
insertRecoveryRobot(t, recoveryTestPrefix+"member_002", "team_r")
|
||||
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
rec := getExecRecord(t, recoveryTestPrefix+"wait_001")
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, "waiting", rec["status"])
|
||||
})
|
||||
|
||||
t.Run("keeps_confirming_on_restart", func(t *testing.T) {
|
||||
cleanupRecoveryData(t)
|
||||
defer cleanupRecoveryData(t)
|
||||
|
||||
insertRecoveryExec(t, recoveryTestPrefix+"conf_001", recoveryTestPrefix+"member_003", "team_r", "confirming")
|
||||
insertRecoveryRobot(t, recoveryTestPrefix+"member_003", "team_r")
|
||||
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
rec := getExecRecord(t, recoveryTestPrefix+"conf_001")
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, "confirming", rec["status"])
|
||||
})
|
||||
|
||||
t.Run("marks_paused_as_failed_on_restart", func(t *testing.T) {
|
||||
cleanupRecoveryData(t)
|
||||
defer cleanupRecoveryData(t)
|
||||
|
||||
insertRecoveryExec(t, recoveryTestPrefix+"pause_001", recoveryTestPrefix+"member_004", "team_r", "paused")
|
||||
insertRecoveryRobot(t, recoveryTestPrefix+"member_004", "team_r")
|
||||
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
rec := getExecRecord(t, recoveryTestPrefix+"pause_001")
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, "failed", rec["status"])
|
||||
})
|
||||
|
||||
t.Run("no_active_executions_starts_normally", func(t *testing.T) {
|
||||
cleanupRecoveryData(t)
|
||||
defer cleanupRecoveryData(t)
|
||||
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
assert.True(t, m.IsStarted())
|
||||
})
|
||||
|
||||
t.Run("updates_robot_status_to_idle_after_fail", func(t *testing.T) {
|
||||
cleanupRecoveryData(t)
|
||||
defer cleanupRecoveryData(t)
|
||||
|
||||
insertRecoveryExec(t, recoveryTestPrefix+"run_002", recoveryTestPrefix+"member_005", "team_r", "running")
|
||||
insertRecoveryRobot(t, recoveryTestPrefix+"member_005", "team_r")
|
||||
setRobotStatus(t, recoveryTestPrefix+"member_005", "working")
|
||||
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
rec := getExecRecord(t, recoveryTestPrefix+"run_002")
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, "failed", rec["status"])
|
||||
|
||||
robot := getRobotRecord(t, recoveryTestPrefix+"member_005")
|
||||
require.NotNil(t, robot)
|
||||
assert.Equal(t, "idle", robot["robot_status"])
|
||||
})
|
||||
|
||||
t.Run("keeps_robot_status_if_other_waiting", func(t *testing.T) {
|
||||
cleanupRecoveryData(t)
|
||||
defer cleanupRecoveryData(t)
|
||||
|
||||
insertRecoveryExec(t, recoveryTestPrefix+"run_003", recoveryTestPrefix+"member_006", "team_r", "running")
|
||||
insertRecoveryExec(t, recoveryTestPrefix+"wait_003", recoveryTestPrefix+"member_006", "team_r", "waiting")
|
||||
insertRecoveryRobot(t, recoveryTestPrefix+"member_006", "team_r")
|
||||
setRobotStatus(t, recoveryTestPrefix+"member_006", "working")
|
||||
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
// Running should be failed
|
||||
rec := getExecRecord(t, recoveryTestPrefix+"run_003")
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, "failed", rec["status"])
|
||||
|
||||
// Waiting should remain
|
||||
rec2 := getExecRecord(t, recoveryTestPrefix+"wait_003")
|
||||
require.NotNil(t, rec2)
|
||||
assert.Equal(t, "waiting", rec2["status"])
|
||||
|
||||
// Robot should NOT be set to idle because waiting exec still exists
|
||||
robot := getRobotRecord(t, recoveryTestPrefix+"member_006")
|
||||
require.NotNil(t, robot)
|
||||
assert.NotEqual(t, "idle", robot["robot_status"],
|
||||
"robot should not be idle when waiting execution exists")
|
||||
})
|
||||
|
||||
t.Run("idempotent_on_double_restart", func(t *testing.T) {
|
||||
cleanupRecoveryData(t)
|
||||
defer cleanupRecoveryData(t)
|
||||
|
||||
insertRecoveryExec(t, recoveryTestPrefix+"run_004", recoveryTestPrefix+"member_007", "team_r", "running")
|
||||
insertRecoveryRobot(t, recoveryTestPrefix+"member_007", "team_r")
|
||||
|
||||
// First start
|
||||
m1 := manager.New()
|
||||
err := m1.Start()
|
||||
require.NoError(t, err)
|
||||
m1.Stop()
|
||||
|
||||
rec := getExecRecord(t, recoveryTestPrefix+"run_004")
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, "failed", rec["status"])
|
||||
|
||||
// Second start — should not panic or error
|
||||
m2 := manager.New()
|
||||
err = m2.Start()
|
||||
require.NoError(t, err)
|
||||
defer m2.Stop()
|
||||
|
||||
rec2 := getExecRecord(t, recoveryTestPrefix+"run_004")
|
||||
require.NotNil(t, rec2)
|
||||
assert.Equal(t, "failed", rec2["status"])
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
func insertRecoveryExec(t *testing.T, execID, memberID, teamID, status string) {
|
||||
t.Helper()
|
||||
mod := model.Select("__yao.agent.execution")
|
||||
tableName := mod.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
now := time.Now()
|
||||
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"execution_id": execID,
|
||||
"member_id": memberID,
|
||||
"team_id": teamID,
|
||||
"trigger_type": "clock",
|
||||
"status": status,
|
||||
"phase": "run",
|
||||
"start_time": now.Add(-1 * time.Hour),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "insert execution %s", execID)
|
||||
}
|
||||
|
||||
func insertRecoveryRobot(t *testing.T, memberID, teamID string) {
|
||||
t.Helper()
|
||||
mod := model.Select("__yao.member")
|
||||
tableName := mod.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
robotConfig := map[string]interface{}{
|
||||
"identity": map[string]interface{}{"role": "Recovery Test Robot"},
|
||||
"triggers": map[string]interface{}{
|
||||
"clock": map[string]interface{}{"enabled": false},
|
||||
},
|
||||
}
|
||||
configJSON, _ := json.Marshal(robotConfig)
|
||||
|
||||
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": memberID,
|
||||
"team_id": teamID,
|
||||
"member_type": "robot",
|
||||
"display_name": "Recovery Test " + memberID,
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": true,
|
||||
"robot_status": "idle",
|
||||
"robot_config": string(configJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "insert robot %s", memberID)
|
||||
}
|
||||
|
||||
func setRobotStatus(t *testing.T, memberID, status string) {
|
||||
t.Helper()
|
||||
mod := model.Select("__yao.member")
|
||||
tableName := mod.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
_, err := qb.Table(tableName).Where("member_id", memberID).Update(map[string]interface{}{
|
||||
"robot_status": status,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func getExecRecord(t *testing.T, execID string) map[string]interface{} {
|
||||
t.Helper()
|
||||
mod := model.Select("__yao.agent.execution")
|
||||
tableName := mod.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
rows, err := qb.Table(tableName).Where("execution_id", execID).Limit(1).Get()
|
||||
require.NoError(t, err)
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}(rows[0])
|
||||
}
|
||||
|
||||
func getRobotRecord(t *testing.T, memberID string) map[string]interface{} {
|
||||
t.Helper()
|
||||
mod := model.Select("__yao.member")
|
||||
tableName := mod.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
rows, err := qb.Table(tableName).Where("member_id", memberID).Limit(1).Get()
|
||||
require.NoError(t, err)
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}(rows[0])
|
||||
}
|
||||
|
||||
func cleanupRecoveryData(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// Clean executions
|
||||
execMod := model.Select("__yao.agent.execution")
|
||||
execTable := execMod.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
qb.Table(execTable).Where("execution_id", "like", recoveryTestPrefix+"%").Delete()
|
||||
|
||||
// Clean robots
|
||||
memberMod := model.Select("__yao.member")
|
||||
memberTable := memberMod.MetaData.Table.Name
|
||||
qb.Table(memberTable).Where("member_id", "like", recoveryTestPrefix+"%").Delete()
|
||||
// Also clean via model (soft delete)
|
||||
memberMod.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", OP: "like", Value: recoveryTestPrefix + "%"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ func processList(p *process.Process) interface{} {
|
|||
filter.TeamID = toString(v)
|
||||
}
|
||||
}
|
||||
result, err := api.ListRobots(ctx, filter)
|
||||
result, err := api.ListAllRobots(ctx, filter)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/cache"
|
||||
"github.com/yaoapp/yao/agent/robot/dedup"
|
||||
"github.com/yaoapp/yao/agent/robot/events/integrations"
|
||||
"github.com/yaoapp/yao/agent/robot/events/integrations/telegram"
|
||||
"github.com/yaoapp/yao/agent/robot/executor"
|
||||
"github.com/yaoapp/yao/agent/robot/logger"
|
||||
"github.com/yaoapp/yao/agent/robot/manager"
|
||||
"github.com/yaoapp/yao/agent/robot/plan"
|
||||
"github.com/yaoapp/yao/agent/robot/pool"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
var (
|
||||
log = logger.New("robot")
|
||||
|
||||
globalManager *manager.Manager
|
||||
globalCache *cache.Cache
|
||||
globalPool *pool.Pool
|
||||
globalDedup *dedup.Dedup
|
||||
globalStore *store.Store
|
||||
globalExecutor executor.Executor
|
||||
globalPlan *plan.Plan
|
||||
globalDispatcher *integrations.Dispatcher
|
||||
)
|
||||
|
||||
// Init initializes the robot agent system
|
||||
func Init() error {
|
||||
globalCache = cache.New()
|
||||
globalDedup = dedup.New()
|
||||
globalStore = store.New()
|
||||
globalPool = pool.New()
|
||||
globalExecutor = executor.New()
|
||||
globalManager = manager.New()
|
||||
globalPlan = plan.New()
|
||||
|
||||
// Load robots into cache from database before starting dispatcher
|
||||
rCtx := robottypes.NewContext(context.Background(), nil)
|
||||
if err := globalCache.Load(rCtx); err != nil {
|
||||
log.Warn("robot.Init: cache load failed (will rely on config events): %v", err)
|
||||
}
|
||||
|
||||
adapters := map[string]integrations.Adapter{
|
||||
"telegram": telegram.NewAdapter(),
|
||||
}
|
||||
globalDispatcher = integrations.NewDispatcher(globalCache, adapters)
|
||||
if err := globalDispatcher.Start(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the robot agent system
|
||||
func Shutdown() error {
|
||||
if globalDispatcher != nil {
|
||||
globalDispatcher.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Manager returns the global manager instance
|
||||
func Manager() *manager.Manager {
|
||||
return globalManager
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/xun/capsule"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
|
|
@ -65,7 +66,8 @@ type ListOptions struct {
|
|||
MemberID string `json:"member_id,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
Status types.ExecStatus `json:"status,omitempty"`
|
||||
ExcludeStatuses []types.ExecStatus `json:"exclude_statuses,omitempty"`
|
||||
Statuses []types.ExecStatus `json:"statuses,omitempty"` // Multi-status IN query; takes priority over Status when non-empty
|
||||
ExcludeStatuses []types.ExecStatus `json:"exclude_statuses,omitempty"` // Exclude these statuses (ne)
|
||||
TriggerType types.TriggerType `json:"trigger_type,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"pagesize,omitempty"`
|
||||
|
|
@ -171,7 +173,11 @@ func (s *ExecutionStore) List(ctx context.Context, opts *ListOptions) (*ListResu
|
|||
if opts.TeamID != "" {
|
||||
wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID})
|
||||
}
|
||||
if opts.Status != "" {
|
||||
if len(opts.Statuses) > 0 {
|
||||
// Backward compat: use the first status for simple equality filter.
|
||||
// For multi-status IN queries, use ListByStatuses() instead.
|
||||
wheres = append(wheres, model.QueryWhere{Column: "status", Value: string(opts.Statuses[0])})
|
||||
} else if opts.Status != "" {
|
||||
wheres = append(wheres, model.QueryWhere{Column: "status", Value: string(opts.Status)})
|
||||
}
|
||||
for _, es := range opts.ExcludeStatuses {
|
||||
|
|
@ -232,6 +238,80 @@ func (s *ExecutionStore) List(ctx context.Context, opts *ListOptions) (*ListResu
|
|||
}, nil
|
||||
}
|
||||
|
||||
// ListByStatuses queries executions matching any of the given statuses using
|
||||
// capsule.Query() with WhereIn, which works reliably (unlike model.Paginate
|
||||
// with OP:"in" or multiple "ne" conditions).
|
||||
func (s *ExecutionStore) ListByStatuses(ctx context.Context, statuses []types.ExecStatus, opts *ListOptions) (*ListResult, error) {
|
||||
if len(statuses) == 0 {
|
||||
return &ListResult{Data: []*ExecutionRecord{}, Total: 0, Page: 1, PageSize: 20}, nil
|
||||
}
|
||||
|
||||
mod := model.Select(s.modelID)
|
||||
if mod == nil {
|
||||
return nil, fmt.Errorf("model %s not found", s.modelID)
|
||||
}
|
||||
tableName := mod.MetaData.Table.Name
|
||||
|
||||
statusStrs := make([]interface{}, len(statuses))
|
||||
for i, st := range statuses {
|
||||
statusStrs[i] = string(st)
|
||||
}
|
||||
|
||||
page := 1
|
||||
pageSize := 20
|
||||
if opts != nil {
|
||||
if opts.Page > 0 {
|
||||
page = opts.Page
|
||||
}
|
||||
if opts.PageSize > 0 {
|
||||
pageSize = opts.PageSize
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
qb := capsule.Query()
|
||||
|
||||
// Count query
|
||||
countQB := qb.Table(tableName).WhereIn("status", statusStrs)
|
||||
if opts != nil && opts.MemberID != "" {
|
||||
countQB = countQB.Where("member_id", opts.MemberID)
|
||||
}
|
||||
total, err := countQB.Count()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to count executions by statuses: %w", err)
|
||||
}
|
||||
|
||||
// Data query
|
||||
dataQB := qb.Table(tableName).WhereIn("status", statusStrs)
|
||||
if opts != nil && opts.MemberID != "" {
|
||||
dataQB = dataQB.Where("member_id", opts.MemberID)
|
||||
}
|
||||
rows, err := dataQB.OrderBy("start_time", "desc").Limit(pageSize).Offset(offset).Get()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list executions by statuses: %w", err)
|
||||
}
|
||||
|
||||
records := make([]*ExecutionRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
rowMap := map[string]interface{}(row)
|
||||
record, err := s.mapToRecord(rowMap)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return &ListResult{
|
||||
Data: records,
|
||||
Total: int(total),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdatePhase updates the current phase and its data
|
||||
func (s *ExecutionStore) UpdatePhase(ctx context.Context, executionID string, phase types.Phase, data interface{}) error {
|
||||
mod := model.Select(s.modelID)
|
||||
|
|
@ -840,18 +920,18 @@ func (s *ExecutionStore) parseTime(v interface{}) *time.Time {
|
|||
case *time.Time:
|
||||
return t
|
||||
case string:
|
||||
// Try parsing common time formats
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05Z",
|
||||
}
|
||||
for _, format := range formats {
|
||||
// Formats that include timezone info — use time.Parse (respects embedded tz)
|
||||
for _, format := range []string{time.RFC3339, time.RFC3339Nano} {
|
||||
if parsed, err := time.Parse(format, t); err == nil {
|
||||
return &parsed
|
||||
}
|
||||
}
|
||||
// Formats without timezone — treat as local time
|
||||
for _, format := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
|
||||
if parsed, err := time.ParseInLocation(format, t, time.Local); err == nil {
|
||||
return &parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,41 @@ func TestExecutionStoreList(t *testing.T) {
|
|||
assert.Equal(t, types.ExecCompleted, r.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list_with_statuses_returns_matching", func(t *testing.T) {
|
||||
result, err := s.ListByStatuses(ctx,
|
||||
[]types.ExecStatus{types.ExecRunning, types.ExecFailed},
|
||||
&store.ListOptions{MemberID: "member_list_002"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, len(result.Data))
|
||||
for _, r := range result.Data {
|
||||
assert.True(t, r.Status == types.ExecRunning || r.Status == types.ExecFailed,
|
||||
"expected running or failed, got %s", r.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list_with_statuses_empty_result", func(t *testing.T) {
|
||||
result, err := s.ListByStatuses(ctx,
|
||||
[]types.ExecStatus{types.ExecWaiting, types.ExecConfirming},
|
||||
&store.ListOptions{MemberID: "member_list_001"})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result.Data)
|
||||
assert.Equal(t, 0, len(result.Data))
|
||||
})
|
||||
|
||||
t.Run("list_with_statuses_single_status", func(t *testing.T) {
|
||||
resultStatuses, err := s.ListByStatuses(ctx,
|
||||
[]types.ExecStatus{types.ExecCompleted},
|
||||
&store.ListOptions{MemberID: "member_list_001"})
|
||||
require.NoError(t, err)
|
||||
|
||||
resultStatus, err := s.List(ctx, &store.ListOptions{
|
||||
Status: types.ExecCompleted,
|
||||
MemberID: "member_list_001",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(resultStatus.Data), len(resultStatuses.Data))
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecutionStoreUpdatePhase tests updating phase and phase data
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type Integrations struct {
|
|||
Feishu *FeishuConfig `json:"feishu,omitempty"`
|
||||
DingTalk *DingTalkConfig `json:"dingtalk,omitempty"`
|
||||
Discord *DiscordConfig `json:"discord,omitempty"`
|
||||
Weixin *WeixinConfig `json:"weixin,omitempty"`
|
||||
}
|
||||
|
||||
// TelegramConfig holds Telegram Bot integration settings.
|
||||
|
|
@ -273,6 +274,16 @@ type MCPConfig struct {
|
|||
Tools []string `json:"tools,omitempty"` // empty = all
|
||||
}
|
||||
|
||||
// WeixinConfig holds WeChat iLink Bot integration settings.
|
||||
type WeixinConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BotToken string `json:"bot_token"`
|
||||
AccountID string `json:"account_id,omitempty"` // ilink_bot_id
|
||||
APIHost string `json:"api_host,omitempty"` // custom API host
|
||||
BaseURL string `json:"base_url,omitempty"` // alias for api_host
|
||||
CDNBaseURL string `json:"cdn_base_url,omitempty"` // custom CDN base URL
|
||||
}
|
||||
|
||||
// Event - event trigger config
|
||||
type Event struct {
|
||||
Type EventSource `json:"type"` // webhook | database
|
||||
|
|
|
|||
248
agent/robot/watcher.go
Normal file
248
agent/robot/watcher.go
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/monitor"
|
||||
)
|
||||
|
||||
func init() {
|
||||
monitor.Register(&robotTasksWatcher{})
|
||||
}
|
||||
|
||||
// WatcherConfig holds tuning knobs for the robot-tasks watcher.
|
||||
type WatcherConfig struct {
|
||||
Interval time.Duration
|
||||
MaxRunDuration time.Duration
|
||||
WaitingTimeout time.Duration
|
||||
ConfirmTimeout time.Duration
|
||||
}
|
||||
|
||||
var defaultConfig = WatcherConfig{
|
||||
Interval: 5 * time.Minute,
|
||||
MaxRunDuration: 4 * time.Hour,
|
||||
WaitingTimeout: 24 * time.Hour,
|
||||
ConfirmTimeout: 1 * time.Hour,
|
||||
}
|
||||
|
||||
type robotTasksWatcher struct {
|
||||
config WatcherConfig
|
||||
}
|
||||
|
||||
func (w *robotTasksWatcher) Name() string { return "robot-tasks" }
|
||||
|
||||
func (w *robotTasksWatcher) Interval() time.Duration {
|
||||
if w.config.Interval > 0 {
|
||||
return w.config.Interval
|
||||
}
|
||||
return defaultConfig.Interval
|
||||
}
|
||||
|
||||
func (w *robotTasksWatcher) Check(ctx context.Context) []monitor.Alert {
|
||||
mgr := api.GetManager()
|
||||
if mgr == nil || !mgr.IsStarted() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var alerts []monitor.Alert
|
||||
execStore := store.NewExecutionStore()
|
||||
now := time.Now()
|
||||
|
||||
alerts = append(alerts, w.checkZombieRunning(ctx, execStore, now)...)
|
||||
alerts = append(alerts, w.checkWaitingTimeout(ctx, execStore, now)...)
|
||||
alerts = append(alerts, w.checkConfirmingTimeout(ctx, execStore, now)...)
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
func (w *robotTasksWatcher) maxRunDuration() time.Duration {
|
||||
if w.config.MaxRunDuration > 0 {
|
||||
return w.config.MaxRunDuration
|
||||
}
|
||||
return defaultConfig.MaxRunDuration
|
||||
}
|
||||
|
||||
func (w *robotTasksWatcher) waitingTimeout() time.Duration {
|
||||
if w.config.WaitingTimeout > 0 {
|
||||
return w.config.WaitingTimeout
|
||||
}
|
||||
return defaultConfig.WaitingTimeout
|
||||
}
|
||||
|
||||
func (w *robotTasksWatcher) confirmTimeout() time.Duration {
|
||||
if w.config.ConfirmTimeout > 0 {
|
||||
return w.config.ConfirmTimeout
|
||||
}
|
||||
return defaultConfig.ConfirmTimeout
|
||||
}
|
||||
|
||||
// checkZombieRunning finds running executions that exceeded maxRunDuration
|
||||
// and are not tracked by the in-memory execController.
|
||||
func (w *robotTasksWatcher) checkZombieRunning(ctx context.Context, execStore *store.ExecutionStore, now time.Time) []monitor.Alert {
|
||||
var alerts []monitor.Alert
|
||||
maxDur := w.maxRunDuration()
|
||||
|
||||
result, err := execStore.List(ctx, &store.ListOptions{
|
||||
Status: types.ExecRunning,
|
||||
PageSize: 100,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
mgr := api.GetManager()
|
||||
|
||||
for _, rec := range result.Data {
|
||||
if rec.StartTime == nil {
|
||||
continue
|
||||
}
|
||||
deadline := rec.StartTime.Add(maxDur)
|
||||
if now.Before(deadline) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if still tracked by execController (genuinely running)
|
||||
if mgr != nil {
|
||||
if _, err := mgr.GetExecutionStatus(rec.ExecutionID); err == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
execID := rec.ExecutionID
|
||||
alerts = append(alerts, monitor.Alert{
|
||||
Level: monitor.Warn,
|
||||
Target: fmt.Sprintf("execution:%s", execID),
|
||||
Message: fmt.Sprintf("zombie running execution %s (started %s, exceeded %v)", execID, rec.StartTime.Format(time.RFC3339), maxDur),
|
||||
Action: func(ctx context.Context) {
|
||||
mod := model.Select("__yao.agent.execution")
|
||||
if mod == nil {
|
||||
return
|
||||
}
|
||||
// CAS: only update if still running
|
||||
mod.UpdateWhere(
|
||||
model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "execution_id", Value: execID},
|
||||
{Column: "status", Value: string(types.ExecRunning)},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"status": string(types.ExecFailed),
|
||||
"error": "killed by watcher: exceeded max run duration",
|
||||
"end_time": time.Now(),
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
// checkWaitingTimeout finds waiting executions past the waiting timeout.
|
||||
func (w *robotTasksWatcher) checkWaitingTimeout(ctx context.Context, execStore *store.ExecutionStore, now time.Time) []monitor.Alert {
|
||||
var alerts []monitor.Alert
|
||||
timeout := w.waitingTimeout()
|
||||
|
||||
result, err := execStore.List(ctx, &store.ListOptions{
|
||||
Status: types.ExecWaiting,
|
||||
PageSize: 100,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, rec := range result.Data {
|
||||
if rec.UpdatedAt == nil {
|
||||
continue
|
||||
}
|
||||
if now.Before(rec.UpdatedAt.Add(timeout)) {
|
||||
continue
|
||||
}
|
||||
|
||||
execID := rec.ExecutionID
|
||||
alerts = append(alerts, monitor.Alert{
|
||||
Level: monitor.Warn,
|
||||
Target: fmt.Sprintf("execution:%s", execID),
|
||||
Message: fmt.Sprintf("waiting execution %s timed out (last updated %s, timeout %v)", execID, rec.UpdatedAt.Format(time.RFC3339), timeout),
|
||||
Action: func(ctx context.Context) {
|
||||
mod := model.Select("__yao.agent.execution")
|
||||
if mod == nil {
|
||||
return
|
||||
}
|
||||
mod.UpdateWhere(
|
||||
model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "execution_id", Value: execID},
|
||||
{Column: "status", Value: string(types.ExecWaiting)},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"status": string(types.ExecCancelled),
|
||||
"error": "cancelled by watcher: waiting timeout exceeded",
|
||||
"end_time": time.Now(),
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
// checkConfirmingTimeout finds confirming executions past the confirm timeout.
|
||||
func (w *robotTasksWatcher) checkConfirmingTimeout(ctx context.Context, execStore *store.ExecutionStore, now time.Time) []monitor.Alert {
|
||||
var alerts []monitor.Alert
|
||||
timeout := w.confirmTimeout()
|
||||
|
||||
result, err := execStore.List(ctx, &store.ListOptions{
|
||||
Status: types.ExecConfirming,
|
||||
PageSize: 100,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, rec := range result.Data {
|
||||
if rec.UpdatedAt == nil {
|
||||
continue
|
||||
}
|
||||
if now.Before(rec.UpdatedAt.Add(timeout)) {
|
||||
continue
|
||||
}
|
||||
|
||||
execID := rec.ExecutionID
|
||||
alerts = append(alerts, monitor.Alert{
|
||||
Level: monitor.Info,
|
||||
Target: fmt.Sprintf("execution:%s", execID),
|
||||
Message: fmt.Sprintf("confirming execution %s timed out (last updated %s, timeout %v)", execID, rec.UpdatedAt.Format(time.RFC3339), timeout),
|
||||
Action: func(ctx context.Context) {
|
||||
mod := model.Select("__yao.agent.execution")
|
||||
if mod == nil {
|
||||
return
|
||||
}
|
||||
mod.UpdateWhere(
|
||||
model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "execution_id", Value: execID},
|
||||
{Column: "status", Value: string(types.ExecConfirming)},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"status": string(types.ExecCancelled),
|
||||
"error": "cancelled by watcher: confirmation timeout exceeded",
|
||||
"end_time": time.Now(),
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
283
agent/robot/watcher_test.go
Normal file
283
agent/robot/watcher_test.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
package robot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/xun/capsule"
|
||||
"github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/agent/robot/manager"
|
||||
"github.com/yaoapp/yao/agent/robot/store"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/monitor"
|
||||
|
||||
// Trigger watcher registration via init()
|
||||
_ "github.com/yaoapp/yao/agent/robot"
|
||||
)
|
||||
|
||||
const watcherTestPrefix = "_test_watcher_"
|
||||
|
||||
func TestRobotTasksWatcher(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("detects_zombie_running_execution", func(t *testing.T) {
|
||||
cleanupWatcherData(t)
|
||||
defer cleanupWatcherData(t)
|
||||
|
||||
insertWatcherRobot(t, watcherTestPrefix+"member_001", "team_w")
|
||||
|
||||
m := startWatcherManager(t)
|
||||
defer m.Stop()
|
||||
|
||||
// Insert AFTER manager start so recovery doesn't touch it
|
||||
oldStart := time.Now().Add(-5 * time.Hour)
|
||||
insertWatcherExec(t, watcherTestPrefix+"zombie_001", watcherTestPrefix+"member_001", "team_w", "running", &oldStart, nil)
|
||||
|
||||
watcher := findWatcher(t, "robot-tasks")
|
||||
alerts := watcher.Check(context.Background())
|
||||
|
||||
found := false
|
||||
for _, a := range alerts {
|
||||
if a.Target == "execution:"+watcherTestPrefix+"zombie_001" {
|
||||
found = true
|
||||
assert.Equal(t, monitor.Warn, a.Level)
|
||||
assert.Contains(t, a.Message, "zombie")
|
||||
assert.NotNil(t, a.Action)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "should detect zombie running execution")
|
||||
})
|
||||
|
||||
t.Run("ignores_recent_running_execution", func(t *testing.T) {
|
||||
cleanupWatcherData(t)
|
||||
defer cleanupWatcherData(t)
|
||||
|
||||
insertWatcherRobot(t, watcherTestPrefix+"member_002", "team_w")
|
||||
|
||||
m := startWatcherManager(t)
|
||||
defer m.Stop()
|
||||
|
||||
// Insert AFTER start so recovery doesn't mark it failed
|
||||
recentStart := time.Now().Add(-30 * time.Minute)
|
||||
insertWatcherExec(t, watcherTestPrefix+"recent_001", watcherTestPrefix+"member_002", "team_w", "running", &recentStart, nil)
|
||||
|
||||
watcher := findWatcher(t, "robot-tasks")
|
||||
alerts := watcher.Check(context.Background())
|
||||
|
||||
for _, a := range alerts {
|
||||
assert.NotEqual(t, "execution:"+watcherTestPrefix+"recent_001", a.Target,
|
||||
"should not alert for recent running execution")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detects_waiting_timeout", func(t *testing.T) {
|
||||
cleanupWatcherData(t)
|
||||
defer cleanupWatcherData(t)
|
||||
|
||||
insertWatcherRobot(t, watcherTestPrefix+"member_003", "team_w")
|
||||
|
||||
m := startWatcherManager(t)
|
||||
defer m.Stop()
|
||||
|
||||
startTime := time.Now().Add(-25 * time.Hour)
|
||||
oldUpdated := time.Now().Add(-25 * time.Hour)
|
||||
insertWatcherExec(t, watcherTestPrefix+"wait_001", watcherTestPrefix+"member_003", "team_w", "waiting", &startTime, &oldUpdated)
|
||||
|
||||
watcher := findWatcher(t, "robot-tasks")
|
||||
alerts := watcher.Check(context.Background())
|
||||
|
||||
found := false
|
||||
for _, a := range alerts {
|
||||
if a.Target == "execution:"+watcherTestPrefix+"wait_001" {
|
||||
found = true
|
||||
assert.Equal(t, monitor.Warn, a.Level)
|
||||
assert.Contains(t, a.Message, "waiting")
|
||||
assert.NotNil(t, a.Action)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "should detect waiting timeout")
|
||||
})
|
||||
|
||||
t.Run("ignores_recent_waiting_execution", func(t *testing.T) {
|
||||
cleanupWatcherData(t)
|
||||
defer cleanupWatcherData(t)
|
||||
|
||||
insertWatcherRobot(t, watcherTestPrefix+"member_004", "team_w")
|
||||
|
||||
m := startWatcherManager(t)
|
||||
defer m.Stop()
|
||||
|
||||
startTime := time.Now().Add(-1 * time.Hour)
|
||||
recentUpdated := time.Now().Add(-30 * time.Minute)
|
||||
insertWatcherExec(t, watcherTestPrefix+"wait_002", watcherTestPrefix+"member_004", "team_w", "waiting", &startTime, &recentUpdated)
|
||||
|
||||
watcher := findWatcher(t, "robot-tasks")
|
||||
alerts := watcher.Check(context.Background())
|
||||
|
||||
for _, a := range alerts {
|
||||
assert.NotEqual(t, "execution:"+watcherTestPrefix+"wait_002", a.Target,
|
||||
"should not alert for recent waiting execution")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detects_confirming_timeout", func(t *testing.T) {
|
||||
cleanupWatcherData(t)
|
||||
defer cleanupWatcherData(t)
|
||||
|
||||
insertWatcherRobot(t, watcherTestPrefix+"member_005", "team_w")
|
||||
|
||||
m := startWatcherManager(t)
|
||||
defer m.Stop()
|
||||
|
||||
startTime := time.Now().Add(-2 * time.Hour)
|
||||
oldUpdated := time.Now().Add(-2 * time.Hour)
|
||||
insertWatcherExec(t, watcherTestPrefix+"conf_001", watcherTestPrefix+"member_005", "team_w", "confirming", &startTime, &oldUpdated)
|
||||
|
||||
watcher := findWatcher(t, "robot-tasks")
|
||||
alerts := watcher.Check(context.Background())
|
||||
|
||||
found := false
|
||||
for _, a := range alerts {
|
||||
if a.Target == "execution:"+watcherTestPrefix+"conf_001" {
|
||||
found = true
|
||||
assert.Equal(t, monitor.Info, a.Level)
|
||||
assert.Contains(t, a.Message, "confirming")
|
||||
assert.NotNil(t, a.Action)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "should detect confirming timeout")
|
||||
})
|
||||
|
||||
t.Run("returns_empty_when_no_issues", func(t *testing.T) {
|
||||
cleanupWatcherData(t)
|
||||
defer cleanupWatcherData(t)
|
||||
|
||||
insertWatcherRobot(t, watcherTestPrefix+"member_006", "team_w")
|
||||
|
||||
m := startWatcherManager(t)
|
||||
defer m.Stop()
|
||||
|
||||
startTime := time.Now().Add(-1 * time.Hour)
|
||||
insertWatcherExec(t, watcherTestPrefix+"done_001", watcherTestPrefix+"member_006", "team_w", "completed", &startTime, nil)
|
||||
insertWatcherExec(t, watcherTestPrefix+"done_002", watcherTestPrefix+"member_006", "team_w", "failed", &startTime, nil)
|
||||
|
||||
watcher := findWatcher(t, "robot-tasks")
|
||||
alerts := watcher.Check(context.Background())
|
||||
|
||||
for _, a := range alerts {
|
||||
assert.NotContains(t, a.Target, watcherTestPrefix,
|
||||
"should not have alerts for terminal-state executions")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles_nil_manager_gracefully", func(t *testing.T) {
|
||||
cleanupWatcherData(t)
|
||||
defer cleanupWatcherData(t)
|
||||
|
||||
// Do NOT start a manager — api.GetManager() returns nil
|
||||
api.SetManager(nil)
|
||||
|
||||
watcher := findWatcher(t, "robot-tasks")
|
||||
assert.NotPanics(t, func() {
|
||||
alerts := watcher.Check(context.Background())
|
||||
assert.Empty(t, alerts)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
func startWatcherManager(t *testing.T) *manager.Manager {
|
||||
t.Helper()
|
||||
m := manager.New()
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
api.SetManager(m)
|
||||
return m
|
||||
}
|
||||
|
||||
func findWatcher(t *testing.T, name string) monitor.Watcher {
|
||||
t.Helper()
|
||||
w := monitor.GetWatcher(name)
|
||||
require.NotNil(t, w, "watcher %q should be registered", name)
|
||||
return w
|
||||
}
|
||||
|
||||
func insertWatcherExec(t *testing.T, execID, memberID, teamID, status string, startTime *time.Time, updatedAt *time.Time) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
execStore := store.NewExecutionStore()
|
||||
|
||||
record := &store.ExecutionRecord{
|
||||
ExecutionID: execID,
|
||||
MemberID: memberID,
|
||||
TeamID: teamID,
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecStatus(status),
|
||||
Phase: types.PhaseRun,
|
||||
StartTime: startTime,
|
||||
}
|
||||
|
||||
err := execStore.Save(ctx, record)
|
||||
require.NoError(t, err, "insert execution %s", execID)
|
||||
|
||||
if updatedAt != nil {
|
||||
mod := model.Select("__yao.agent.execution")
|
||||
require.NotNil(t, mod)
|
||||
tableName := mod.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
_, err := qb.Table(tableName).
|
||||
Where("execution_id", execID).
|
||||
Update(map[string]interface{}{"updated_at": updatedAt.Format("2006-01-02 15:04:05")})
|
||||
require.NoError(t, err, "update updated_at for %s", execID)
|
||||
}
|
||||
}
|
||||
|
||||
func insertWatcherRobot(t *testing.T, memberID, teamID string) {
|
||||
t.Helper()
|
||||
mod := model.Select("__yao.member")
|
||||
tableName := mod.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
|
||||
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
||||
{
|
||||
"member_id": memberID,
|
||||
"team_id": teamID,
|
||||
"member_type": "robot",
|
||||
"display_name": "Watcher Test " + memberID,
|
||||
"status": "active",
|
||||
"role_id": "member",
|
||||
"autonomous_mode": true,
|
||||
"robot_status": "idle",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "insert robot %s", memberID)
|
||||
}
|
||||
|
||||
func cleanupWatcherData(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
execMod := model.Select("__yao.agent.execution")
|
||||
execTable := execMod.MetaData.Table.Name
|
||||
qb := capsule.Query()
|
||||
qb.Table(execTable).Where("execution_id", "like", watcherTestPrefix+"%").Delete()
|
||||
|
||||
memberMod := model.Select("__yao.member")
|
||||
memberTable := memberMod.MetaData.Table.Name
|
||||
qb.Table(memberTable).Where("member_id", "like", watcherTestPrefix+"%").Delete()
|
||||
memberMod.DeleteWhere(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "member_id", OP: "like", Value: watcherTestPrefix + "%"},
|
||||
},
|
||||
})
|
||||
}
|
||||
4
go.mod
4
go.mod
|
|
@ -50,7 +50,7 @@ require (
|
|||
golang.org/x/net v0.50.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
|
@ -220,7 +220,7 @@ require (
|
|||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect
|
||||
golang.org/x/image v0.29.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -559,8 +559,8 @@ golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
|||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -648,8 +648,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:
|
|||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ import (
|
|||
"github.com/yaoapp/yao/attachment"
|
||||
)
|
||||
|
||||
// SendTyping is a placeholder for typing indicator support.
|
||||
// Feishu does not provide a public typing status API; this is a no-op
|
||||
// so callers can use a uniform interface across all adapters.
|
||||
func (b *Bot) SendTyping(ctx context.Context, chatID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendTextMessage sends a text message to a chat.
|
||||
func (b *Bot) SendTextMessage(ctx context.Context, chatID, text string) (string, error) {
|
||||
content, _ := json.Marshal(map[string]string{"text": text})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,19 @@ import (
|
|||
"github.com/yaoapp/yao/attachment"
|
||||
)
|
||||
|
||||
// SendTyping sends a "typing" chat action to indicate the bot is preparing a response.
|
||||
func (b *Bot) SendTyping(ctx context.Context, chatID int64) error {
|
||||
sdk, err := b.sdk()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = sdk.SendChatAction(ctx, &bot.SendChatActionParams{
|
||||
ChatID: chatID,
|
||||
Action: models.ChatActionTyping,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SendMessage sends a message to a chat. If the text contains Markdown formatting,
|
||||
// it is automatically converted to Telegram-compatible HTML.
|
||||
func (b *Bot) SendMessage(ctx context.Context, chatID int64, text string, replyTo int64) error {
|
||||
|
|
|
|||
376
integrations/weixin/bot.go
Normal file
376
integrations/weixin/bot.go
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/kun/log"
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
const defaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
const channelVersion = "1.0.0"
|
||||
|
||||
const (
|
||||
UploadMediaImage = 1
|
||||
UploadMediaVideo = 2
|
||||
UploadMediaFile = 3
|
||||
UploadMediaVoice = 4
|
||||
)
|
||||
|
||||
const cdnUploadMaxRetries = 3
|
||||
|
||||
type Bot struct {
|
||||
token string
|
||||
baseURL string
|
||||
cdnBaseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewBot(token, baseURL, cdnBaseURL string) *Bot {
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
if cdnBaseURL == "" {
|
||||
cdnBaseURL = defaultCDNBaseURL
|
||||
}
|
||||
return &Bot{
|
||||
token: token,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
cdnBaseURL: strings.TrimRight(cdnBaseURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) Token() string { return b.token }
|
||||
func (b *Bot) BaseURL() string { return b.baseURL }
|
||||
func (b *Bot) CDNBaseURL() string { return b.cdnBaseURL }
|
||||
func DefaultBaseURL() string { return defaultBaseURL }
|
||||
func DefaultCDNBaseURL() string { return defaultCDNBaseURL }
|
||||
|
||||
func (b *Bot) GetUpdates(ctx context.Context, syncBuf string, timeoutMs int) (*GetUpdatesResp, error) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"get_updates_buf": syncBuf,
|
||||
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||
})
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs+5000)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
raw, err := b.post(reqCtx, "ilink/bot/getupdates", body)
|
||||
if err != nil {
|
||||
if reqCtx.Err() != nil {
|
||||
return &GetUpdatesResp{GetUpdatesBuf: syncBuf}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp GetUpdatesResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("weixin GetUpdates unmarshal: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (b *Bot) SendMessage(ctx context.Context, toUserID, contextToken, text string) error {
|
||||
if contextToken == "" {
|
||||
return fmt.Errorf("weixin SendMessage: contextToken is required for to=%s", toUserID)
|
||||
}
|
||||
clientID := randomClientID()
|
||||
req := map[string]interface{}{
|
||||
"msg": map[string]interface{}{
|
||||
"from_user_id": "",
|
||||
"to_user_id": toUserID,
|
||||
"client_id": clientID,
|
||||
"message_type": MessageTypeBot,
|
||||
"message_state": MessageStateFinish,
|
||||
"context_token": contextToken,
|
||||
"item_list": []map[string]interface{}{
|
||||
{
|
||||
"type": ItemTypeText,
|
||||
"text_item": map[string]string{"text": text},
|
||||
},
|
||||
},
|
||||
},
|
||||
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
_, err := b.post(ctx, "ilink/bot/sendmessage", body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bot) SendImageMessage(ctx context.Context, toUserID, contextToken string, uploaded *UploadedFileInfo) error {
|
||||
return b.sendMediaMessage(ctx, toUserID, contextToken, MsgItem{
|
||||
Type: ItemTypeImage,
|
||||
ImageItem: &ImageItem{
|
||||
Media: &CDNMedia{
|
||||
EncryptQueryParam: uploaded.DownloadParam,
|
||||
AesKey: base64.StdEncoding.EncodeToString([]byte(uploaded.AesKeyHex)),
|
||||
EncryptType: 1,
|
||||
},
|
||||
MidSize: uploaded.FileSizeCiphertext,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bot) SendVideoMessage(ctx context.Context, toUserID, contextToken string, uploaded *UploadedFileInfo) error {
|
||||
return b.sendMediaMessage(ctx, toUserID, contextToken, MsgItem{
|
||||
Type: ItemTypeVideo,
|
||||
VideoItem: &VideoItem{
|
||||
Media: &CDNMedia{
|
||||
EncryptQueryParam: uploaded.DownloadParam,
|
||||
AesKey: base64.StdEncoding.EncodeToString([]byte(uploaded.AesKeyHex)),
|
||||
EncryptType: 1,
|
||||
},
|
||||
VideoSize: uploaded.FileSizeCiphertext,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bot) SendFileMessage(ctx context.Context, toUserID, contextToken, fileName string, uploaded *UploadedFileInfo) error {
|
||||
return b.sendMediaMessage(ctx, toUserID, contextToken, MsgItem{
|
||||
Type: ItemTypeFile,
|
||||
FileItem: &FileItem{
|
||||
FileName: fileName,
|
||||
Media: &CDNMedia{
|
||||
EncryptQueryParam: uploaded.DownloadParam,
|
||||
AesKey: base64.StdEncoding.EncodeToString([]byte(uploaded.AesKeyHex)),
|
||||
EncryptType: 1,
|
||||
},
|
||||
Len: strconv.Itoa(uploaded.FileSize),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SendVoiceMessage sends a voice message with a bubble UI.
|
||||
// TODO(weixin-voice): The voice bubble displays correctly (with playtime) but
|
||||
// audio playback does not work — the WeChat client reports "message still
|
||||
// downloading". This affects all formats tested (SILK, Speex, OGG, MP3) and
|
||||
// even echoing back an inbound voice's CDN reference verbatim. The iLink Bot
|
||||
// API likely does not yet fully support outbound voice playback. For now,
|
||||
// callers should fall back to SendFileMessage for audio attachments until
|
||||
// WeChat officially supports voice playback via iLink Bot.
|
||||
func (b *Bot) SendVoiceMessage(ctx context.Context, toUserID, contextToken string, uploaded *UploadedFileInfo, playtimeMs, sampleRate int) error {
|
||||
item := MsgItem{
|
||||
Type: ItemTypeVoice,
|
||||
VoiceItem: &VoiceItem{
|
||||
Media: &CDNMedia{
|
||||
EncryptQueryParam: uploaded.DownloadParam,
|
||||
AesKey: base64.StdEncoding.EncodeToString([]byte(uploaded.AesKeyHex)),
|
||||
},
|
||||
PlayTime: playtimeMs,
|
||||
SampleRate: sampleRate,
|
||||
},
|
||||
}
|
||||
return b.sendMediaMessage(ctx, toUserID, contextToken, item)
|
||||
}
|
||||
|
||||
func (b *Bot) sendMediaMessage(ctx context.Context, toUserID, contextToken string, item MsgItem) error {
|
||||
if contextToken == "" {
|
||||
return fmt.Errorf("weixin sendMediaMessage: contextToken is required for to=%s", toUserID)
|
||||
}
|
||||
clientID := randomClientID()
|
||||
req := map[string]interface{}{
|
||||
"msg": map[string]interface{}{
|
||||
"from_user_id": "",
|
||||
"to_user_id": toUserID,
|
||||
"client_id": clientID,
|
||||
"message_type": MessageTypeBot,
|
||||
"message_state": MessageStateFinish,
|
||||
"context_token": contextToken,
|
||||
"item_list": []MsgItem{item},
|
||||
},
|
||||
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
_, err := b.post(ctx, "ilink/bot/sendmessage", body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bot) UploadMedia(ctx context.Context, plaintext []byte, toUserID string, mediaType int) (*UploadedFileInfo, error) {
|
||||
rawsize := len(plaintext)
|
||||
hash := md5.Sum(plaintext)
|
||||
rawfilemd5 := hex.EncodeToString(hash[:])
|
||||
filesize := aesEcbPaddedSize(rawsize)
|
||||
|
||||
var filekeyBuf [16]byte
|
||||
rand.Read(filekeyBuf[:])
|
||||
filekey := hex.EncodeToString(filekeyBuf[:])
|
||||
|
||||
var aeskeyBuf [16]byte
|
||||
rand.Read(aeskeyBuf[:])
|
||||
aeskeyHex := hex.EncodeToString(aeskeyBuf[:])
|
||||
|
||||
uploadReq, _ := json.Marshal(map[string]interface{}{
|
||||
"filekey": filekey,
|
||||
"media_type": mediaType,
|
||||
"to_user_id": toUserID,
|
||||
"rawsize": rawsize,
|
||||
"rawfilemd5": rawfilemd5,
|
||||
"filesize": filesize,
|
||||
"no_need_thumb": true,
|
||||
"aeskey": aeskeyHex,
|
||||
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||
})
|
||||
|
||||
log.Info("[weixin:upload] getuploadurl request: media_type=%d to_user_id=%s rawsize=%d filesize=%d filekey=%s md5=%s",
|
||||
mediaType, toUserID, rawsize, filesize, filekey, rawfilemd5)
|
||||
|
||||
raw, err := b.post(ctx, "ilink/bot/getuploadurl", uploadReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getUploadUrl: %w", err)
|
||||
}
|
||||
var uploadResp GetUploadUrlResp
|
||||
if err := json.Unmarshal(raw, &uploadResp); err != nil {
|
||||
return nil, fmt.Errorf("getUploadUrl unmarshal: %w (body: %s)", err, string(raw))
|
||||
}
|
||||
|
||||
log.Info("[weixin:upload] getuploadurl response: ret=%d errcode=%d errmsg=%q upload_param_len=%d",
|
||||
uploadResp.Ret, uploadResp.ErrCode, uploadResp.ErrMsg, len(uploadResp.UploadParam))
|
||||
|
||||
if uploadResp.Ret != 0 || uploadResp.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("getUploadUrl: ret=%d errcode=%d errmsg=%q media_type=%d to_user_id=%s rawsize=%d filesize=%d rawfilemd5=%s",
|
||||
uploadResp.Ret, uploadResp.ErrCode, uploadResp.ErrMsg, mediaType, toUserID, rawsize, filesize, rawfilemd5)
|
||||
}
|
||||
if uploadResp.UploadParam == "" {
|
||||
return nil, fmt.Errorf("getUploadUrl: empty upload_param (body: %s)", string(raw))
|
||||
}
|
||||
|
||||
ciphertext := encryptAES128ECB(plaintext, aeskeyBuf[:])
|
||||
log.Info("[weixin:upload] CDN uploading: ciphertext_len=%d filekey=%s", len(ciphertext), filekey)
|
||||
|
||||
downloadParam, err := b.uploadBufferToCDN(ctx, ciphertext, uploadResp.UploadParam, filekey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CDN upload: %w", err)
|
||||
}
|
||||
log.Info("[weixin:upload] CDN success: download_param_len=%d", len(downloadParam))
|
||||
|
||||
return &UploadedFileInfo{
|
||||
Filekey: filekey,
|
||||
DownloadParam: downloadParam,
|
||||
AesKeyHex: aeskeyHex,
|
||||
FileSize: rawsize,
|
||||
FileSizeCiphertext: filesize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Bot) uploadBufferToCDN(ctx context.Context, ciphertext []byte, uploadParam, filekey string) (string, error) {
|
||||
cdnURL := b.cdnBaseURL + "/upload?encrypted_query_param=" +
|
||||
url.QueryEscape(uploadParam) + "&filekey=" + url.QueryEscape(filekey)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= cdnUploadMaxRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(ciphertext))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
resp, err := b.httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("CDN upload client error %d", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("CDN upload server error %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
downloadParam := resp.Header.Get("x-encrypted-param")
|
||||
resp.Body.Close()
|
||||
if downloadParam == "" {
|
||||
lastErr = fmt.Errorf("CDN response missing x-encrypted-param")
|
||||
continue
|
||||
}
|
||||
return downloadParam, nil
|
||||
}
|
||||
return "", fmt.Errorf("CDN upload failed after %d attempts: %w", cdnUploadMaxRetries, lastErr)
|
||||
}
|
||||
|
||||
func randomClientID() string {
|
||||
var buf [8]byte
|
||||
rand.Read(buf[:])
|
||||
return fmt.Sprintf("yao-weixin-%x", buf[:])
|
||||
}
|
||||
|
||||
func (b *Bot) SendTyping(ctx context.Context, toUserID, typingTicket string, status int) error {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"ilink_user_id": toUserID,
|
||||
"typing_ticket": typingTicket,
|
||||
"status": status,
|
||||
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||
})
|
||||
_, err := b.post(ctx, "ilink/bot/sendtyping", body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bot) GetConfig(ctx context.Context, ilinkUserID, contextToken string) (string, error) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"ilink_user_id": ilinkUserID,
|
||||
"context_token": contextToken,
|
||||
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||
})
|
||||
raw, err := b.post(ctx, "ilink/bot/getconfig", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp GetConfigResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return "", fmt.Errorf("weixin GetConfig unmarshal: %w", err)
|
||||
}
|
||||
return resp.TypingTicket, nil
|
||||
}
|
||||
|
||||
func (b *Bot) post(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
|
||||
reqURL := b.baseURL + "/" + endpoint
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", HeaderAuthVal)
|
||||
req.Header.Set("Authorization", "Bearer "+b.token)
|
||||
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
req.Header.Set("X-WECHAT-UIN", randomWechatUin())
|
||||
|
||||
resp, err := b.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin %s read body: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("weixin %s HTTP %d: %s", endpoint, resp.StatusCode, string(raw))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func randomWechatUin() string {
|
||||
var buf [4]byte
|
||||
rand.Read(buf[:])
|
||||
n := binary.BigEndian.Uint32(buf[:])
|
||||
return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(n), 10)))
|
||||
}
|
||||
126
integrations/weixin/cdn.go
Normal file
126
integrations/weixin/cdn.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func buildCDNDownloadURL(cdnBaseURL, encryptedQueryParam string) string {
|
||||
return cdnBaseURL + "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
|
||||
}
|
||||
|
||||
func parseAesKey(aesKeyBase64 string) ([]byte, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parseAesKey: base64 decode: %w", err)
|
||||
}
|
||||
if len(decoded) == 16 {
|
||||
return decoded, nil
|
||||
}
|
||||
if len(decoded) == 32 {
|
||||
hexStr := string(decoded)
|
||||
raw := make([]byte, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
var b byte
|
||||
_, err := fmt.Sscanf(hexStr[i*2:i*2+2], "%02x", &b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parseAesKey: hex parse: %w", err)
|
||||
}
|
||||
raw[i] = b
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
return nil, fmt.Errorf("parseAesKey: unexpected decoded len=%d", len(decoded))
|
||||
}
|
||||
|
||||
func decryptAES128ECB(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bs := block.BlockSize()
|
||||
if len(ciphertext)%bs != 0 {
|
||||
return nil, fmt.Errorf("decryptAES128ECB: ciphertext len %d not multiple of block size", len(ciphertext))
|
||||
}
|
||||
dst := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += bs {
|
||||
block.Decrypt(dst[i:i+bs], ciphertext[i:i+bs])
|
||||
}
|
||||
if len(dst) == 0 {
|
||||
return dst, nil
|
||||
}
|
||||
padLen := int(dst[len(dst)-1])
|
||||
if padLen == 0 || padLen > bs {
|
||||
return nil, fmt.Errorf("decryptAES128ECB: invalid PKCS7 padding %d", padLen)
|
||||
}
|
||||
return dst[:len(dst)-padLen], nil
|
||||
}
|
||||
|
||||
func downloadCDNBytes(cdnBaseURL, encryptedQueryParam string) ([]byte, error) {
|
||||
u := buildCDNDownloadURL(cdnBaseURL, encryptedQueryParam)
|
||||
resp, err := http.Get(u) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CDN download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("CDN download HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func DownloadAndDecrypt(cdnBaseURL, encryptedQueryParam, aesKeyBase64 string) ([]byte, error) {
|
||||
key, err := parseAesKey(aesKeyBase64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := downloadCDNBytes(cdnBaseURL, encryptedQueryParam)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decryptAES128ECB(data, key)
|
||||
}
|
||||
|
||||
func DecryptFromRaw(cdnBaseURL, encryptedQueryParam string, rawKey []byte) ([]byte, error) {
|
||||
data, err := downloadCDNBytes(cdnBaseURL, encryptedQueryParam)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decryptAES128ECB(data, rawKey)
|
||||
}
|
||||
|
||||
func DownloadPlain(cdnBaseURL, encryptedQueryParam string) ([]byte, error) {
|
||||
return downloadCDNBytes(cdnBaseURL, encryptedQueryParam)
|
||||
}
|
||||
|
||||
func encryptAES128ECB(plaintext, key []byte) []byte {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
bs := block.BlockSize()
|
||||
padLen := bs - (len(plaintext) % bs)
|
||||
padded := make([]byte, len(plaintext)+padLen)
|
||||
copy(padded, plaintext)
|
||||
for i := len(plaintext); i < len(padded); i++ {
|
||||
padded[i] = byte(padLen)
|
||||
}
|
||||
dst := make([]byte, len(padded))
|
||||
for i := 0; i < len(padded); i += bs {
|
||||
block.Encrypt(dst[i:i+bs], padded[i:i+bs])
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func aesEcbPaddedSize(plaintextSize int) int {
|
||||
return ((plaintextSize + 1 + 15) / 16) * 16
|
||||
}
|
||||
|
||||
func buildCDNUploadURL(cdnBaseURL, uploadParam, filekey string) string {
|
||||
return cdnBaseURL + "/upload?encrypted_query_param=" +
|
||||
url.QueryEscape(uploadParam) + "&filekey=" + url.QueryEscape(filekey)
|
||||
}
|
||||
226
integrations/weixin/format.go
Normal file
226
integrations/weixin/format.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// FormatWeixinText converts standard Markdown to plain text suitable for
|
||||
// WeChat iLink Bot's text_item. WeChat renders only plain text with clickable
|
||||
// URLs and [text](url) style links. All other Markdown/HTML is stripped and
|
||||
// gracefully degraded to readable plain text.
|
||||
func FormatWeixinText(md string) string {
|
||||
md = strings.ReplaceAll(md, "\r\n", "\n")
|
||||
|
||||
var out strings.Builder
|
||||
lines := strings.Split(md, "\n")
|
||||
|
||||
inCodeBlock := false
|
||||
var codeLines []string
|
||||
|
||||
inTable := false
|
||||
var tableRows [][]string
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
|
||||
if strings.HasPrefix(line, "```") {
|
||||
if !inCodeBlock {
|
||||
inCodeBlock = true
|
||||
codeLines = nil
|
||||
} else {
|
||||
inCodeBlock = false
|
||||
for _, cl := range codeLines {
|
||||
out.WriteString(" " + cl + "\n")
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inCodeBlock {
|
||||
codeLines = append(codeLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
if wxIsTableRow(line) {
|
||||
if !inTable {
|
||||
inTable = true
|
||||
tableRows = nil
|
||||
}
|
||||
if wxIsTableSep(line) {
|
||||
continue
|
||||
}
|
||||
tableRows = append(tableRows, wxParseTableRow(line))
|
||||
continue
|
||||
}
|
||||
if inTable {
|
||||
wxFlushTable(&out, tableRows)
|
||||
inTable = false
|
||||
tableRows = nil
|
||||
}
|
||||
|
||||
if line == "---" || line == "***" || line == "___" {
|
||||
out.WriteString("——————\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if m := wxReHeading.FindStringSubmatch(line); m != nil {
|
||||
out.WriteString("【" + wxFormatInline(m[2]) + "】\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if m := wxReBlockquote.FindStringSubmatch(line); m != nil {
|
||||
out.WriteString("│ " + wxFormatInline(m[1]) + "\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if m := wxReUnorderedList.FindStringSubmatch(line); m != nil {
|
||||
out.WriteString("• " + wxFormatInline(m[1]) + "\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if m := wxReOrderedList.FindStringSubmatch(line); m != nil {
|
||||
out.WriteString(m[1] + ". " + wxFormatInline(m[2]) + "\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if m := wxReImage.FindStringSubmatch(line); m != nil {
|
||||
alt := m[1]
|
||||
url := m[2]
|
||||
if alt != "" {
|
||||
out.WriteString("[" + alt + "](" + url + ")\n")
|
||||
} else {
|
||||
out.WriteString(url + "\n")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteString(wxFormatInline(line) + "\n")
|
||||
}
|
||||
|
||||
if inCodeBlock && len(codeLines) > 0 {
|
||||
for _, cl := range codeLines {
|
||||
out.WriteString(" " + cl + "\n")
|
||||
}
|
||||
}
|
||||
if inTable {
|
||||
wxFlushTable(&out, tableRows)
|
||||
}
|
||||
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
var (
|
||||
wxReHeading = regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
|
||||
wxReBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
|
||||
wxReUnorderedList = regexp.MustCompile(`^[\s]*[-*+]\s+(.+)$`)
|
||||
wxReOrderedList = regexp.MustCompile(`^[\s]*(\d+)[.)]\s+(.+)$`)
|
||||
wxReImage = regexp.MustCompile(`^!\[([^\]]*)\]\(([^)]+)\)$`)
|
||||
wxReTableRow = regexp.MustCompile(`^\|.*\|$`)
|
||||
wxReTableSep = regexp.MustCompile(`^\|[\s\-:|]+\|$`)
|
||||
|
||||
wxReBoldItalic = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`)
|
||||
wxReBold = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
||||
wxReBoldAlt = regexp.MustCompile(`__(.+?)__`)
|
||||
wxReItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+?)\*(?:[^*]|$)`)
|
||||
wxReStrikethrough = regexp.MustCompile(`~~(.+?)~~`)
|
||||
wxReCode = regexp.MustCompile("`([^`]+)`")
|
||||
wxReLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
|
||||
wxReHTMLTag = regexp.MustCompile(`<[^>]+>`)
|
||||
)
|
||||
|
||||
// wxFormatInline strips inline Markdown/HTML formatting, keeping links in
|
||||
// [text](url) form which WeChat renders as clickable.
|
||||
func wxFormatInline(s string) string {
|
||||
s = wxReLink.ReplaceAllString(s, "[$1]($2)")
|
||||
s = wxReBoldItalic.ReplaceAllString(s, "$1")
|
||||
s = wxReBold.ReplaceAllString(s, "$1")
|
||||
s = wxReBoldAlt.ReplaceAllString(s, "$1")
|
||||
s = wxReStrikethrough.ReplaceAllString(s, "$1")
|
||||
s = wxReCode.ReplaceAllString(s, "$1")
|
||||
s = wxReItalic.ReplaceAllStringFunc(s, func(match string) string {
|
||||
m := wxReItalic.FindStringSubmatch(match)
|
||||
if len(m) < 2 {
|
||||
return match
|
||||
}
|
||||
prefix := ""
|
||||
suffix := ""
|
||||
if len(match) > 0 && match[0] != '*' {
|
||||
prefix = string(match[0])
|
||||
}
|
||||
if len(match) > 0 && match[len(match)-1] != '*' {
|
||||
suffix = string(match[len(match)-1])
|
||||
}
|
||||
return prefix + m[1] + suffix
|
||||
})
|
||||
s = wxReHTMLTag.ReplaceAllString(s, "")
|
||||
return s
|
||||
}
|
||||
|
||||
func wxIsTableRow(line string) bool {
|
||||
return wxReTableRow.MatchString(strings.TrimSpace(line))
|
||||
}
|
||||
|
||||
func wxIsTableSep(line string) bool {
|
||||
return wxReTableSep.MatchString(strings.TrimSpace(line))
|
||||
}
|
||||
|
||||
func wxParseTableRow(line string) []string {
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.TrimPrefix(line, "|")
|
||||
line = strings.TrimSuffix(line, "|")
|
||||
cells := strings.Split(line, "|")
|
||||
for i := range cells {
|
||||
cells[i] = strings.TrimSpace(cells[i])
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func wxFlushTable(out *strings.Builder, rows [][]string) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
colWidths := make([]int, len(rows[0]))
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i < len(colWidths) && utf8.RuneCountInString(cell) > colWidths[i] {
|
||||
colWidths[i] = utf8.RuneCountInString(cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
for ri, row := range rows {
|
||||
for ci, cell := range row {
|
||||
if ci > 0 {
|
||||
out.WriteString(" | ")
|
||||
}
|
||||
w := 0
|
||||
if ci < len(colWidths) {
|
||||
w = colWidths[ci]
|
||||
}
|
||||
out.WriteString(wxPadRight(cell, w))
|
||||
}
|
||||
out.WriteString("\n")
|
||||
if ri == 0 && len(rows) > 1 {
|
||||
for ci := range row {
|
||||
if ci > 0 {
|
||||
out.WriteString("-+-")
|
||||
}
|
||||
w := 0
|
||||
if ci < len(colWidths) {
|
||||
w = colWidths[ci]
|
||||
}
|
||||
out.WriteString(strings.Repeat("-", w))
|
||||
}
|
||||
out.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func wxPadRight(s string, width int) string {
|
||||
runes := utf8.RuneCountInString(s)
|
||||
if runes >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-runes)
|
||||
}
|
||||
47
integrations/weixin/mime.go
Normal file
47
integrations/weixin/mime.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var mimeMap = map[string]string{
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".silk": "audio/silk",
|
||||
".amr": "audio/amr",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".avi": "video/x-msvideo",
|
||||
".webm": "video/webm",
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".zip": "application/zip",
|
||||
".gz": "application/gzip",
|
||||
".tar": "application/x-tar",
|
||||
".txt": "text/plain",
|
||||
".csv": "text/csv",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
}
|
||||
|
||||
func MimeFromFilename(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if m, ok := mimeMap[ext]; ok {
|
||||
return m
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
75
integrations/weixin/qrcode.go
Normal file
75
integrations/weixin/qrcode.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultBotType = "3"
|
||||
|
||||
func GetQRCode(ctx context.Context, apiHost string) (qrcode, qrcodeImgURL string, err error) {
|
||||
if apiHost == "" {
|
||||
apiHost = defaultBaseURL
|
||||
}
|
||||
u := strings.TrimRight(apiHost, "/") + "/ilink/bot/get_bot_qrcode?bot_type=" + DefaultBotType
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("GetQRCode: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("GetQRCode HTTP %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var r QRCodeResp
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return "", "", fmt.Errorf("GetQRCode unmarshal: %w", err)
|
||||
}
|
||||
return r.QRCode, r.QRCodeImgContent, nil
|
||||
}
|
||||
|
||||
func PollQRStatus(ctx context.Context, apiHost, qrcode string) (*QRStatusResp, error) {
|
||||
if apiHost == "" {
|
||||
apiHost = defaultBaseURL
|
||||
}
|
||||
u := fmt.Sprintf("%s/ilink/bot/get_qrcode_status?qrcode=%s",
|
||||
strings.TrimRight(apiHost, "/"), qrcode)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("iLink-App-ClientVersion", "1")
|
||||
|
||||
client := &http.Client{Timeout: 35 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PollQRStatus: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("PollQRStatus HTTP %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var r QRStatusResp
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, fmt.Errorf("PollQRStatus unmarshal: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
163
integrations/weixin/types.go
Normal file
163
integrations/weixin/types.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package weixin
|
||||
|
||||
const (
|
||||
HeaderAuthType = "AuthorizationType"
|
||||
HeaderAuthVal = "ilink_bot_token"
|
||||
)
|
||||
|
||||
const SessionExpiredErrCode = -14
|
||||
|
||||
const (
|
||||
ItemTypeText = 1
|
||||
ItemTypeImage = 2
|
||||
ItemTypeVoice = 3
|
||||
ItemTypeFile = 4
|
||||
ItemTypeVideo = 5
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeNone = 0
|
||||
MessageTypeUser = 1
|
||||
MessageTypeBot = 2
|
||||
)
|
||||
|
||||
const (
|
||||
MessageStateNew = 0
|
||||
MessageStateGenerating = 1
|
||||
MessageStateFinish = 2
|
||||
)
|
||||
|
||||
const (
|
||||
TypingStatusTyping = 1
|
||||
TypingStatusCancel = 2
|
||||
)
|
||||
|
||||
type BaseInfo struct {
|
||||
ChannelVersion string `json:"channel_version,omitempty"`
|
||||
}
|
||||
|
||||
type TextItem struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type CDNMedia struct {
|
||||
EncryptQueryParam string `json:"encrypt_query_param"`
|
||||
AesKey string `json:"aes_key"`
|
||||
EncryptType int `json:"encrypt_type,omitempty"`
|
||||
}
|
||||
|
||||
type UploadedFileInfo struct {
|
||||
Filekey string `json:"filekey"`
|
||||
DownloadParam string `json:"download_encrypted_query_param"`
|
||||
AesKeyHex string `json:"aeskey"`
|
||||
FileSize int `json:"file_size"`
|
||||
FileSizeCiphertext int `json:"file_size_ciphertext"`
|
||||
}
|
||||
|
||||
type GetUploadUrlResp struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
UploadParam string `json:"upload_param"`
|
||||
ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
|
||||
}
|
||||
|
||||
type ImageItem struct {
|
||||
AesKey string `json:"aeskey,omitempty"`
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
|
||||
MidSize int `json:"mid_size,omitempty"`
|
||||
HdSize int `json:"hd_size,omitempty"`
|
||||
}
|
||||
|
||||
type VoiceItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
EncodeType int `json:"encode_type,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
PlayTime int `json:"playtime,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type FileItem struct {
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
Len string `json:"len,omitempty"`
|
||||
}
|
||||
|
||||
type VideoItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
|
||||
VideoSize int `json:"video_size,omitempty"`
|
||||
}
|
||||
|
||||
type RefMessage struct {
|
||||
MessageItem *MsgItem `json:"message_item,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
type MsgItem struct {
|
||||
Type int `json:"type"`
|
||||
TextItem *TextItem `json:"text_item,omitempty"`
|
||||
ImageItem *ImageItem `json:"image_item,omitempty"`
|
||||
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
|
||||
FileItem *FileItem `json:"file_item,omitempty"`
|
||||
VideoItem *VideoItem `json:"video_item,omitempty"`
|
||||
RefMsg *RefMessage `json:"ref_msg,omitempty"`
|
||||
}
|
||||
|
||||
type WeixinMessage struct {
|
||||
Seq int64 `json:"seq,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
MessageType int `json:"message_type,omitempty"`
|
||||
MessageState int `json:"message_state,omitempty"`
|
||||
ContextToken string `json:"context_token"`
|
||||
CreateTimeMs int64 `json:"create_time_ms"`
|
||||
UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
|
||||
DeleteTimeMs int64 `json:"delete_time_ms,omitempty"`
|
||||
ItemList []MsgItem `json:"item_list"`
|
||||
}
|
||||
|
||||
type GetUpdatesResp struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
Msgs []WeixinMessage `json:"msgs"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
LongPollingTimeoutMs int `json:"longpolling_timeout_ms"`
|
||||
}
|
||||
|
||||
type SendMessageReq struct {
|
||||
Msg *WeixinMessage `json:"msg"`
|
||||
BaseInfo *BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
type SendTypingReq struct {
|
||||
IlinkUserID string `json:"ilink_user_id"`
|
||||
TypingTicket string `json:"typing_ticket"`
|
||||
Status int `json:"status"`
|
||||
BaseInfo *BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
type GetConfigResp struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
TypingTicket string `json:"typing_ticket"`
|
||||
}
|
||||
|
||||
type QRCodeResp struct {
|
||||
QRCode string `json:"qrcode"`
|
||||
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||
}
|
||||
|
||||
type QRStatusResp struct {
|
||||
Status string `json:"status"`
|
||||
BotToken string `json:"bot_token"`
|
||||
IlinkBotID string `json:"ilink_bot_id"`
|
||||
BaseURL string `json:"baseurl"`
|
||||
UserID string `json:"ilink_user_id"`
|
||||
}
|
||||
|
|
@ -35,6 +35,16 @@ type monitorService struct {
|
|||
started bool
|
||||
}
|
||||
|
||||
// GetWatcher returns a registered watcher by name, or nil if not found.
|
||||
func GetWatcher(name string) Watcher {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
if entry, ok := svc.watchers[name]; ok {
|
||||
return entry.watcher
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register adds a watcher. Call before Start (typically in init).
|
||||
// Registering a watcher with the same name replaces the previous one.
|
||||
func Register(w Watcher) {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import (
|
|||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
||||
// ListRobots lists robots with pagination and filtering
|
||||
// ListAllRobots lists robots with pagination and filtering
|
||||
// GET /v1/agent/robots
|
||||
func ListRobots(c *gin.Context) {
|
||||
func ListAllRobots(c *gin.Context) {
|
||||
// Get authorized information
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ func ListRobots(c *gin.Context) {
|
|||
ctx := &robottypes.Context{}
|
||||
|
||||
// Call API layer
|
||||
result, err := robotapi.ListRobots(ctx, query)
|
||||
result, err := robotapi.ListAllRobots(ctx, query)
|
||||
if err != nil {
|
||||
log.Error("Failed to list robots: %v", err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
group.Use(oauth.Guard)
|
||||
|
||||
// Robot CRUD - Standard REST endpoints
|
||||
group.GET("", ListRobots) // GET /robots - List robots with pagination and filtering
|
||||
group.POST("", CreateRobot) // POST /robots - Create a new robot
|
||||
group.GET("", ListAllRobots) // GET /robots - List robots with pagination and filtering
|
||||
group.POST("", CreateRobot) // POST /robots - Create a new robot
|
||||
|
||||
// Activities - Cross-robot activity feed for team (must be before /:id to avoid conflict)
|
||||
group.GET("/activities", ListActivities) // GET /robots/activities - List team activities
|
||||
|
|
@ -25,6 +25,10 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
// Integration credential verification (must be before /:id to avoid conflict)
|
||||
group.POST("/integrations/verify", VerifyIntegration) // POST /robots/integrations/verify - Verify integration credentials
|
||||
|
||||
// WeChat iLink Bot QR code login
|
||||
group.POST("/integrations/weixin/qrcode", CreateWeixinQRCode) // POST /robots/integrations/weixin/qrcode - Create QR session
|
||||
group.GET("/integrations/weixin/qrcode/:session_key", PollWeixinQRCode) // GET /robots/integrations/weixin/qrcode/:session_key - Poll QR status
|
||||
|
||||
group.GET("/:id", GetRobot) // GET /robots/:id - Get robot details
|
||||
group.PUT("/:id", UpdateRobot) // PUT /robots/:id - Update robot
|
||||
group.DELETE("/:id", DeleteRobot) // DELETE /robots/:id - Delete robot
|
||||
|
|
|
|||
72
openapi/agent/robot/weixin.go
Normal file
72
openapi/agent/robot/weixin.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
api "github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
||||
type createWeixinQRCodeRequest struct {
|
||||
APIHost string `json:"api_host"`
|
||||
}
|
||||
|
||||
// CreateWeixinQRCode handles POST /robots/integrations/weixin/qrcode
|
||||
func CreateWeixinQRCode(c *gin.Context) {
|
||||
var req createWeixinQRCodeRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
apiHost := req.APIHost
|
||||
if apiHost == "" {
|
||||
apiHost = os.Getenv("YAO_WEIXIN_API_HOST")
|
||||
}
|
||||
if apiHost == "" {
|
||||
apiHost = "https://ilinkai.weixin.qq.com"
|
||||
}
|
||||
|
||||
sessionKey, qrcodeURL, qrcodeImg, err := api.WeixinQRCodeCreate(apiHost)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, response.StatusOK, gin.H{
|
||||
"session_key": sessionKey,
|
||||
"qrcode_url": qrcodeURL,
|
||||
"qrcode_img": qrcodeImg,
|
||||
})
|
||||
}
|
||||
|
||||
// PollWeixinQRCode handles GET /robots/integrations/weixin/qrcode/:session_key
|
||||
func PollWeixinQRCode(c *gin.Context) {
|
||||
sessionKey := c.Param("session_key")
|
||||
if sessionKey == "" {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "session_key is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status, botToken, accountID, baseURL, _, err := api.WeixinQRCodePoll(sessionKey)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result := gin.H{"status": status}
|
||||
if status == "confirmed" {
|
||||
result["bot_token"] = botToken
|
||||
result["account_id"] = accountID
|
||||
result["base_url"] = baseURL
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, response.StatusOK, result)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue