feat(weixin): add media sync and typing support
This commit is contained in:
parent
8fec5a75e9
commit
3e595f6398
7 changed files with 1394 additions and 55 deletions
|
|
@ -128,10 +128,13 @@ func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpda
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) error {
|
func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendMessageResp, error) {
|
||||||
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
||||||
var resp SendMessageResp
|
var resp SendMessageResp
|
||||||
return c.post(ctx, "ilink/bot/sendmessage", req, &resp)
|
if err := c.post(ctx, "ilink/bot/sendmessage", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) {
|
func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) {
|
||||||
|
|
@ -144,9 +147,22 @@ func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*Get
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) error {
|
func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfigResp, error) {
|
||||||
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
||||||
return c.post(ctx, "ilink/bot/sendtyping", req, nil)
|
var resp GetConfigResp
|
||||||
|
if err := c.post(ctx, "ilink/bot/getconfig", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTypingResp, error) {
|
||||||
|
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
||||||
|
var resp SendTypingResp
|
||||||
|
if err := c.post(ctx, "ilink/bot/sendtyping", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) {
|
func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) {
|
||||||
|
|
|
||||||
1037
pkg/channels/weixin/media.go
Normal file
1037
pkg/channels/weixin/media.go
Normal file
File diff suppressed because it is too large
Load diff
226
pkg/channels/weixin/state.go
Normal file
226
pkg/channels/weixin/state.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
package weixin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
basechannels "github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
weixinDefaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||||
|
weixinConfigCacheTTL = 24 * time.Hour
|
||||||
|
weixinConfigRetryInitial = 2 * time.Second
|
||||||
|
weixinConfigRetryMax = time.Hour
|
||||||
|
weixinSessionPauseDuration = time.Hour
|
||||||
|
weixinSessionExpiredCode = -14
|
||||||
|
)
|
||||||
|
|
||||||
|
type typingTicketCacheEntry struct {
|
||||||
|
ticket string
|
||||||
|
nextFetchAt time.Time
|
||||||
|
retryDelay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncCursorFile struct {
|
||||||
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func picoclawHomeDir() string {
|
||||||
|
if home := os.Getenv(config.EnvHome); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
userHome, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(userHome, ".picoclaw")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
|
||||||
|
key := "default"
|
||||||
|
token := strings.TrimSpace(cfg.Token)
|
||||||
|
if token != "" {
|
||||||
|
sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token))
|
||||||
|
key = hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
|
return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", key+".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGetUpdatesBuf(path string) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded syncCursorFile
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded.GetUpdatesBuf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveGetUpdatesBuf(path, cursor string) error {
|
||||||
|
data, err := json.Marshal(syncCursorFile{GetUpdatesBuf: cursor})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeixinChannel) cdnBaseURL() string {
|
||||||
|
if base := strings.TrimSpace(c.config.CDNBaseURL); base != "" {
|
||||||
|
return strings.TrimRight(base, "/")
|
||||||
|
}
|
||||||
|
return weixinDefaultCDNBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSessionExpiredStatus(ret, errcode int) bool {
|
||||||
|
return ret == weixinSessionExpiredCode || errcode == weixinSessionExpiredCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeixinChannel) pauseSession(operation string, ret, errcode int, errmsg string) time.Duration {
|
||||||
|
c.pauseMu.Lock()
|
||||||
|
defer c.pauseMu.Unlock()
|
||||||
|
|
||||||
|
until := time.Now().Add(weixinSessionPauseDuration)
|
||||||
|
if until.After(c.pauseUntil) {
|
||||||
|
c.pauseUntil = until
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := time.Until(c.pauseUntil)
|
||||||
|
logger.ErrorCF("weixin", "Session expired; pausing Weixin channel", map[string]any{
|
||||||
|
"operation": operation,
|
||||||
|
"ret": ret,
|
||||||
|
"errcode": errcode,
|
||||||
|
"errmsg": errmsg,
|
||||||
|
"until": c.pauseUntil.Format(time.RFC3339),
|
||||||
|
"minutes": int((remaining + time.Minute - 1) / time.Minute),
|
||||||
|
})
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeixinChannel) remainingPause() time.Duration {
|
||||||
|
c.pauseMu.Lock()
|
||||||
|
defer c.pauseMu.Unlock()
|
||||||
|
|
||||||
|
if c.pauseUntil.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
remaining := time.Until(c.pauseUntil)
|
||||||
|
if remaining <= 0 {
|
||||||
|
c.pauseUntil = time.Time{}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeixinChannel) waitWhileSessionPaused(ctx context.Context) error {
|
||||||
|
remaining := c.remainingPause()
|
||||||
|
if remaining <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(remaining)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeixinChannel) ensureSessionActive() error {
|
||||||
|
remaining := c.remainingPause()
|
||||||
|
if remaining <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"weixin session paused (%d min remaining): %w",
|
||||||
|
int((remaining+time.Minute-1)/time.Minute),
|
||||||
|
basechannels.ErrSendFailed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WeixinChannel) getTypingTicket(ctx context.Context, userID string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
c.typingMu.Lock()
|
||||||
|
entry, ok := c.typingCache[userID]
|
||||||
|
if ok && now.Before(entry.nextFetchAt) {
|
||||||
|
ticket := entry.ticket
|
||||||
|
c.typingMu.Unlock()
|
||||||
|
return ticket, nil
|
||||||
|
}
|
||||||
|
cachedTicket := entry.ticket
|
||||||
|
retryDelay := entry.retryDelay
|
||||||
|
c.typingMu.Unlock()
|
||||||
|
|
||||||
|
contextToken := ""
|
||||||
|
if v, ok := c.contextTokens.Load(userID); ok {
|
||||||
|
contextToken, _ = v.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.api.GetConfig(ctx, GetConfigReq{
|
||||||
|
IlinkUserID: userID,
|
||||||
|
ContextToken: contextToken,
|
||||||
|
})
|
||||||
|
if err == nil && resp != nil && resp.Ret == 0 && resp.Errcode == 0 {
|
||||||
|
ticket := strings.TrimSpace(resp.TypingTicket)
|
||||||
|
c.typingMu.Lock()
|
||||||
|
c.typingCache[userID] = typingTicketCacheEntry{
|
||||||
|
ticket: ticket,
|
||||||
|
nextFetchAt: now.Add(weixinConfigCacheTTL),
|
||||||
|
retryDelay: weixinConfigRetryInitial,
|
||||||
|
}
|
||||||
|
c.typingMu.Unlock()
|
||||||
|
return ticket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp != nil && isSessionExpiredStatus(resp.Ret, resp.Errcode) {
|
||||||
|
c.pauseSession("getconfig", resp.Ret, resp.Errcode, resp.Errmsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retryDelay <= 0 {
|
||||||
|
retryDelay = weixinConfigRetryInitial
|
||||||
|
} else {
|
||||||
|
retryDelay *= 2
|
||||||
|
if retryDelay > weixinConfigRetryMax {
|
||||||
|
retryDelay = weixinConfigRetryMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.typingMu.Lock()
|
||||||
|
c.typingCache[userID] = typingTicketCacheEntry{
|
||||||
|
ticket: cachedTicket,
|
||||||
|
nextFetchAt: now.Add(retryDelay),
|
||||||
|
retryDelay: retryDelay,
|
||||||
|
}
|
||||||
|
c.typingMu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return cachedTicket, err
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return cachedTicket, fmt.Errorf("getconfig returned nil response")
|
||||||
|
}
|
||||||
|
return cachedTicket, fmt.Errorf(
|
||||||
|
"getconfig failed: ret=%d errcode=%d errmsg=%s",
|
||||||
|
resp.Ret,
|
||||||
|
resp.Errcode,
|
||||||
|
resp.Errmsg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,12 @@ type BaseInfo struct {
|
||||||
ChannelVersion string `json:"channel_version,omitempty"`
|
ChannelVersion string `json:"channel_version,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type APIStatus struct {
|
||||||
|
Ret int `json:"ret,omitempty"`
|
||||||
|
Errcode int `json:"errcode,omitempty"`
|
||||||
|
Errmsg string `json:"errmsg,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// UploadMediaType constants
|
// UploadMediaType constants
|
||||||
const (
|
const (
|
||||||
UploadMediaTypeImage = 1
|
UploadMediaTypeImage = 1
|
||||||
|
|
@ -24,11 +30,12 @@ type GetUploadUrlReq struct {
|
||||||
ThumbRawfileMD5 string `json:"thumb_rawfilemd5,omitempty"`
|
ThumbRawfileMD5 string `json:"thumb_rawfilemd5,omitempty"`
|
||||||
ThumbFilesize int64 `json:"thumb_filesize,omitempty"`
|
ThumbFilesize int64 `json:"thumb_filesize,omitempty"`
|
||||||
NoNeedThumb bool `json:"no_need_thumb,omitempty"`
|
NoNeedThumb bool `json:"no_need_thumb,omitempty"`
|
||||||
Aeskey string `json:"aeskey,omitempty"` // base64
|
Aeskey string `json:"aeskey,omitempty"` // hex-encoded 16-byte AES key
|
||||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetUploadUrlResp struct {
|
type GetUploadUrlResp struct {
|
||||||
|
APIStatus
|
||||||
UploadParam string `json:"upload_param,omitempty"`
|
UploadParam string `json:"upload_param,omitempty"`
|
||||||
ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
|
ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -146,9 +153,7 @@ type GetUpdatesReq struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetUpdatesResp struct {
|
type GetUpdatesResp struct {
|
||||||
Ret int `json:"ret,omitempty"`
|
APIStatus
|
||||||
Errcode int `json:"errcode,omitempty"`
|
|
||||||
Errmsg string `json:"errmsg,omitempty"`
|
|
||||||
Msgs []WeixinMessage `json:"msgs,omitempty"`
|
Msgs []WeixinMessage `json:"msgs,omitempty"`
|
||||||
SyncBuf string `json:"sync_buf,omitempty"`
|
SyncBuf string `json:"sync_buf,omitempty"`
|
||||||
GetUpdatesBuf string `json:"get_updates_buf,omitempty"`
|
GetUpdatesBuf string `json:"get_updates_buf,omitempty"`
|
||||||
|
|
@ -161,15 +166,25 @@ type SendMessageReq struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendMessageResp struct {
|
type SendMessageResp struct {
|
||||||
// Usually empty
|
APIStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetConfigReq struct {
|
||||||
|
IlinkUserID string `json:"ilink_user_id,omitempty"`
|
||||||
|
ContextToken string `json:"context_token,omitempty"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetConfigResp struct {
|
type GetConfigResp struct {
|
||||||
Ret int `json:"ret,omitempty"`
|
APIStatus
|
||||||
Errmsg string `json:"errmsg,omitempty"`
|
|
||||||
TypingTicket string `json:"typing_ticket,omitempty"`
|
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypingStatusTyping = 1
|
||||||
|
TypingStatusCancel = 2
|
||||||
|
)
|
||||||
|
|
||||||
type SendTypingReq struct {
|
type SendTypingReq struct {
|
||||||
IlinkUserID string `json:"ilink_user_id,omitempty"`
|
IlinkUserID string `json:"ilink_user_id,omitempty"`
|
||||||
TypingTicket string `json:"typing_ticket,omitempty"`
|
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||||
|
|
@ -177,6 +192,10 @@ type SendTypingReq struct {
|
||||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SendTypingResp struct {
|
||||||
|
APIStatus
|
||||||
|
}
|
||||||
|
|
||||||
type QRCodeResponse struct {
|
type QRCodeResponse struct {
|
||||||
Qrcode string `json:"qrcode"`
|
Qrcode string `json:"qrcode"`
|
||||||
QrcodeImgContent string `json:"qrcode_img_content"`
|
QrcodeImgContent string `json:"qrcode_img_content"`
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ type WeixinChannel struct {
|
||||||
// contextTokens stores the last context_token per user (from_user_id → context_token).
|
// contextTokens stores the last context_token per user (from_user_id → context_token).
|
||||||
// This is required by the iLink API to associate replies with the right chat session.
|
// This is required by the iLink API to associate replies with the right chat session.
|
||||||
contextTokens sync.Map
|
contextTokens sync.Map
|
||||||
|
typingMu sync.Mutex
|
||||||
|
typingCache map[string]typingTicketCacheEntry
|
||||||
|
pauseMu sync.Mutex
|
||||||
|
pauseUntil time.Time
|
||||||
|
syncBufPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -56,6 +61,8 @@ func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*Wei
|
||||||
api: api,
|
api: api,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
bus: messageBus,
|
bus: messageBus,
|
||||||
|
typingCache: make(map[string]typingTicketCacheEntry),
|
||||||
|
syncBufPath: buildWeixinSyncBufPath(cfg),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +94,20 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) {
|
||||||
)
|
)
|
||||||
|
|
||||||
consecutiveFails := 0
|
consecutiveFails := 0
|
||||||
getUpdatesBuf := ""
|
getUpdatesBuf, err := loadGetUpdatesBuf(c.syncBufPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("weixin", "Failed to load persisted get_updates_buf", map[string]any{
|
||||||
|
"path": c.syncBufPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
getUpdatesBuf = ""
|
||||||
|
} else if getUpdatesBuf != "" {
|
||||||
|
logger.InfoCF("weixin", "Resuming persisted get_updates_buf", map[string]any{
|
||||||
|
"path": c.syncBufPath,
|
||||||
|
"bytes": len(getUpdatesBuf),
|
||||||
|
"source": "disk",
|
||||||
|
})
|
||||||
|
}
|
||||||
nextTimeoutMs := defaultPollTimeoutMs
|
nextTimeoutMs := defaultPollTimeoutMs
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
@ -98,6 +118,13 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) {
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := c.waitWhileSessionPaused(ctx); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Build a context with timeout slightly longer than the long-poll
|
// Build a context with timeout slightly longer than the long-poll
|
||||||
pollCtx, pollCancel := context.WithTimeout(ctx, time.Duration(nextTimeoutMs+5000)*time.Millisecond)
|
pollCtx, pollCancel := context.WithTimeout(ctx, time.Duration(nextTimeoutMs+5000)*time.Millisecond)
|
||||||
|
|
||||||
|
|
@ -138,9 +165,17 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for API-level error codes (-14 = session expired)
|
if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
|
||||||
const sessionExpiredErrcode = -14
|
remaining := c.pauseSession("getupdates", resp.Ret, resp.Errcode, resp.Errmsg)
|
||||||
if resp.Errcode != 0 || (resp.Ret != 0 && resp.Ret != sessionExpiredErrcode) {
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(remaining):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Errcode != 0 || resp.Ret != 0 {
|
||||||
consecutiveFails++
|
consecutiveFails++
|
||||||
logger.ErrorCF("weixin", "getUpdates API error", map[string]any{
|
logger.ErrorCF("weixin", "getUpdates API error", map[string]any{
|
||||||
"ret": resp.Ret,
|
"ret": resp.Ret,
|
||||||
|
|
@ -155,17 +190,6 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.Errcode == sessionExpiredErrcode || resp.Ret == sessionExpiredErrcode {
|
|
||||||
logger.ErrorC("weixin", "Session expired — please re-run login")
|
|
||||||
// Pause for a long time to avoid hammering with a bad token
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(10 * time.Minute):
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
consecutiveFails = 0
|
consecutiveFails = 0
|
||||||
|
|
||||||
// Update the long-poll timeout from server hint
|
// Update the long-poll timeout from server hint
|
||||||
|
|
@ -176,6 +200,12 @@ func (c *WeixinChannel) pollLoop(ctx context.Context) {
|
||||||
// Advance cursor
|
// Advance cursor
|
||||||
if resp.GetUpdatesBuf != "" {
|
if resp.GetUpdatesBuf != "" {
|
||||||
getUpdatesBuf = resp.GetUpdatesBuf
|
getUpdatesBuf = resp.GetUpdatesBuf
|
||||||
|
if err := saveGetUpdatesBuf(c.syncBufPath, getUpdatesBuf); err != nil {
|
||||||
|
logger.WarnCF("weixin", "Failed to persist get_updates_buf", map[string]any{
|
||||||
|
"path": c.syncBufPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch messages
|
// Dispatch messages
|
||||||
|
|
@ -192,6 +222,11 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
messageID := msg.ClientID
|
||||||
|
if messageID == "" {
|
||||||
|
messageID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
// Build text content from item_list
|
// Build text content from item_list
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, item := range msg.ItemList {
|
for _, item := range msg.ItemList {
|
||||||
|
|
@ -205,7 +240,7 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
|
||||||
// Use voice → text transcription from server
|
// Use voice → text transcription from server
|
||||||
parts = append(parts, item.VoiceItem.Text)
|
parts = append(parts, item.VoiceItem.Text)
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, "[voice message]")
|
parts = append(parts, "[audio]")
|
||||||
}
|
}
|
||||||
case MessageItemTypeImage:
|
case MessageItemTypeImage:
|
||||||
parts = append(parts, "[image]")
|
parts = append(parts, "[image]")
|
||||||
|
|
@ -220,8 +255,23 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var mediaRefs []string
|
||||||
|
if mediaItem := selectInboundMediaItem(msg); mediaItem != nil {
|
||||||
|
ref, err := c.downloadMediaFromItem(ctx, fromUserID, messageID, mediaItem)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("weixin", "Failed to download inbound media", map[string]any{
|
||||||
|
"from_user_id": fromUserID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"type": mediaItem.Type,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else if ref != "" {
|
||||||
|
mediaRefs = append(mediaRefs, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
content := strings.Join(parts, "\n")
|
content := strings.Join(parts, "\n")
|
||||||
if content == "" {
|
if content == "" && len(mediaRefs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,11 +290,6 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
messageID := msg.ClientID
|
|
||||||
if messageID == "" {
|
|
||||||
messageID = uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
peer := bus.Peer{Kind: "direct", ID: fromUserID}
|
peer := bus.Peer{Kind: "direct", ID: fromUserID}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
|
|
@ -256,6 +301,7 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
|
||||||
logger.DebugCF("weixin", "Received message", map[string]any{
|
logger.DebugCF("weixin", "Received message", map[string]any{
|
||||||
"from_user_id": fromUserID,
|
"from_user_id": fromUserID,
|
||||||
"content_len": len(content),
|
"content_len": len(content),
|
||||||
|
"media_count": len(mediaRefs),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store context_token for outbound reply association
|
// Store context_token for outbound reply association
|
||||||
|
|
@ -263,7 +309,7 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
|
||||||
c.contextTokens.Store(fromUserID, msg.ContextToken)
|
c.contextTokens.Store(fromUserID, msg.ContextToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, nil, metadata, sender)
|
c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, mediaRefs, metadata, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send implements channels.Channel by sending a text message to the WeChat user.
|
// Send implements channels.Channel by sending a text message to the WeChat user.
|
||||||
|
|
@ -271,6 +317,9 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
if err := c.ensureSessionActive(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if msg.Content == "" {
|
if msg.Content == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -294,32 +343,15 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
})
|
})
|
||||||
return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
|
return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
|
||||||
}
|
}
|
||||||
clientID := "picoclaw-" + uuid.New().String()
|
|
||||||
|
|
||||||
req := SendMessageReq{
|
if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil {
|
||||||
Msg: WeixinMessage{
|
|
||||||
FromUserID: "",
|
|
||||||
ToUserID: toUserID,
|
|
||||||
ClientID: clientID,
|
|
||||||
MessageType: MessageTypeBot,
|
|
||||||
MessageState: MessageStateFinish,
|
|
||||||
ItemList: []MessageItem{
|
|
||||||
{
|
|
||||||
Type: MessageItemTypeText,
|
|
||||||
TextItem: &TextItem{
|
|
||||||
Text: msg.Content,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ContextToken: contextToken,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.api.SendMessage(ctx, req); err != nil {
|
|
||||||
logger.ErrorCF("weixin", "Failed to send message", map[string]any{
|
logger.ErrorCF("weixin", "Failed to send message", map[string]any{
|
||||||
"to_user_id": toUserID,
|
"to_user_id": toUserID,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
if c.remainingPause() > 0 {
|
||||||
|
return fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
return fmt.Errorf("weixin send: %w", channels.ErrTemporary)
|
return fmt.Errorf("weixin send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,7 @@ type WeixinConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
|
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
|
||||||
|
CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
|
||||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,14 @@ func DefaultConfig() *Config {
|
||||||
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
|
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
ProcessingMessage: DefaultWeComAIBotProcessingMessage,
|
ProcessingMessage: DefaultWeComAIBotProcessingMessage,
|
||||||
},
|
},
|
||||||
|
Weixin: WeixinConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Token: "",
|
||||||
|
BaseURL: "https://ilinkai.weixin.qq.com/",
|
||||||
|
CDNBaseURL: "https://novac2c.cdn.weixin.qq.com/c2c",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
Proxy: "",
|
||||||
|
},
|
||||||
Pico: PicoConfig{
|
Pico: PicoConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
Token: "",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue