feat(robot): add Weixin integration and enhance existing adapters
- Introduced Weixin integration support with new configuration options for WeChat iLink Bot. - Updated existing adapters (DingTalk, Discord, Feishu, Telegram) to include sender_id and app_id in message metadata for improved context handling. - Enhanced dispatcher logic to accommodate the new Weixin adapter and ensure proper initialization and shutdown processes. - Improved message handling across integrations to support typing indicators, providing a more interactive user experience.
This commit is contained in:
parent
c5bc1fe1c0
commit
09af247a7c
34 changed files with 2213 additions and 155 deletions
|
|
@ -11,6 +11,7 @@ import (
|
||||||
dcadapter "github.com/yaoapp/yao/agent/robot/events/integrations/discord"
|
dcadapter "github.com/yaoapp/yao/agent/robot/events/integrations/discord"
|
||||||
fsadapter "github.com/yaoapp/yao/agent/robot/events/integrations/feishu"
|
fsadapter "github.com/yaoapp/yao/agent/robot/events/integrations/feishu"
|
||||||
"github.com/yaoapp/yao/agent/robot/events/integrations/telegram"
|
"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/logger"
|
||||||
"github.com/yaoapp/yao/agent/robot/manager"
|
"github.com/yaoapp/yao/agent/robot/manager"
|
||||||
"github.com/yaoapp/yao/agent/robot/types"
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
|
@ -65,6 +66,7 @@ func Start() error {
|
||||||
"feishu": fsadapter.NewAdapter(),
|
"feishu": fsadapter.NewAdapter(),
|
||||||
"dingtalk": dtadapter.NewAdapter(),
|
"dingtalk": dtadapter.NewAdapter(),
|
||||||
"discord": dcadapter.NewAdapter(),
|
"discord": dcadapter.NewAdapter(),
|
||||||
|
"weixin": weixinadapter.NewAdapter(),
|
||||||
}
|
}
|
||||||
globalDispatcher = integrations.NewDispatcher(globalManager.Cache(), adapters)
|
globalDispatcher = integrations.NewDispatcher(globalManager.Cache(), adapters)
|
||||||
if err := globalDispatcher.Start(context.Background()); err != nil {
|
if err := globalDispatcher.Start(context.Background()); err != nil {
|
||||||
|
|
|
||||||
103
agent/robot/api/weixin_qrcode.go
Normal file
103
agent/robot/api/weixin_qrcode.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
qrSessionTTL = 5 * time.Minute
|
||||||
|
maxQRRefreshCount = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
type qrSession struct {
|
||||||
|
qrcode string
|
||||||
|
apiHost string
|
||||||
|
startedAt time.Time
|
||||||
|
refreshes int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
qrSessions = make(map[string]*qrSession)
|
||||||
|
qrSessionsMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeixinQRCodeCreate creates a new QR code session for WeChat login.
|
||||||
|
// Returns the session key and QR code URL.
|
||||||
|
func WeixinQRCodeCreate(apiHost string) (sessionKey, qrcodeURL, qrcodeImg string, err error) {
|
||||||
|
qrcode, qrcodeImgContent, err := weixinapi.GetQRCode(context.Background(), apiHost)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("get QR code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey = uuid.New().String()
|
||||||
|
qrSessionsMu.Lock()
|
||||||
|
qrSessions[sessionKey] = &qrSession{
|
||||||
|
qrcode: qrcode,
|
||||||
|
apiHost: apiHost,
|
||||||
|
startedAt: time.Now(),
|
||||||
|
}
|
||||||
|
qrSessionsMu.Unlock()
|
||||||
|
|
||||||
|
return sessionKey, qrcode, qrcodeImgContent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeixinQRCodePoll polls the QR code status for a given session.
|
||||||
|
func WeixinQRCodePoll(sessionKey string) (status, botToken, accountID, baseURL, userID string, err error) {
|
||||||
|
qrSessionsMu.Lock()
|
||||||
|
session, ok := qrSessions[sessionKey]
|
||||||
|
if !ok {
|
||||||
|
qrSessionsMu.Unlock()
|
||||||
|
return "", "", "", "", "", fmt.Errorf("session not found: %s", sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(session.startedAt) > qrSessionTTL {
|
||||||
|
if session.refreshes < maxQRRefreshCount {
|
||||||
|
session.refreshes++
|
||||||
|
session.startedAt = time.Now()
|
||||||
|
apiHost := session.apiHost
|
||||||
|
qrSessionsMu.Unlock()
|
||||||
|
|
||||||
|
newQR, _, refreshErr := weixinapi.GetQRCode(context.Background(), apiHost)
|
||||||
|
if refreshErr != nil {
|
||||||
|
qrSessionsMu.Lock()
|
||||||
|
delete(qrSessions, sessionKey)
|
||||||
|
qrSessionsMu.Unlock()
|
||||||
|
return "expired", "", "", "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
qrSessionsMu.Lock()
|
||||||
|
if s, ok := qrSessions[sessionKey]; ok {
|
||||||
|
s.qrcode = newQR
|
||||||
|
}
|
||||||
|
qrSessionsMu.Unlock()
|
||||||
|
return "refreshed", "", "", "", "", nil
|
||||||
|
}
|
||||||
|
delete(qrSessions, sessionKey)
|
||||||
|
qrSessionsMu.Unlock()
|
||||||
|
return "expired", "", "", "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
qrcode := session.qrcode
|
||||||
|
apiHost := session.apiHost
|
||||||
|
qrSessionsMu.Unlock()
|
||||||
|
|
||||||
|
resp, err := weixinapi.PollQRStatus(context.Background(), apiHost, qrcode)
|
||||||
|
if err != nil {
|
||||||
|
return "wait", "", "", "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Status == "confirmed" {
|
||||||
|
qrSessionsMu.Lock()
|
||||||
|
delete(qrSessions, sessionKey)
|
||||||
|
qrSessionsMu.Unlock()
|
||||||
|
return resp.Status, resp.BotToken, resp.IlinkBotID, resp.BaseURL, resp.UserID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Status, "", "", "", "", nil
|
||||||
|
}
|
||||||
|
|
@ -104,9 +104,13 @@ func (h *robotHandler) handleDelivery(ctx context.Context, ev *eventtypes.Event,
|
||||||
for k, v := range payload.Extra {
|
for k, v := range payload.Extra {
|
||||||
extra[k] = v
|
extra[k] = v
|
||||||
}
|
}
|
||||||
|
senderID, _ := payload.Extra["sender_id"].(string)
|
||||||
|
appID, _ := payload.Extra["app_id"].(string)
|
||||||
metadata := &MessageMetadata{
|
metadata := &MessageMetadata{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
|
SenderID: senderID,
|
||||||
|
AppID: appID,
|
||||||
Extra: extra,
|
Extra: extra,
|
||||||
}
|
}
|
||||||
if err := reply(ctx, msg, metadata); err != nil {
|
if err := reply(ctx, msg, metadata); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ type Adapter struct {
|
||||||
type botEntry struct {
|
type botEntry struct {
|
||||||
robotID string
|
robotID string
|
||||||
clientID string
|
clientID string
|
||||||
|
clientSecret string
|
||||||
bot *dtapi.Bot
|
bot *dtapi.Bot
|
||||||
cancelFn context.CancelFunc
|
cancelFn context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +59,8 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
if existing, ok := a.bots[robot.MemberID]; ok {
|
if existing, ok := a.bots[robot.MemberID]; ok {
|
||||||
if existing.clientID == dtConf.ClientID {
|
if existing.clientID == dtConf.ClientID &&
|
||||||
|
existing.clientSecret == dtConf.ClientSecret {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.removeBotLocked(robot.MemberID)
|
a.removeBotLocked(robot.MemberID)
|
||||||
|
|
@ -70,6 +72,7 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
||||||
entry := &botEntry{
|
entry := &botEntry{
|
||||||
robotID: robot.MemberID,
|
robotID: robot.MemberID,
|
||||||
clientID: dtConf.ClientID,
|
clientID: dtConf.ClientID,
|
||||||
|
clientSecret: dtConf.ClientSecret,
|
||||||
bot: bot,
|
bot: bot,
|
||||||
cancelFn: streamCancel,
|
cancelFn: streamCancel,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*dt
|
||||||
"session_webhook": lastCM.SessionWebhook,
|
"session_webhook": lastCM.SessionWebhook,
|
||||||
"conversation_type": lastCM.ConversationType,
|
"conversation_type": lastCM.ConversationType,
|
||||||
"dt_message_id": lastCM.MessageID,
|
"dt_message_id": lastCM.MessageID,
|
||||||
|
"sender_id": lastCM.SenderID,
|
||||||
|
"app_id": entry.clientID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
if existing, ok := a.bots[robot.MemberID]; ok {
|
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
|
return
|
||||||
}
|
}
|
||||||
a.removeBotLocked(robot.MemberID)
|
a.removeBotLocked(robot.MemberID)
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*dc
|
||||||
"discord_message_id": lastCM.MessageID,
|
"discord_message_id": lastCM.MessageID,
|
||||||
"guild_id": lastCM.GuildID,
|
"guild_id": lastCM.GuildID,
|
||||||
"is_dm": lastCM.IsDM,
|
"is_dm": lastCM.IsDM,
|
||||||
|
"sender_id": lastCM.AuthorID,
|
||||||
|
"app_id": entry.appID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/model"
|
|
||||||
"github.com/yaoapp/kun/maps"
|
|
||||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||||
robotcache "github.com/yaoapp/yao/agent/robot/cache"
|
robotcache "github.com/yaoapp/yao/agent/robot/cache"
|
||||||
events "github.com/yaoapp/yao/agent/robot/events"
|
events "github.com/yaoapp/yao/agent/robot/events"
|
||||||
|
|
@ -22,6 +20,7 @@ type Adapter interface {
|
||||||
Apply(ctx context.Context, robot *robottypes.Robot)
|
Apply(ctx context.Context, robot *robottypes.Robot)
|
||||||
Remove(ctx context.Context, robotID string)
|
Remove(ctx context.Context, robotID string)
|
||||||
Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error
|
Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error
|
||||||
|
Shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatcher distributes Robot integration configs to platform adapters.
|
// Dispatcher distributes Robot integration configs to platform adapters.
|
||||||
|
|
@ -48,7 +47,7 @@ func (d *Dispatcher) Start(ctx context.Context) error {
|
||||||
|
|
||||||
events.RegisterReplyFunc(d.reply)
|
events.RegisterReplyFunc(d.reply)
|
||||||
|
|
||||||
ch := make(chan *eventtypes.Event, 64)
|
ch := make(chan *eventtypes.Event, 256)
|
||||||
d.subID = event.Subscribe("robot.config.*", ch)
|
d.subID = event.Subscribe("robot.config.*", ch)
|
||||||
go d.watch(ctx, ch)
|
go d.watch(ctx, ch)
|
||||||
|
|
||||||
|
|
@ -81,76 +80,29 @@ func (d *Dispatcher) reply(ctx context.Context, msg *agentcontext.Message, metad
|
||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop unsubscribes from events.
|
// Stop unsubscribes from events and shuts down all adapters.
|
||||||
func (d *Dispatcher) Stop() {
|
func (d *Dispatcher) Stop() {
|
||||||
close(d.stopCh)
|
close(d.stopCh)
|
||||||
if d.subID != "" {
|
if d.subID != "" {
|
||||||
event.Unsubscribe(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")
|
log.Info("integration dispatcher: stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) loadAll(ctx context.Context) {
|
func (d *Dispatcher) loadAll(ctx context.Context) {
|
||||||
robots := d.loadIntegrationRobots()
|
robots := d.robotCache.ListAll()
|
||||||
|
count := 0
|
||||||
for _, robot := range robots {
|
for _, robot := range robots {
|
||||||
d.robotCache.Add(robot)
|
|
||||||
d.apply(ctx, robot)
|
|
||||||
}
|
|
||||||
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 {
|
if robot.Config != nil && robot.Config.Integrations != nil && len(parseIntegrations(robot.Config.Integrations)) > 0 {
|
||||||
result = append(result, robot)
|
d.apply(ctx, robot)
|
||||||
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.Info("integration dispatcher: initial load complete, %d robots with integrations", count)
|
||||||
total, _ := res.Get("total").(int)
|
|
||||||
if page*pageSize >= total {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
page++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply parses which integrations the robot has configured,
|
// apply parses which integrations the robot has configured,
|
||||||
|
|
@ -187,6 +139,9 @@ func parseIntegrations(intg *robottypes.Integrations) []string {
|
||||||
if intg.Discord != nil {
|
if intg.Discord != nil {
|
||||||
keys = append(keys, "discord")
|
keys = append(keys, "discord")
|
||||||
}
|
}
|
||||||
|
if intg.Weixin != nil {
|
||||||
|
keys = append(keys, "weixin")
|
||||||
|
}
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
robotcache "github.com/yaoapp/yao/agent/robot/cache"
|
robotcache "github.com/yaoapp/yao/agent/robot/cache"
|
||||||
events "github.com/yaoapp/yao/agent/robot/events"
|
events "github.com/yaoapp/yao/agent/robot/events"
|
||||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
"github.com/yaoapp/yao/event"
|
"github.com/yaoapp/yao/event"
|
||||||
eventtypes "github.com/yaoapp/yao/event/types"
|
eventtypes "github.com/yaoapp/yao/event/types"
|
||||||
)
|
)
|
||||||
|
|
@ -39,6 +40,8 @@ func (m *mockAdapter) Reply(ctx context.Context, msg *agentcontext.Message, meta
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockAdapter) Shutdown() {}
|
||||||
|
|
||||||
func (m *mockAdapter) getApplied() []*robottypes.Robot {
|
func (m *mockAdapter) getApplied() []*robottypes.Robot {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
@ -239,6 +242,13 @@ func TestConfigDeleted_TriggersRemove(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigCreated_RobotNotInCache(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)
|
setupEventBus(t)
|
||||||
cache := robotcache.New()
|
cache := robotcache.New()
|
||||||
|
|
||||||
|
|
@ -248,7 +258,7 @@ func TestConfigCreated_RobotNotInCache(t *testing.T) {
|
||||||
require.NoError(t, d.Start(context.Background()))
|
require.NoError(t, d.Start(context.Background()))
|
||||||
defer d.Stop()
|
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{
|
event.Push(context.Background(), events.RobotConfigCreated, events.RobotConfigPayload{
|
||||||
MemberID: "r-ghost", TeamID: "team1",
|
MemberID: "r-ghost", TeamID: "team1",
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ type Adapter struct {
|
||||||
type botEntry struct {
|
type botEntry struct {
|
||||||
robotID string
|
robotID string
|
||||||
appID string
|
appID string
|
||||||
|
appSecret string
|
||||||
bot *fsapi.Bot
|
bot *fsapi.Bot
|
||||||
cancelFn context.CancelFunc // cancels the event subscription goroutine
|
cancelFn context.CancelFunc // cancels the event subscription goroutine
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +59,8 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
if existing, ok := a.bots[robot.MemberID]; ok {
|
if existing, ok := a.bots[robot.MemberID]; ok {
|
||||||
if existing.appID == fsConf.AppID {
|
if existing.appID == fsConf.AppID &&
|
||||||
|
existing.appSecret == fsConf.AppSecret {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.removeBotLocked(robot.MemberID)
|
a.removeBotLocked(robot.MemberID)
|
||||||
|
|
@ -70,6 +72,7 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
||||||
entry := &botEntry{
|
entry := &botEntry{
|
||||||
robotID: robot.MemberID,
|
robotID: robot.MemberID,
|
||||||
appID: fsConf.AppID,
|
appID: fsConf.AppID,
|
||||||
|
appSecret: fsConf.AppSecret,
|
||||||
bot: bot,
|
bot: bot,
|
||||||
cancelFn: streamCancel,
|
cancelFn: streamCancel,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*fs
|
||||||
Locale: events.NormalizeLocale(lastCM.LanguageCode),
|
Locale: events.NormalizeLocale(lastCM.LanguageCode),
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"feishu_message_id": lastCM.MessageID,
|
"feishu_message_id": lastCM.MessageID,
|
||||||
|
"sender_id": lastCM.SenderID,
|
||||||
|
"app_id": entry.appID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := entry.bot.SendTyping(ctx, metadata.ChatID); err != nil {
|
||||||
|
log.Debug("feishu reply: send typing failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return a.sendContent(ctx, entry, metadata.ChatID, replyToMsgID, msg.Content)
|
return a.sendContent(ctx, entry, metadata.ChatID, replyToMsgID, msg.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*tg
|
||||||
Locale: events.NormalizeLocale(lastCM.LanguageCode),
|
Locale: events.NormalizeLocale(lastCM.LanguageCode),
|
||||||
Extra: map[string]any{
|
Extra: map[string]any{
|
||||||
"tg_message_id": lastCM.MessageID,
|
"tg_message_id": lastCM.MessageID,
|
||||||
|
"sender_id": strconv.FormatInt(lastCM.SenderID, 10),
|
||||||
|
"app_id": entry.appID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata
|
||||||
return fmt.Errorf("no bot registered for channel metadata (appID=%s)", metadata.AppID)
|
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)
|
return a.sendContent(ctx, entry.bot, chatID, replyTo, msg.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ type Adapter struct {
|
||||||
type botEntry struct {
|
type botEntry struct {
|
||||||
robotID string
|
robotID string
|
||||||
appID string
|
appID string
|
||||||
|
host string
|
||||||
bot *tgapi.Bot // bound to this robot's token
|
bot *tgapi.Bot // bound to this robot's token
|
||||||
offset int64 // polling offset
|
offset int64 // polling offset
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +66,9 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
if existing, ok := a.bots[robot.MemberID]; ok {
|
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
|
return
|
||||||
}
|
}
|
||||||
a.removeBotLocked(robot.MemberID)
|
a.removeBotLocked(robot.MemberID)
|
||||||
|
|
@ -78,6 +81,7 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
||||||
entry := &botEntry{
|
entry := &botEntry{
|
||||||
robotID: robot.MemberID,
|
robotID: robot.MemberID,
|
||||||
appID: tgConf.AppID,
|
appID: tgConf.AppID,
|
||||||
|
host: tgConf.Host,
|
||||||
bot: tgapi.NewBot(tgConf.BotToken, tgConf.WebhookSecret, opts...),
|
bot: tgapi.NewBot(tgConf.BotToken, tgConf.WebhookSecret, opts...),
|
||||||
}
|
}
|
||||||
a.bots[robot.MemberID] = entry
|
a.bots[robot.MemberID] = entry
|
||||||
|
|
|
||||||
44
agent/robot/events/integrations/weixin/dedup.go
Normal file
44
agent/robot/events/integrations/weixin/dedup.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dedupTTL = 24 * time.Hour
|
||||||
|
dedupCleanInterval = time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type dedupStore struct {
|
||||||
|
m sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDedupStore() *dedupStore {
|
||||||
|
return &dedupStore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dedupStore) markSeen(key string) bool {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, loaded := d.m.LoadOrStore(key, now)
|
||||||
|
return !loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dedupStore) cleaner(stopCh <-chan struct{}) {
|
||||||
|
ticker := time.NewTicker(dedupCleanInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
cutoff := time.Now().Add(-dedupTTL).Unix()
|
||||||
|
d.m.Range(func(key, value any) bool {
|
||||||
|
if ts, ok := value.(int64); ok && ts < cutoff {
|
||||||
|
d.m.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
agent/robot/events/integrations/weixin/message.go
Normal file
179
agent/robot/events/integrations/weixin/message.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/attachment"
|
||||||
|
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolvedMedia struct {
|
||||||
|
Wrapper string
|
||||||
|
MimeType string
|
||||||
|
FileName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertMessage(ctx context.Context, bot *weixinapi.Bot, items []weixinapi.MsgItem, groups []string) (string, []resolvedMedia) {
|
||||||
|
var textBuf strings.Builder
|
||||||
|
var media []resolvedMedia
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
switch item.Type {
|
||||||
|
case weixinapi.ItemTypeText:
|
||||||
|
if item.TextItem != nil && item.TextItem.Text != "" {
|
||||||
|
text := item.TextItem.Text
|
||||||
|
if item.RefMsg != nil {
|
||||||
|
text = formatRefMessage(item.RefMsg, text)
|
||||||
|
}
|
||||||
|
textBuf.WriteString(text)
|
||||||
|
}
|
||||||
|
case weixinapi.ItemTypeVoice:
|
||||||
|
if item.VoiceItem != nil {
|
||||||
|
if item.VoiceItem.Text != "" {
|
||||||
|
textBuf.WriteString(item.VoiceItem.Text)
|
||||||
|
} else if item.VoiceItem.Media != nil && item.VoiceItem.Media.EncryptQueryParam != "" {
|
||||||
|
m := resolveVoice(ctx, bot, item.VoiceItem, groups)
|
||||||
|
if m != nil {
|
||||||
|
media = append(media, *m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case weixinapi.ItemTypeImage:
|
||||||
|
if item.ImageItem != nil {
|
||||||
|
m := resolveImage(ctx, bot, item.ImageItem, groups)
|
||||||
|
if m != nil {
|
||||||
|
media = append(media, *m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case weixinapi.ItemTypeFile:
|
||||||
|
if item.FileItem != nil && item.FileItem.Media != nil && item.FileItem.Media.EncryptQueryParam != "" {
|
||||||
|
m := resolveFile(ctx, bot, item.FileItem, groups)
|
||||||
|
if m != nil {
|
||||||
|
media = append(media, *m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case weixinapi.ItemTypeVideo:
|
||||||
|
if item.VideoItem != nil && item.VideoItem.Media != nil && item.VideoItem.Media.EncryptQueryParam != "" {
|
||||||
|
m := resolveVideo(ctx, bot, item.VideoItem, groups)
|
||||||
|
if m != nil {
|
||||||
|
media = append(media, *m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return textBuf.String(), media
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRefMessage(ref *weixinapi.RefMessage, text string) string {
|
||||||
|
if ref == nil || ref.MessageItem == nil {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
var refBody string
|
||||||
|
if ref.MessageItem.TextItem != nil {
|
||||||
|
refBody = ref.MessageItem.TextItem.Text
|
||||||
|
}
|
||||||
|
title := ref.Title
|
||||||
|
if title == "" && refBody == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[引用: %s | %s]\n%s", title, refBody, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveImage(ctx context.Context, bot *weixinapi.Bot, img *weixinapi.ImageItem, groups []string) *resolvedMedia {
|
||||||
|
if img.AesKey != "" && img.Media != nil && img.Media.EncryptQueryParam != "" {
|
||||||
|
rawKey, err := hex.DecodeString(img.AesKey)
|
||||||
|
if err == nil {
|
||||||
|
data, err := weixinapi.DecryptFromRaw(bot.CDNBaseURL(), img.Media.EncryptQueryParam, rawKey)
|
||||||
|
if err == nil {
|
||||||
|
return storeMedia(ctx, data, "image/jpeg", "image.jpg", groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if img.Media != nil && img.Media.EncryptQueryParam != "" && img.Media.AesKey != "" {
|
||||||
|
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), img.Media.EncryptQueryParam, img.Media.AesKey)
|
||||||
|
if err == nil {
|
||||||
|
return storeMedia(ctx, data, "image/jpeg", "image.jpg", groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveVoice(ctx context.Context, bot *weixinapi.Bot, voice *weixinapi.VoiceItem, groups []string) *resolvedMedia {
|
||||||
|
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), voice.Media.EncryptQueryParam, voice.Media.AesKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("weixin: voice decrypt failed: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mime := "audio/mpeg"
|
||||||
|
ext := "mp3"
|
||||||
|
if voice.EncodeType == 6 {
|
||||||
|
mime = "audio/silk"
|
||||||
|
ext = "silk"
|
||||||
|
}
|
||||||
|
return storeMedia(ctx, data, mime, "voice."+ext, groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFile(ctx context.Context, bot *weixinapi.Bot, file *weixinapi.FileItem, groups []string) *resolvedMedia {
|
||||||
|
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), file.Media.EncryptQueryParam, file.Media.AesKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("weixin: file decrypt failed: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
filename := file.FileName
|
||||||
|
if filename == "" {
|
||||||
|
filename = "file.bin"
|
||||||
|
}
|
||||||
|
mime := weixinapi.MimeFromFilename(filename)
|
||||||
|
return storeMedia(ctx, data, mime, filename, groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveVideo(ctx context.Context, bot *weixinapi.Bot, video *weixinapi.VideoItem, groups []string) *resolvedMedia {
|
||||||
|
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), video.Media.EncryptQueryParam, video.Media.AesKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("weixin: video decrypt failed: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return storeMedia(ctx, data, "video/mp4", "video.mp4", groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeMedia(ctx context.Context, data []byte, mimeType, filename string, groups []string) *resolvedMedia {
|
||||||
|
manager, exists := attachment.Managers["__yao.attachment"]
|
||||||
|
if !exists {
|
||||||
|
log.Error("weixin: __yao.attachment manager not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fh := makeFileHeader(filename, mimeType, int64(len(data)))
|
||||||
|
reader := bytes.NewReader(data)
|
||||||
|
file, err := manager.Upload(ctx, fh, reader, attachment.UploadOption{Groups: groups})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("weixin: attachment upload failed: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resolvedMedia{
|
||||||
|
Wrapper: fmt.Sprintf("__yao.attachment://%s", file.ID),
|
||||||
|
MimeType: mimeType,
|
||||||
|
FileName: filename,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeFileHeader(filename, contentType string, size int64) *attachment.FileHeader {
|
||||||
|
hdr := make(textproto.MIMEHeader)
|
||||||
|
hdr.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
|
||||||
|
hdr.Set("Content-Type", contentType)
|
||||||
|
return &attachment.FileHeader{
|
||||||
|
FileHeader: &multipart.FileHeader{
|
||||||
|
Filename: filename,
|
||||||
|
Header: hdr,
|
||||||
|
Size: size,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
161
agent/robot/events/integrations/weixin/polling.go
Normal file
161
agent/robot/events/integrations/weixin/polling.go
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||||
|
events "github.com/yaoapp/yao/agent/robot/events"
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxConsecutiveFailures = 3
|
||||||
|
backoffDuration = 30 * time.Second
|
||||||
|
retryDuration = 2 * time.Second
|
||||||
|
sessionPauseDuration = 30 * time.Minute
|
||||||
|
defaultTimeoutMs = 35_000
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Adapter) pollLoop(ctx context.Context, entry *botEntry) {
|
||||||
|
syncBuf := loadSyncBuf(entry.accountID)
|
||||||
|
nextTimeoutMs := defaultTimeoutMs
|
||||||
|
failures := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := entry.bot.GetUpdates(ctx, syncBuf, nextTimeoutMs)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failures++
|
||||||
|
if failures >= maxConsecutiveFailures {
|
||||||
|
failures = 0
|
||||||
|
sleep(ctx, backoffDuration)
|
||||||
|
} else {
|
||||||
|
sleep(ctx, retryDuration)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ErrCode == weixinapi.SessionExpiredErrCode || resp.Ret == weixinapi.SessionExpiredErrCode {
|
||||||
|
log.Warn("weixin session expired, pausing %s robot=%s", sessionPauseDuration, entry.robotID)
|
||||||
|
failures = 0
|
||||||
|
sleep(ctx, sessionPauseDuration)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isApiError := (resp.Ret != 0) || (resp.ErrCode != 0)
|
||||||
|
if isApiError {
|
||||||
|
failures++
|
||||||
|
if failures >= maxConsecutiveFailures {
|
||||||
|
failures = 0
|
||||||
|
sleep(ctx, backoffDuration)
|
||||||
|
} else {
|
||||||
|
sleep(ctx, retryDuration)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures = 0
|
||||||
|
|
||||||
|
if resp.LongPollingTimeoutMs > 0 {
|
||||||
|
nextTimeoutMs = resp.LongPollingTimeoutMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.GetUpdatesBuf != "" && resp.GetUpdatesBuf != syncBuf {
|
||||||
|
syncBuf = resp.GetUpdatesBuf
|
||||||
|
saveSyncBuf(entry.accountID, syncBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range resp.Msgs {
|
||||||
|
a.handleMessage(ctx, entry, &resp.Msgs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) handleMessage(ctx context.Context, entry *botEntry, msg *weixinapi.WeixinMessage) {
|
||||||
|
var dedupKey string
|
||||||
|
switch {
|
||||||
|
case msg.MessageID != 0:
|
||||||
|
dedupKey = fmt.Sprintf("wx:%s:mid:%d", entry.robotID, msg.MessageID)
|
||||||
|
case msg.Seq != 0:
|
||||||
|
dedupKey = fmt.Sprintf("wx:%s:seq:%d", entry.robotID, msg.Seq)
|
||||||
|
default:
|
||||||
|
dedupKey = fmt.Sprintf("wx:%s:%s:%d", entry.robotID, msg.FromUserID, msg.CreateTimeMs)
|
||||||
|
}
|
||||||
|
if !a.dedup.markSeen(dedupKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("incoming msg from=%s context_token=%s", msg.FromUserID, msg.ContextToken)
|
||||||
|
|
||||||
|
groups := []string{"weixin", entry.accountID}
|
||||||
|
content, mediaItems := convertMessage(ctx, entry.bot, msg.ItemList, groups)
|
||||||
|
|
||||||
|
if content == "" && len(mediaItems) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgContent interface{}
|
||||||
|
if len(mediaItems) == 0 {
|
||||||
|
msgContent = content
|
||||||
|
} else {
|
||||||
|
parts := make([]interface{}, 0, 1+len(mediaItems))
|
||||||
|
if content != "" {
|
||||||
|
parts = append(parts, map[string]interface{}{"type": "text", "text": content})
|
||||||
|
}
|
||||||
|
for _, m := range mediaItems {
|
||||||
|
parts = append(parts, map[string]interface{}{
|
||||||
|
"type": "file",
|
||||||
|
"file_url": m.Wrapper,
|
||||||
|
"mime_type": m.MimeType,
|
||||||
|
"file_name": m.FileName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
msgContent = parts
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID := ""
|
||||||
|
if msg.MessageID != 0 {
|
||||||
|
messageID = fmt.Sprintf("%d", msg.MessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := events.MessagePayload{
|
||||||
|
RobotID: entry.robotID,
|
||||||
|
Messages: []agentcontext.Message{
|
||||||
|
{Role: agentcontext.RoleUser, Content: msgContent},
|
||||||
|
},
|
||||||
|
Metadata: &events.MessageMetadata{
|
||||||
|
Channel: "weixin",
|
||||||
|
MessageID: messageID,
|
||||||
|
AppID: entry.accountID,
|
||||||
|
ChatID: msg.FromUserID,
|
||||||
|
SenderID: msg.FromUserID,
|
||||||
|
Locale: "zh-cn",
|
||||||
|
Extra: map[string]any{
|
||||||
|
"context_token": msg.ContextToken,
|
||||||
|
"sender_id": msg.FromUserID,
|
||||||
|
"app_id": entry.accountID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := event.Push(ctx, events.Message, payload); err != nil {
|
||||||
|
log.Error("weixin adapter: event.Push failed robot=%s: %v", entry.robotID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleep(ctx context.Context, d time.Duration) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(d):
|
||||||
|
}
|
||||||
|
}
|
||||||
305
agent/robot/events/integrations/weixin/reply.go
Normal file
305
agent/robot/events/integrations/weixin/reply.go
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||||
|
events "github.com/yaoapp/yao/agent/robot/events"
|
||||||
|
"github.com/yaoapp/yao/attachment"
|
||||||
|
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error {
|
||||||
|
if msg == nil || metadata == nil {
|
||||||
|
return fmt.Errorf("weixin Reply: nil message or metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := a.resolveByAccountID(metadata.AppID)
|
||||||
|
if entry == nil {
|
||||||
|
a.mu.RLock()
|
||||||
|
for _, e := range a.bots {
|
||||||
|
entry = e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a.mu.RUnlock()
|
||||||
|
}
|
||||||
|
if entry == nil {
|
||||||
|
return fmt.Errorf("weixin Reply: no bot registered (appID=%s)", metadata.AppID)
|
||||||
|
}
|
||||||
|
|
||||||
|
contextToken, _ := metadata.Extra["context_token"].(string)
|
||||||
|
toUserID := metadata.SenderID
|
||||||
|
if toUserID == "" {
|
||||||
|
toUserID = metadata.ChatID
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket := entry.ticketCache.Get(toUserID)
|
||||||
|
if ticket == "" {
|
||||||
|
if t, err := entry.bot.GetConfig(ctx, toUserID, contextToken); err == nil && t != "" {
|
||||||
|
ticket = t
|
||||||
|
entry.ticketCache.Set(toUserID, ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticket != "" {
|
||||||
|
_ = entry.bot.SendTyping(ctx, toUserID, ticket, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.sendContent(ctx, entry, toUserID, contextToken, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) sendContent(ctx context.Context, entry *botEntry, toUserID, contextToken string, content interface{}) error {
|
||||||
|
switch c := content.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(c) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entry.bot.SendMessage(ctx, toUserID, contextToken, weixinapi.FormatWeixinText(c))
|
||||||
|
|
||||||
|
case []interface{}:
|
||||||
|
return a.sendParts(ctx, entry, toUserID, contextToken, c)
|
||||||
|
|
||||||
|
default:
|
||||||
|
parts, ok := toContentParts(content)
|
||||||
|
if ok {
|
||||||
|
return a.sendPartsTyped(ctx, entry, toUserID, contextToken, parts)
|
||||||
|
}
|
||||||
|
return entry.bot.SendMessage(ctx, toUserID, contextToken, weixinapi.FormatWeixinText(fmt.Sprintf("%v", content)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) sendParts(ctx context.Context, entry *botEntry, toUserID, contextToken string, parts []interface{}) error {
|
||||||
|
var textBuf strings.Builder
|
||||||
|
for _, part := range parts {
|
||||||
|
m, ok := part.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
partType, _ := m["type"].(string)
|
||||||
|
switch partType {
|
||||||
|
case "text":
|
||||||
|
if text, ok := m["text"].(string); ok {
|
||||||
|
textBuf.WriteString(text)
|
||||||
|
}
|
||||||
|
case "image_url":
|
||||||
|
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if imgMap, ok := m["image_url"].(map[string]interface{}); ok {
|
||||||
|
if url, ok := imgMap["url"].(string); ok {
|
||||||
|
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, url, "", "image"); err != nil {
|
||||||
|
log.Error("weixin reply: send image: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "file":
|
||||||
|
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fileURL, _ := m["file_url"].(string)
|
||||||
|
fileName, _ := m["file_name"].(string)
|
||||||
|
mimeType, _ := m["mime_type"].(string)
|
||||||
|
if fileURL == "" {
|
||||||
|
if fileMap, ok := m["file"].(map[string]interface{}); ok {
|
||||||
|
fileURL, _ = fileMap["url"].(string)
|
||||||
|
if fileName == "" {
|
||||||
|
fileName, _ = fileMap["filename"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileURL != "" {
|
||||||
|
mediaHint := detectMediaHint(mimeType, fileName)
|
||||||
|
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, fileURL, fileName, mediaHint); err != nil {
|
||||||
|
log.Error("weixin reply: send file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.flushText(ctx, entry, toUserID, contextToken, &textBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) sendPartsTyped(ctx context.Context, entry *botEntry, toUserID, contextToken string, parts []agentcontext.ContentPart) error {
|
||||||
|
var textBuf strings.Builder
|
||||||
|
for _, part := range parts {
|
||||||
|
switch part.Type {
|
||||||
|
case agentcontext.ContentText:
|
||||||
|
textBuf.WriteString(part.Text)
|
||||||
|
case agentcontext.ContentImageURL:
|
||||||
|
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if part.ImageURL != nil {
|
||||||
|
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, part.ImageURL.URL, "", "image"); err != nil {
|
||||||
|
log.Error("weixin reply: send image: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case agentcontext.ContentFile:
|
||||||
|
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if part.File != nil {
|
||||||
|
mediaHint := detectMediaHint("", part.File.Filename)
|
||||||
|
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, part.File.URL, part.File.Filename, mediaHint); err != nil {
|
||||||
|
log.Error("weixin reply: send file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.flushText(ctx, entry, toUserID, contextToken, &textBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) flushText(ctx context.Context, entry *botEntry, toUserID, contextToken string, buf *strings.Builder) error {
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
text := weixinapi.FormatWeixinText(buf.String())
|
||||||
|
buf.Reset()
|
||||||
|
return entry.bot.SendMessage(ctx, toUserID, contextToken, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) sendMediaFromURL(ctx context.Context, entry *botEntry, toUserID, contextToken, fileURL, fileName, mediaHint string) error {
|
||||||
|
log.Info("weixin sendMedia: to=%s url=%s fileName=%q hint=%s contextToken_len=%d",
|
||||||
|
toUserID, fileURL, fileName, mediaHint, len(contextToken))
|
||||||
|
|
||||||
|
var plaintext []byte
|
||||||
|
var contentType string
|
||||||
|
|
||||||
|
if isWrapper(fileURL) {
|
||||||
|
managerName, fileID, err := parseWrapper(fileURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Info("weixin sendMedia: wrapper manager=%s fileID=%s", managerName, fileID)
|
||||||
|
manager, exists := attachment.Managers[managerName]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("attachment manager %s not found", managerName)
|
||||||
|
}
|
||||||
|
resp, err := manager.Download(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("attachment download %s: %w", fileID, err)
|
||||||
|
}
|
||||||
|
defer resp.Reader.Close()
|
||||||
|
plaintext, err = io.ReadAll(resp.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read attachment %s: %w", fileID, err)
|
||||||
|
}
|
||||||
|
contentType = resp.ContentType
|
||||||
|
if fileName == "" {
|
||||||
|
fileName = fileID + resp.Extension
|
||||||
|
}
|
||||||
|
log.Info("weixin sendMedia: attachment downloaded bytes=%d contentType=%q fileName=%q", len(plaintext), contentType, fileName)
|
||||||
|
} else if strings.HasPrefix(fileURL, "http") {
|
||||||
|
resp, err := http.Get(fileURL) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download %s: %w", fileURL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download %s: HTTP %d", fileURL, resp.StatusCode)
|
||||||
|
}
|
||||||
|
plaintext, err = io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", fileURL, err)
|
||||||
|
}
|
||||||
|
contentType = resp.Header.Get("Content-Type")
|
||||||
|
log.Info("weixin sendMedia: http downloaded bytes=%d contentType=%q", len(plaintext), contentType)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("unsupported URL scheme: %s", fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mediaHint == "" {
|
||||||
|
mediaHint = detectMediaHint(contentType, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaType int
|
||||||
|
switch mediaHint {
|
||||||
|
case "image":
|
||||||
|
mediaType = weixinapi.UploadMediaImage
|
||||||
|
case "video":
|
||||||
|
mediaType = weixinapi.UploadMediaVideo
|
||||||
|
default:
|
||||||
|
mediaType = weixinapi.UploadMediaFile
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("weixin sendMedia: uploading media_type=%d mediaHint=%s bytes=%d to=%s", mediaType, mediaHint, len(plaintext), toUserID)
|
||||||
|
uploaded, err := entry.bot.UploadMedia(ctx, plaintext, toUserID, mediaType)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("weixin UploadMedia failed: media_type=%d mediaHint=%s bytes=%d to=%s err=%v", mediaType, mediaHint, len(plaintext), toUserID, err)
|
||||||
|
fallbackText := fileURL
|
||||||
|
if fileName != "" {
|
||||||
|
fallbackText = fileName + "\n" + fileURL
|
||||||
|
}
|
||||||
|
return entry.bot.SendMessage(ctx, toUserID, contextToken, fallbackText)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mediaHint {
|
||||||
|
case "image":
|
||||||
|
return entry.bot.SendImageMessage(ctx, toUserID, contextToken, uploaded)
|
||||||
|
case "video":
|
||||||
|
return entry.bot.SendVideoMessage(ctx, toUserID, contextToken, uploaded)
|
||||||
|
default:
|
||||||
|
if fileName == "" {
|
||||||
|
fileName = "file.bin"
|
||||||
|
}
|
||||||
|
return entry.bot.SendFileMessage(ctx, toUserID, contextToken, fileName, uploaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectMediaHint(mimeType, fileName string) string {
|
||||||
|
lower := strings.ToLower(mimeType)
|
||||||
|
if strings.HasPrefix(lower, "image/") {
|
||||||
|
return "image"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(lower, "video/") {
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
// TODO(weixin-voice): audio/* detected as "file" because iLink Bot voice
|
||||||
|
// playback is not yet functional. Switch to "voice" once supported.
|
||||||
|
if fileName != "" {
|
||||||
|
ext := strings.ToLower(fileName)
|
||||||
|
if strings.HasSuffix(ext, ".jpg") || strings.HasSuffix(ext, ".jpeg") ||
|
||||||
|
strings.HasSuffix(ext, ".png") || strings.HasSuffix(ext, ".gif") ||
|
||||||
|
strings.HasSuffix(ext, ".webp") || strings.HasSuffix(ext, ".bmp") {
|
||||||
|
return "image"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(ext, ".mp4") || strings.HasSuffix(ext, ".mov") ||
|
||||||
|
strings.HasSuffix(ext, ".avi") || strings.HasSuffix(ext, ".webm") {
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWrapper(url string) bool {
|
||||||
|
return strings.Contains(url, "://") && !strings.HasPrefix(url, "http")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWrapper(wrapper string) (managerName, fileID string, err error) {
|
||||||
|
idx := strings.Index(wrapper, "://")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", "", fmt.Errorf("invalid wrapper: %s", wrapper)
|
||||||
|
}
|
||||||
|
return wrapper[:idx], wrapper[idx+3:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toContentParts(content interface{}) ([]agentcontext.ContentPart, bool) {
|
||||||
|
parts, ok := content.([]agentcontext.ContentPart)
|
||||||
|
return parts, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) resolveByAccountID(accountID string) *botEntry {
|
||||||
|
if accountID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
robotID, ok := a.accountIdx[accountID]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.bots[robotID]
|
||||||
|
}
|
||||||
44
agent/robot/events/integrations/weixin/syncbuf.go
Normal file
44
agent/robot/events/integrations/weixin/syncbuf.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
type syncBufData struct {
|
||||||
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncBufPath(accountID string) string {
|
||||||
|
root := application.App.Root()
|
||||||
|
return filepath.Join(root, "data", "weixin", accountID+".sync.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSyncBuf(accountID string) string {
|
||||||
|
p := syncBufPath(accountID)
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var buf syncBufData
|
||||||
|
if err := json.Unmarshal(data, &buf); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return buf.GetUpdatesBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSyncBuf(accountID, syncBuf string) {
|
||||||
|
p := syncBufPath(accountID)
|
||||||
|
dir := filepath.Dir(p)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
log.Error("weixin: mkdir for syncbuf: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(syncBufData{GetUpdatesBuf: syncBuf})
|
||||||
|
if err := os.WriteFile(p, data, 0644); err != nil {
|
||||||
|
log.Error("weixin: write syncbuf: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
agent/robot/events/integrations/weixin/typing_cache.go
Normal file
43
agent/robot/events/integrations/weixin/typing_cache.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ticketTTL = 20 * time.Hour
|
||||||
|
|
||||||
|
type ticketEntry struct {
|
||||||
|
ticket string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type typingTicketCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]*ticketEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTypingTicketCache() *typingTicketCache {
|
||||||
|
return &typingTicketCache{
|
||||||
|
items: make(map[string]*ticketEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *typingTicketCache) Get(userID string) string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
entry, ok := c.items[userID]
|
||||||
|
if !ok || time.Now().After(entry.expiresAt) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return entry.ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *typingTicketCache) Set(userID, ticket string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.items[userID] = &ticketEntry{
|
||||||
|
ticket: ticket,
|
||||||
|
expiresAt: time.Now().Add(ticketTTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
130
agent/robot/events/integrations/weixin/weixin.go
Normal file
130
agent/robot/events/integrations/weixin/weixin.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/robot/logger"
|
||||||
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
weixinapi "github.com/yaoapp/yao/integrations/weixin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = logger.New("weixin")
|
||||||
|
|
||||||
|
type Adapter struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
bots map[string]*botEntry
|
||||||
|
accountIdx map[string]string // accountID(ilink_bot_id) -> robotID
|
||||||
|
dedup *dedupStore
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type botEntry struct {
|
||||||
|
robotID string
|
||||||
|
accountID string
|
||||||
|
bot *weixinapi.Bot
|
||||||
|
cancelFn context.CancelFunc
|
||||||
|
ticketCache *typingTicketCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdapter() *Adapter {
|
||||||
|
a := &Adapter{
|
||||||
|
bots: make(map[string]*botEntry),
|
||||||
|
accountIdx: make(map[string]string),
|
||||||
|
dedup: newDedupStore(),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go a.dedup.cleaner(a.stopCh)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
|
||||||
|
conf := extractConfig(robot)
|
||||||
|
if conf == nil || !conf.Enabled || conf.BotToken == "" {
|
||||||
|
a.removeBot(robot.MemberID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if existing, ok := a.bots[robot.MemberID]; ok {
|
||||||
|
if existing.bot.Token() == conf.BotToken &&
|
||||||
|
existing.bot.BaseURL() == resolveBaseURL(conf) &&
|
||||||
|
existing.bot.CDNBaseURL() == resolveCDNBaseURL(conf) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.removeBotLocked(robot.MemberID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pollCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
entry := &botEntry{
|
||||||
|
robotID: robot.MemberID,
|
||||||
|
accountID: conf.AccountID,
|
||||||
|
bot: weixinapi.NewBot(conf.BotToken, resolveBaseURL(conf), resolveCDNBaseURL(conf)),
|
||||||
|
cancelFn: cancel,
|
||||||
|
ticketCache: newTypingTicketCache(),
|
||||||
|
}
|
||||||
|
a.bots[robot.MemberID] = entry
|
||||||
|
if conf.AccountID != "" {
|
||||||
|
a.accountIdx[conf.AccountID] = robot.MemberID
|
||||||
|
}
|
||||||
|
go a.pollLoop(pollCtx, entry)
|
||||||
|
|
||||||
|
log.Info("weixin adapter: registered robot=%s accountID=%s", robot.MemberID, conf.AccountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) Remove(ctx context.Context, robotID string) {
|
||||||
|
a.removeBot(robotID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) Shutdown() {
|
||||||
|
close(a.stopCh)
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
for id := range a.bots {
|
||||||
|
a.removeBotLocked(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) removeBot(robotID string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.removeBotLocked(robotID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) removeBotLocked(robotID string) {
|
||||||
|
entry, ok := a.bots[robotID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.cancelFn()
|
||||||
|
if entry.accountID != "" {
|
||||||
|
delete(a.accountIdx, entry.accountID)
|
||||||
|
}
|
||||||
|
delete(a.bots, robotID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBaseURL(conf *robottypes.WeixinConfig) string {
|
||||||
|
if conf.APIHost != "" {
|
||||||
|
return conf.APIHost
|
||||||
|
}
|
||||||
|
if conf.BaseURL != "" {
|
||||||
|
return conf.BaseURL
|
||||||
|
}
|
||||||
|
return weixinapi.DefaultBaseURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCDNBaseURL(conf *robottypes.WeixinConfig) string {
|
||||||
|
if conf.CDNBaseURL != "" {
|
||||||
|
return conf.CDNBaseURL
|
||||||
|
}
|
||||||
|
return weixinapi.DefaultCDNBaseURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractConfig(robot *robottypes.Robot) *robottypes.WeixinConfig {
|
||||||
|
if robot.Config == nil || robot.Config.Integrations == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return robot.Config.Integrations.Weixin
|
||||||
|
}
|
||||||
|
|
@ -1,70 +1 @@
|
||||||
package robot
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ type Integrations struct {
|
||||||
Feishu *FeishuConfig `json:"feishu,omitempty"`
|
Feishu *FeishuConfig `json:"feishu,omitempty"`
|
||||||
DingTalk *DingTalkConfig `json:"dingtalk,omitempty"`
|
DingTalk *DingTalkConfig `json:"dingtalk,omitempty"`
|
||||||
Discord *DiscordConfig `json:"discord,omitempty"`
|
Discord *DiscordConfig `json:"discord,omitempty"`
|
||||||
|
Weixin *WeixinConfig `json:"weixin,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TelegramConfig holds Telegram Bot integration settings.
|
// TelegramConfig holds Telegram Bot integration settings.
|
||||||
|
|
@ -273,6 +274,16 @@ type MCPConfig struct {
|
||||||
Tools []string `json:"tools,omitempty"` // empty = all
|
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
|
// Event - event trigger config
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type EventSource `json:"type"` // webhook | database
|
Type EventSource `json:"type"` // webhook | database
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ import (
|
||||||
"github.com/yaoapp/yao/attachment"
|
"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.
|
// SendTextMessage sends a text message to a chat.
|
||||||
func (b *Bot) SendTextMessage(ctx context.Context, chatID, text string) (string, error) {
|
func (b *Bot) SendTextMessage(ctx context.Context, chatID, text string) (string, error) {
|
||||||
content, _ := json.Marshal(map[string]string{"text": text})
|
content, _ := json.Marshal(map[string]string{"text": text})
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,19 @@ import (
|
||||||
"github.com/yaoapp/yao/attachment"
|
"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,
|
// SendMessage sends a message to a chat. If the text contains Markdown formatting,
|
||||||
// it is automatically converted to Telegram-compatible HTML.
|
// it is automatically converted to Telegram-compatible HTML.
|
||||||
func (b *Bot) SendMessage(ctx context.Context, chatID int64, text string, replyTo int64) error {
|
func (b *Bot) SendMessage(ctx context.Context, chatID int64, text string, replyTo int64) error {
|
||||||
|
|
|
||||||
376
integrations/weixin/bot.go
Normal file
376
integrations/weixin/bot.go
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/kun/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||||
|
const defaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||||
|
const channelVersion = "1.0.0"
|
||||||
|
|
||||||
|
const (
|
||||||
|
UploadMediaImage = 1
|
||||||
|
UploadMediaVideo = 2
|
||||||
|
UploadMediaFile = 3
|
||||||
|
UploadMediaVoice = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
const cdnUploadMaxRetries = 3
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
token string
|
||||||
|
baseURL string
|
||||||
|
cdnBaseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBot(token, baseURL, cdnBaseURL string) *Bot {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = defaultBaseURL
|
||||||
|
}
|
||||||
|
if cdnBaseURL == "" {
|
||||||
|
cdnBaseURL = defaultCDNBaseURL
|
||||||
|
}
|
||||||
|
return &Bot{
|
||||||
|
token: token,
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
cdnBaseURL: strings.TrimRight(cdnBaseURL, "/"),
|
||||||
|
httpClient: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) Token() string { return b.token }
|
||||||
|
func (b *Bot) BaseURL() string { return b.baseURL }
|
||||||
|
func (b *Bot) CDNBaseURL() string { return b.cdnBaseURL }
|
||||||
|
func DefaultBaseURL() string { return defaultBaseURL }
|
||||||
|
func DefaultCDNBaseURL() string { return defaultCDNBaseURL }
|
||||||
|
|
||||||
|
func (b *Bot) GetUpdates(ctx context.Context, syncBuf string, timeoutMs int) (*GetUpdatesResp, error) {
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"get_updates_buf": syncBuf,
|
||||||
|
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||||
|
})
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs+5000)*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
raw, err := b.post(reqCtx, "ilink/bot/getupdates", body)
|
||||||
|
if err != nil {
|
||||||
|
if reqCtx.Err() != nil {
|
||||||
|
return &GetUpdatesResp{GetUpdatesBuf: syncBuf}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp GetUpdatesResp
|
||||||
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("weixin GetUpdates unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) SendMessage(ctx context.Context, toUserID, contextToken, text string) error {
|
||||||
|
if contextToken == "" {
|
||||||
|
return fmt.Errorf("weixin SendMessage: contextToken is required for to=%s", toUserID)
|
||||||
|
}
|
||||||
|
clientID := randomClientID()
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"msg": map[string]interface{}{
|
||||||
|
"from_user_id": "",
|
||||||
|
"to_user_id": toUserID,
|
||||||
|
"client_id": clientID,
|
||||||
|
"message_type": MessageTypeBot,
|
||||||
|
"message_state": MessageStateFinish,
|
||||||
|
"context_token": contextToken,
|
||||||
|
"item_list": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"type": ItemTypeText,
|
||||||
|
"text_item": map[string]string{"text": text},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
_, err := b.post(ctx, "ilink/bot/sendmessage", body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) SendImageMessage(ctx context.Context, toUserID, contextToken string, uploaded *UploadedFileInfo) error {
|
||||||
|
return b.sendMediaMessage(ctx, toUserID, contextToken, MsgItem{
|
||||||
|
Type: ItemTypeImage,
|
||||||
|
ImageItem: &ImageItem{
|
||||||
|
Media: &CDNMedia{
|
||||||
|
EncryptQueryParam: uploaded.DownloadParam,
|
||||||
|
AesKey: base64.StdEncoding.EncodeToString([]byte(uploaded.AesKeyHex)),
|
||||||
|
EncryptType: 1,
|
||||||
|
},
|
||||||
|
MidSize: uploaded.FileSizeCiphertext,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) SendVideoMessage(ctx context.Context, toUserID, contextToken string, uploaded *UploadedFileInfo) error {
|
||||||
|
return b.sendMediaMessage(ctx, toUserID, contextToken, MsgItem{
|
||||||
|
Type: ItemTypeVideo,
|
||||||
|
VideoItem: &VideoItem{
|
||||||
|
Media: &CDNMedia{
|
||||||
|
EncryptQueryParam: uploaded.DownloadParam,
|
||||||
|
AesKey: base64.StdEncoding.EncodeToString([]byte(uploaded.AesKeyHex)),
|
||||||
|
EncryptType: 1,
|
||||||
|
},
|
||||||
|
VideoSize: uploaded.FileSizeCiphertext,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) SendFileMessage(ctx context.Context, toUserID, contextToken, fileName string, uploaded *UploadedFileInfo) error {
|
||||||
|
return b.sendMediaMessage(ctx, toUserID, contextToken, MsgItem{
|
||||||
|
Type: ItemTypeFile,
|
||||||
|
FileItem: &FileItem{
|
||||||
|
FileName: fileName,
|
||||||
|
Media: &CDNMedia{
|
||||||
|
EncryptQueryParam: uploaded.DownloadParam,
|
||||||
|
AesKey: base64.StdEncoding.EncodeToString([]byte(uploaded.AesKeyHex)),
|
||||||
|
EncryptType: 1,
|
||||||
|
},
|
||||||
|
Len: strconv.Itoa(uploaded.FileSize),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendVoiceMessage sends a voice message with a bubble UI.
|
||||||
|
// TODO(weixin-voice): The voice bubble displays correctly (with playtime) but
|
||||||
|
// audio playback does not work — the WeChat client reports "message still
|
||||||
|
// downloading". This affects all formats tested (SILK, Speex, OGG, MP3) and
|
||||||
|
// even echoing back an inbound voice's CDN reference verbatim. The iLink Bot
|
||||||
|
// API likely does not yet fully support outbound voice playback. For now,
|
||||||
|
// callers should fall back to SendFileMessage for audio attachments until
|
||||||
|
// WeChat officially supports voice playback via iLink Bot.
|
||||||
|
func (b *Bot) SendVoiceMessage(ctx context.Context, toUserID, contextToken string, uploaded *UploadedFileInfo, playtimeMs, sampleRate int) error {
|
||||||
|
item := MsgItem{
|
||||||
|
Type: ItemTypeVoice,
|
||||||
|
VoiceItem: &VoiceItem{
|
||||||
|
Media: &CDNMedia{
|
||||||
|
EncryptQueryParam: uploaded.DownloadParam,
|
||||||
|
AesKey: base64.StdEncoding.EncodeToString([]byte(uploaded.AesKeyHex)),
|
||||||
|
},
|
||||||
|
PlayTime: playtimeMs,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return b.sendMediaMessage(ctx, toUserID, contextToken, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) sendMediaMessage(ctx context.Context, toUserID, contextToken string, item MsgItem) error {
|
||||||
|
if contextToken == "" {
|
||||||
|
return fmt.Errorf("weixin sendMediaMessage: contextToken is required for to=%s", toUserID)
|
||||||
|
}
|
||||||
|
clientID := randomClientID()
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"msg": map[string]interface{}{
|
||||||
|
"from_user_id": "",
|
||||||
|
"to_user_id": toUserID,
|
||||||
|
"client_id": clientID,
|
||||||
|
"message_type": MessageTypeBot,
|
||||||
|
"message_state": MessageStateFinish,
|
||||||
|
"context_token": contextToken,
|
||||||
|
"item_list": []MsgItem{item},
|
||||||
|
},
|
||||||
|
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
_, err := b.post(ctx, "ilink/bot/sendmessage", body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) UploadMedia(ctx context.Context, plaintext []byte, toUserID string, mediaType int) (*UploadedFileInfo, error) {
|
||||||
|
rawsize := len(plaintext)
|
||||||
|
hash := md5.Sum(plaintext)
|
||||||
|
rawfilemd5 := hex.EncodeToString(hash[:])
|
||||||
|
filesize := aesEcbPaddedSize(rawsize)
|
||||||
|
|
||||||
|
var filekeyBuf [16]byte
|
||||||
|
rand.Read(filekeyBuf[:])
|
||||||
|
filekey := hex.EncodeToString(filekeyBuf[:])
|
||||||
|
|
||||||
|
var aeskeyBuf [16]byte
|
||||||
|
rand.Read(aeskeyBuf[:])
|
||||||
|
aeskeyHex := hex.EncodeToString(aeskeyBuf[:])
|
||||||
|
|
||||||
|
uploadReq, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"filekey": filekey,
|
||||||
|
"media_type": mediaType,
|
||||||
|
"to_user_id": toUserID,
|
||||||
|
"rawsize": rawsize,
|
||||||
|
"rawfilemd5": rawfilemd5,
|
||||||
|
"filesize": filesize,
|
||||||
|
"no_need_thumb": true,
|
||||||
|
"aeskey": aeskeyHex,
|
||||||
|
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Info("[weixin:upload] getuploadurl request: media_type=%d to_user_id=%s rawsize=%d filesize=%d filekey=%s md5=%s",
|
||||||
|
mediaType, toUserID, rawsize, filesize, filekey, rawfilemd5)
|
||||||
|
|
||||||
|
raw, err := b.post(ctx, "ilink/bot/getuploadurl", uploadReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getUploadUrl: %w", err)
|
||||||
|
}
|
||||||
|
var uploadResp GetUploadUrlResp
|
||||||
|
if err := json.Unmarshal(raw, &uploadResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("getUploadUrl unmarshal: %w (body: %s)", err, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("[weixin:upload] getuploadurl response: ret=%d errcode=%d errmsg=%q upload_param_len=%d",
|
||||||
|
uploadResp.Ret, uploadResp.ErrCode, uploadResp.ErrMsg, len(uploadResp.UploadParam))
|
||||||
|
|
||||||
|
if uploadResp.Ret != 0 || uploadResp.ErrCode != 0 {
|
||||||
|
return nil, fmt.Errorf("getUploadUrl: ret=%d errcode=%d errmsg=%q media_type=%d to_user_id=%s rawsize=%d filesize=%d rawfilemd5=%s",
|
||||||
|
uploadResp.Ret, uploadResp.ErrCode, uploadResp.ErrMsg, mediaType, toUserID, rawsize, filesize, rawfilemd5)
|
||||||
|
}
|
||||||
|
if uploadResp.UploadParam == "" {
|
||||||
|
return nil, fmt.Errorf("getUploadUrl: empty upload_param (body: %s)", string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := encryptAES128ECB(plaintext, aeskeyBuf[:])
|
||||||
|
log.Info("[weixin:upload] CDN uploading: ciphertext_len=%d filekey=%s", len(ciphertext), filekey)
|
||||||
|
|
||||||
|
downloadParam, err := b.uploadBufferToCDN(ctx, ciphertext, uploadResp.UploadParam, filekey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CDN upload: %w", err)
|
||||||
|
}
|
||||||
|
log.Info("[weixin:upload] CDN success: download_param_len=%d", len(downloadParam))
|
||||||
|
|
||||||
|
return &UploadedFileInfo{
|
||||||
|
Filekey: filekey,
|
||||||
|
DownloadParam: downloadParam,
|
||||||
|
AesKeyHex: aeskeyHex,
|
||||||
|
FileSize: rawsize,
|
||||||
|
FileSizeCiphertext: filesize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) uploadBufferToCDN(ctx context.Context, ciphertext []byte, uploadParam, filekey string) (string, error) {
|
||||||
|
cdnURL := b.cdnBaseURL + "/upload?encrypted_query_param=" +
|
||||||
|
url.QueryEscape(uploadParam) + "&filekey=" + url.QueryEscape(filekey)
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= cdnUploadMaxRetries; attempt++ {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(ciphertext))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
|
resp, err := b.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||||
|
resp.Body.Close()
|
||||||
|
return "", fmt.Errorf("CDN upload client error %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
lastErr = fmt.Errorf("CDN upload server error %d", resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadParam := resp.Header.Get("x-encrypted-param")
|
||||||
|
resp.Body.Close()
|
||||||
|
if downloadParam == "" {
|
||||||
|
lastErr = fmt.Errorf("CDN response missing x-encrypted-param")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return downloadParam, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("CDN upload failed after %d attempts: %w", cdnUploadMaxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomClientID() string {
|
||||||
|
var buf [8]byte
|
||||||
|
rand.Read(buf[:])
|
||||||
|
return fmt.Sprintf("yao-weixin-%x", buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) SendTyping(ctx context.Context, toUserID, typingTicket string, status int) error {
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"ilink_user_id": toUserID,
|
||||||
|
"typing_ticket": typingTicket,
|
||||||
|
"status": status,
|
||||||
|
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||||
|
})
|
||||||
|
_, err := b.post(ctx, "ilink/bot/sendtyping", body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) GetConfig(ctx context.Context, ilinkUserID, contextToken string) (string, error) {
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"ilink_user_id": ilinkUserID,
|
||||||
|
"context_token": contextToken,
|
||||||
|
"base_info": BaseInfo{ChannelVersion: channelVersion},
|
||||||
|
})
|
||||||
|
raw, err := b.post(ctx, "ilink/bot/getconfig", body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var resp GetConfigResp
|
||||||
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||||
|
return "", fmt.Errorf("weixin GetConfig unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return resp.TypingTicket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) post(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
|
||||||
|
reqURL := b.baseURL + "/" + endpoint
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("AuthorizationType", HeaderAuthVal)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+b.token)
|
||||||
|
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
|
req.Header.Set("X-WECHAT-UIN", randomWechatUin())
|
||||||
|
|
||||||
|
resp, err := b.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weixin %s: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("weixin %s read body: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("weixin %s HTTP %d: %s", endpoint, resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomWechatUin() string {
|
||||||
|
var buf [4]byte
|
||||||
|
rand.Read(buf[:])
|
||||||
|
n := binary.BigEndian.Uint32(buf[:])
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(n), 10)))
|
||||||
|
}
|
||||||
126
integrations/weixin/cdn.go
Normal file
126
integrations/weixin/cdn.go
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildCDNDownloadURL(cdnBaseURL, encryptedQueryParam string) string {
|
||||||
|
return cdnBaseURL + "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAesKey(aesKeyBase64 string) ([]byte, error) {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parseAesKey: base64 decode: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded) == 16 {
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
if len(decoded) == 32 {
|
||||||
|
hexStr := string(decoded)
|
||||||
|
raw := make([]byte, 16)
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
var b byte
|
||||||
|
_, err := fmt.Sscanf(hexStr[i*2:i*2+2], "%02x", &b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parseAesKey: hex parse: %w", err)
|
||||||
|
}
|
||||||
|
raw[i] = b
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("parseAesKey: unexpected decoded len=%d", len(decoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptAES128ECB(ciphertext, key []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bs := block.BlockSize()
|
||||||
|
if len(ciphertext)%bs != 0 {
|
||||||
|
return nil, fmt.Errorf("decryptAES128ECB: ciphertext len %d not multiple of block size", len(ciphertext))
|
||||||
|
}
|
||||||
|
dst := make([]byte, len(ciphertext))
|
||||||
|
for i := 0; i < len(ciphertext); i += bs {
|
||||||
|
block.Decrypt(dst[i:i+bs], ciphertext[i:i+bs])
|
||||||
|
}
|
||||||
|
if len(dst) == 0 {
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
padLen := int(dst[len(dst)-1])
|
||||||
|
if padLen == 0 || padLen > bs {
|
||||||
|
return nil, fmt.Errorf("decryptAES128ECB: invalid PKCS7 padding %d", padLen)
|
||||||
|
}
|
||||||
|
return dst[:len(dst)-padLen], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadCDNBytes(cdnBaseURL, encryptedQueryParam string) ([]byte, error) {
|
||||||
|
u := buildCDNDownloadURL(cdnBaseURL, encryptedQueryParam)
|
||||||
|
resp, err := http.Get(u) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CDN download: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("CDN download HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadAndDecrypt(cdnBaseURL, encryptedQueryParam, aesKeyBase64 string) ([]byte, error) {
|
||||||
|
key, err := parseAesKey(aesKeyBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := downloadCDNBytes(cdnBaseURL, encryptedQueryParam)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decryptAES128ECB(data, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptFromRaw(cdnBaseURL, encryptedQueryParam string, rawKey []byte) ([]byte, error) {
|
||||||
|
data, err := downloadCDNBytes(cdnBaseURL, encryptedQueryParam)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decryptAES128ECB(data, rawKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadPlain(cdnBaseURL, encryptedQueryParam string) ([]byte, error) {
|
||||||
|
return downloadCDNBytes(cdnBaseURL, encryptedQueryParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptAES128ECB(plaintext, key []byte) []byte {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bs := block.BlockSize()
|
||||||
|
padLen := bs - (len(plaintext) % bs)
|
||||||
|
padded := make([]byte, len(plaintext)+padLen)
|
||||||
|
copy(padded, plaintext)
|
||||||
|
for i := len(plaintext); i < len(padded); i++ {
|
||||||
|
padded[i] = byte(padLen)
|
||||||
|
}
|
||||||
|
dst := make([]byte, len(padded))
|
||||||
|
for i := 0; i < len(padded); i += bs {
|
||||||
|
block.Encrypt(dst[i:i+bs], padded[i:i+bs])
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesEcbPaddedSize(plaintextSize int) int {
|
||||||
|
return ((plaintextSize + 1 + 15) / 16) * 16
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCDNUploadURL(cdnBaseURL, uploadParam, filekey string) string {
|
||||||
|
return cdnBaseURL + "/upload?encrypted_query_param=" +
|
||||||
|
url.QueryEscape(uploadParam) + "&filekey=" + url.QueryEscape(filekey)
|
||||||
|
}
|
||||||
226
integrations/weixin/format.go
Normal file
226
integrations/weixin/format.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatWeixinText converts standard Markdown to plain text suitable for
|
||||||
|
// WeChat iLink Bot's text_item. WeChat renders only plain text with clickable
|
||||||
|
// URLs and [text](url) style links. All other Markdown/HTML is stripped and
|
||||||
|
// gracefully degraded to readable plain text.
|
||||||
|
func FormatWeixinText(md string) string {
|
||||||
|
md = strings.ReplaceAll(md, "\r\n", "\n")
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
lines := strings.Split(md, "\n")
|
||||||
|
|
||||||
|
inCodeBlock := false
|
||||||
|
var codeLines []string
|
||||||
|
|
||||||
|
inTable := false
|
||||||
|
var tableRows [][]string
|
||||||
|
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
line := lines[i]
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "```") {
|
||||||
|
if !inCodeBlock {
|
||||||
|
inCodeBlock = true
|
||||||
|
codeLines = nil
|
||||||
|
} else {
|
||||||
|
inCodeBlock = false
|
||||||
|
for _, cl := range codeLines {
|
||||||
|
out.WriteString(" " + cl + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inCodeBlock {
|
||||||
|
codeLines = append(codeLines, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if wxIsTableRow(line) {
|
||||||
|
if !inTable {
|
||||||
|
inTable = true
|
||||||
|
tableRows = nil
|
||||||
|
}
|
||||||
|
if wxIsTableSep(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tableRows = append(tableRows, wxParseTableRow(line))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inTable {
|
||||||
|
wxFlushTable(&out, tableRows)
|
||||||
|
inTable = false
|
||||||
|
tableRows = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if line == "---" || line == "***" || line == "___" {
|
||||||
|
out.WriteString("——————\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := wxReHeading.FindStringSubmatch(line); m != nil {
|
||||||
|
out.WriteString("【" + wxFormatInline(m[2]) + "】\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := wxReBlockquote.FindStringSubmatch(line); m != nil {
|
||||||
|
out.WriteString("│ " + wxFormatInline(m[1]) + "\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := wxReUnorderedList.FindStringSubmatch(line); m != nil {
|
||||||
|
out.WriteString("• " + wxFormatInline(m[1]) + "\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := wxReOrderedList.FindStringSubmatch(line); m != nil {
|
||||||
|
out.WriteString(m[1] + ". " + wxFormatInline(m[2]) + "\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := wxReImage.FindStringSubmatch(line); m != nil {
|
||||||
|
alt := m[1]
|
||||||
|
url := m[2]
|
||||||
|
if alt != "" {
|
||||||
|
out.WriteString("[" + alt + "](" + url + ")\n")
|
||||||
|
} else {
|
||||||
|
out.WriteString(url + "\n")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out.WriteString(wxFormatInline(line) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if inCodeBlock && len(codeLines) > 0 {
|
||||||
|
for _, cl := range codeLines {
|
||||||
|
out.WriteString(" " + cl + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inTable {
|
||||||
|
wxFlushTable(&out, tableRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimRight(out.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
wxReHeading = regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
|
||||||
|
wxReBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
|
||||||
|
wxReUnorderedList = regexp.MustCompile(`^[\s]*[-*+]\s+(.+)$`)
|
||||||
|
wxReOrderedList = regexp.MustCompile(`^[\s]*(\d+)[.)]\s+(.+)$`)
|
||||||
|
wxReImage = regexp.MustCompile(`^!\[([^\]]*)\]\(([^)]+)\)$`)
|
||||||
|
wxReTableRow = regexp.MustCompile(`^\|.*\|$`)
|
||||||
|
wxReTableSep = regexp.MustCompile(`^\|[\s\-:|]+\|$`)
|
||||||
|
|
||||||
|
wxReBoldItalic = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`)
|
||||||
|
wxReBold = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
||||||
|
wxReBoldAlt = regexp.MustCompile(`__(.+?)__`)
|
||||||
|
wxReItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+?)\*(?:[^*]|$)`)
|
||||||
|
wxReStrikethrough = regexp.MustCompile(`~~(.+?)~~`)
|
||||||
|
wxReCode = regexp.MustCompile("`([^`]+)`")
|
||||||
|
wxReLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||||
|
|
||||||
|
wxReHTMLTag = regexp.MustCompile(`<[^>]+>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// wxFormatInline strips inline Markdown/HTML formatting, keeping links in
|
||||||
|
// [text](url) form which WeChat renders as clickable.
|
||||||
|
func wxFormatInline(s string) string {
|
||||||
|
s = wxReLink.ReplaceAllString(s, "[$1]($2)")
|
||||||
|
s = wxReBoldItalic.ReplaceAllString(s, "$1")
|
||||||
|
s = wxReBold.ReplaceAllString(s, "$1")
|
||||||
|
s = wxReBoldAlt.ReplaceAllString(s, "$1")
|
||||||
|
s = wxReStrikethrough.ReplaceAllString(s, "$1")
|
||||||
|
s = wxReCode.ReplaceAllString(s, "$1")
|
||||||
|
s = wxReItalic.ReplaceAllStringFunc(s, func(match string) string {
|
||||||
|
m := wxReItalic.FindStringSubmatch(match)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
prefix := ""
|
||||||
|
suffix := ""
|
||||||
|
if len(match) > 0 && match[0] != '*' {
|
||||||
|
prefix = string(match[0])
|
||||||
|
}
|
||||||
|
if len(match) > 0 && match[len(match)-1] != '*' {
|
||||||
|
suffix = string(match[len(match)-1])
|
||||||
|
}
|
||||||
|
return prefix + m[1] + suffix
|
||||||
|
})
|
||||||
|
s = wxReHTMLTag.ReplaceAllString(s, "")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func wxIsTableRow(line string) bool {
|
||||||
|
return wxReTableRow.MatchString(strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
func wxIsTableSep(line string) bool {
|
||||||
|
return wxReTableSep.MatchString(strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
func wxParseTableRow(line string) []string {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
line = strings.TrimPrefix(line, "|")
|
||||||
|
line = strings.TrimSuffix(line, "|")
|
||||||
|
cells := strings.Split(line, "|")
|
||||||
|
for i := range cells {
|
||||||
|
cells[i] = strings.TrimSpace(cells[i])
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
func wxFlushTable(out *strings.Builder, rows [][]string) {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
colWidths := make([]int, len(rows[0]))
|
||||||
|
for _, row := range rows {
|
||||||
|
for i, cell := range row {
|
||||||
|
if i < len(colWidths) && utf8.RuneCountInString(cell) > colWidths[i] {
|
||||||
|
colWidths[i] = utf8.RuneCountInString(cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ri, row := range rows {
|
||||||
|
for ci, cell := range row {
|
||||||
|
if ci > 0 {
|
||||||
|
out.WriteString(" | ")
|
||||||
|
}
|
||||||
|
w := 0
|
||||||
|
if ci < len(colWidths) {
|
||||||
|
w = colWidths[ci]
|
||||||
|
}
|
||||||
|
out.WriteString(wxPadRight(cell, w))
|
||||||
|
}
|
||||||
|
out.WriteString("\n")
|
||||||
|
if ri == 0 && len(rows) > 1 {
|
||||||
|
for ci := range row {
|
||||||
|
if ci > 0 {
|
||||||
|
out.WriteString("-+-")
|
||||||
|
}
|
||||||
|
w := 0
|
||||||
|
if ci < len(colWidths) {
|
||||||
|
w = colWidths[ci]
|
||||||
|
}
|
||||||
|
out.WriteString(strings.Repeat("-", w))
|
||||||
|
}
|
||||||
|
out.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wxPadRight(s string, width int) string {
|
||||||
|
runes := utf8.RuneCountInString(s)
|
||||||
|
if runes >= width {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", width-runes)
|
||||||
|
}
|
||||||
47
integrations/weixin/mime.go
Normal file
47
integrations/weixin/mime.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mimeMap = map[string]string{
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".ogg": "audio/ogg",
|
||||||
|
".silk": "audio/silk",
|
||||||
|
".amr": "audio/amr",
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".mov": "video/quicktime",
|
||||||
|
".avi": "video/x-msvideo",
|
||||||
|
".webm": "video/webm",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".doc": "application/msword",
|
||||||
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
".xls": "application/vnd.ms-excel",
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".ppt": "application/vnd.ms-powerpoint",
|
||||||
|
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
".zip": "application/zip",
|
||||||
|
".gz": "application/gzip",
|
||||||
|
".tar": "application/x-tar",
|
||||||
|
".txt": "text/plain",
|
||||||
|
".csv": "text/csv",
|
||||||
|
".json": "application/json",
|
||||||
|
".xml": "application/xml",
|
||||||
|
}
|
||||||
|
|
||||||
|
func MimeFromFilename(filename string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
if m, ok := mimeMap[ext]; ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
75
integrations/weixin/qrcode.go
Normal file
75
integrations/weixin/qrcode.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultBotType = "3"
|
||||||
|
|
||||||
|
func GetQRCode(ctx context.Context, apiHost string) (qrcode, qrcodeImgURL string, err error) {
|
||||||
|
if apiHost == "" {
|
||||||
|
apiHost = defaultBaseURL
|
||||||
|
}
|
||||||
|
u := strings.TrimRight(apiHost, "/") + "/ilink/bot/get_bot_qrcode?bot_type=" + DefaultBotType
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("GetQRCode: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("GetQRCode HTTP %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
var r QRCodeResp
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return "", "", fmt.Errorf("GetQRCode unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return r.QRCode, r.QRCodeImgContent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PollQRStatus(ctx context.Context, apiHost, qrcode string) (*QRStatusResp, error) {
|
||||||
|
if apiHost == "" {
|
||||||
|
apiHost = defaultBaseURL
|
||||||
|
}
|
||||||
|
u := fmt.Sprintf("%s/ilink/bot/get_qrcode_status?qrcode=%s",
|
||||||
|
strings.TrimRight(apiHost, "/"), qrcode)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("iLink-App-ClientVersion", "1")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 35 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("PollQRStatus: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("PollQRStatus HTTP %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
var r QRStatusResp
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("PollQRStatus unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
163
integrations/weixin/types.go
Normal file
163
integrations/weixin/types.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
const (
|
||||||
|
HeaderAuthType = "AuthorizationType"
|
||||||
|
HeaderAuthVal = "ilink_bot_token"
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionExpiredErrCode = -14
|
||||||
|
|
||||||
|
const (
|
||||||
|
ItemTypeText = 1
|
||||||
|
ItemTypeImage = 2
|
||||||
|
ItemTypeVoice = 3
|
||||||
|
ItemTypeFile = 4
|
||||||
|
ItemTypeVideo = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MessageTypeNone = 0
|
||||||
|
MessageTypeUser = 1
|
||||||
|
MessageTypeBot = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MessageStateNew = 0
|
||||||
|
MessageStateGenerating = 1
|
||||||
|
MessageStateFinish = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypingStatusTyping = 1
|
||||||
|
TypingStatusCancel = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
type BaseInfo struct {
|
||||||
|
ChannelVersion string `json:"channel_version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextItem struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CDNMedia struct {
|
||||||
|
EncryptQueryParam string `json:"encrypt_query_param"`
|
||||||
|
AesKey string `json:"aes_key"`
|
||||||
|
EncryptType int `json:"encrypt_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadedFileInfo struct {
|
||||||
|
Filekey string `json:"filekey"`
|
||||||
|
DownloadParam string `json:"download_encrypted_query_param"`
|
||||||
|
AesKeyHex string `json:"aeskey"`
|
||||||
|
FileSize int `json:"file_size"`
|
||||||
|
FileSizeCiphertext int `json:"file_size_ciphertext"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUploadUrlResp struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
UploadParam string `json:"upload_param"`
|
||||||
|
ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageItem struct {
|
||||||
|
AesKey string `json:"aeskey,omitempty"`
|
||||||
|
Media *CDNMedia `json:"media,omitempty"`
|
||||||
|
ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
|
||||||
|
MidSize int `json:"mid_size,omitempty"`
|
||||||
|
HdSize int `json:"hd_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoiceItem struct {
|
||||||
|
Media *CDNMedia `json:"media,omitempty"`
|
||||||
|
EncodeType int `json:"encode_type,omitempty"`
|
||||||
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
|
PlayTime int `json:"playtime,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileItem struct {
|
||||||
|
FileName string `json:"file_name,omitempty"`
|
||||||
|
Media *CDNMedia `json:"media,omitempty"`
|
||||||
|
Len string `json:"len,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoItem struct {
|
||||||
|
Media *CDNMedia `json:"media,omitempty"`
|
||||||
|
ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
|
||||||
|
VideoSize int `json:"video_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefMessage struct {
|
||||||
|
MessageItem *MsgItem `json:"message_item,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MsgItem struct {
|
||||||
|
Type int `json:"type"`
|
||||||
|
TextItem *TextItem `json:"text_item,omitempty"`
|
||||||
|
ImageItem *ImageItem `json:"image_item,omitempty"`
|
||||||
|
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
|
||||||
|
FileItem *FileItem `json:"file_item,omitempty"`
|
||||||
|
VideoItem *VideoItem `json:"video_item,omitempty"`
|
||||||
|
RefMsg *RefMessage `json:"ref_msg,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeixinMessage struct {
|
||||||
|
Seq int64 `json:"seq,omitempty"`
|
||||||
|
MessageID int64 `json:"message_id,omitempty"`
|
||||||
|
FromUserID string `json:"from_user_id"`
|
||||||
|
ToUserID string `json:"to_user_id"`
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
GroupID string `json:"group_id,omitempty"`
|
||||||
|
MessageType int `json:"message_type,omitempty"`
|
||||||
|
MessageState int `json:"message_state,omitempty"`
|
||||||
|
ContextToken string `json:"context_token"`
|
||||||
|
CreateTimeMs int64 `json:"create_time_ms"`
|
||||||
|
UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
|
||||||
|
DeleteTimeMs int64 `json:"delete_time_ms,omitempty"`
|
||||||
|
ItemList []MsgItem `json:"item_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUpdatesResp struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
Msgs []WeixinMessage `json:"msgs"`
|
||||||
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||||
|
LongPollingTimeoutMs int `json:"longpolling_timeout_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendMessageReq struct {
|
||||||
|
Msg *WeixinMessage `json:"msg"`
|
||||||
|
BaseInfo *BaseInfo `json:"base_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendTypingReq struct {
|
||||||
|
IlinkUserID string `json:"ilink_user_id"`
|
||||||
|
TypingTicket string `json:"typing_ticket"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
BaseInfo *BaseInfo `json:"base_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetConfigResp struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
TypingTicket string `json:"typing_ticket"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QRCodeResp struct {
|
||||||
|
QRCode string `json:"qrcode"`
|
||||||
|
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QRStatusResp struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
BotToken string `json:"bot_token"`
|
||||||
|
IlinkBotID string `json:"ilink_bot_id"`
|
||||||
|
BaseURL string `json:"baseurl"`
|
||||||
|
UserID string `json:"ilink_user_id"`
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,10 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
||||||
// Integration credential verification (must be before /:id to avoid conflict)
|
// Integration credential verification (must be before /:id to avoid conflict)
|
||||||
group.POST("/integrations/verify", VerifyIntegration) // POST /robots/integrations/verify - Verify integration credentials
|
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.GET("/:id", GetRobot) // GET /robots/:id - Get robot details
|
||||||
group.PUT("/:id", UpdateRobot) // PUT /robots/:id - Update robot
|
group.PUT("/:id", UpdateRobot) // PUT /robots/:id - Update robot
|
||||||
group.DELETE("/:id", DeleteRobot) // DELETE /robots/:id - Delete robot
|
group.DELETE("/:id", DeleteRobot) // DELETE /robots/:id - Delete robot
|
||||||
|
|
|
||||||
72
openapi/agent/robot/weixin.go
Normal file
72
openapi/agent/robot/weixin.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package robot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
api "github.com/yaoapp/yao/agent/robot/api"
|
||||||
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type createWeixinQRCodeRequest struct {
|
||||||
|
APIHost string `json:"api_host"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWeixinQRCode handles POST /robots/integrations/weixin/qrcode
|
||||||
|
func CreateWeixinQRCode(c *gin.Context) {
|
||||||
|
var req createWeixinQRCodeRequest
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
apiHost := req.APIHost
|
||||||
|
if apiHost == "" {
|
||||||
|
apiHost = os.Getenv("YAO_WEIXIN_API_HOST")
|
||||||
|
}
|
||||||
|
if apiHost == "" {
|
||||||
|
apiHost = "https://ilinkai.weixin.qq.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey, qrcodeURL, qrcodeImg, err := api.WeixinQRCodeCreate(apiHost)
|
||||||
|
if err != nil {
|
||||||
|
response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.RespondWithSuccess(c, response.StatusOK, gin.H{
|
||||||
|
"session_key": sessionKey,
|
||||||
|
"qrcode_url": qrcodeURL,
|
||||||
|
"qrcode_img": qrcodeImg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollWeixinQRCode handles GET /robots/integrations/weixin/qrcode/:session_key
|
||||||
|
func PollWeixinQRCode(c *gin.Context) {
|
||||||
|
sessionKey := c.Param("session_key")
|
||||||
|
if sessionKey == "" {
|
||||||
|
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: "session_key is required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, botToken, accountID, baseURL, _, err := api.WeixinQRCodePoll(sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gin.H{"status": status}
|
||||||
|
if status == "confirmed" {
|
||||||
|
result["bot_token"] = botToken
|
||||||
|
result["account_id"] = accountID
|
||||||
|
result["base_url"] = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
response.RespondWithSuccess(c, response.StatusOK, result)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue