Merge pull request #1501 from trheyi/main

feat(robot): add Weixin integration and enhance existing adapters
This commit is contained in:
Max 2026-03-24 08:18:22 +08:00 committed by GitHub
commit 1055ad3eaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 3533 additions and 293 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -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,
})

View 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
}

View file

@ -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")

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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"
)

View file

@ -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

View file

@ -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,
},
},
}

View file

@ -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)

View file

@ -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,
},
},
}

View file

@ -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
}

View file

@ -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",
})

View file

@ -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

View file

@ -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,
},
},
}

View file

@ -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)
}

View file

@ -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,
},
},
}

View file

@ -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)
}

View file

@ -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

View 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
})
}
}
}

View 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,
},
}
}

View 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):
}
}

View 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]
}

View 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)
}
}

View 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),
}
}

View 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
}

View file

@ -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)

View file

@ -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

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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) {

View 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)
}
}
}
}

View 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 + "%"},
},
})
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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
View 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
View 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
View file

@ -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
View file

@ -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=

View file

@ -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})

View file

@ -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
View 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
View 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)
}

View 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)
}

View 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"
}

View 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
}

View 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"`
}

View file

@ -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) {

View file

@ -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{

View file

@ -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

View 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)
}