This commit is contained in:
Huaaudio 2026-03-22 05:06:23 +01:00
parent 8d04fb7403
commit 5fcf08ed09
7 changed files with 93 additions and 88 deletions

View file

@ -35,4 +35,3 @@ func NewOnboardCommand() *cobra.Command {
return cmd return cmd
} }

View file

@ -116,7 +116,7 @@ func saveWeixinConfig(token, baseURL, proxy string) error {
} }
func writeMinimalWeixinConfig(cfgPath, token, baseURL, proxy string) error { func writeMinimalWeixinConfig(cfgPath, token, baseURL, proxy string) error {
if err := os.MkdirAll(internal.GetPicoclawHome(), 0755); err != nil { if err := os.MkdirAll(internal.GetPicoclawHome(), 0o755); err != nil {
return err return err
} }
@ -137,7 +137,7 @@ func writeMinimalWeixinConfig(cfgPath, token, baseURL, proxy string) error {
if err != nil { if err != nil {
return err return err
} }
if err := os.WriteFile(cfgPath, data, 0600); err != nil { if err := os.WriteFile(cfgPath, data, 0o600); err != nil {
return err return err
} }
fmt.Printf("✓ Created config at %s\n", cfgPath) fmt.Printf("✓ Created config at %s\n", cfgPath)

View file

@ -53,7 +53,7 @@ func randomWechatUIN() string {
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", uint32Val))) return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", uint32Val)))
} }
func (c *ApiClient) post(ctx context.Context, endpoint string, body interface{}, responseObj interface{}) error { func (c *ApiClient) post(ctx context.Context, endpoint string, body any, responseObj any) error {
u, err := url.Parse(c.BaseURL) u, err := url.Parse(c.BaseURL)
if err != nil { if err != nil {
return err return err
@ -72,16 +72,17 @@ func (c *ApiClient) post(ctx context.Context, endpoint string, body interface{},
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if endpoint == "ilink/bot/get_bot_qrcode" || endpoint == "ilink/bot/get_qrcode_status" { if endpoint == "ilink/bot/get_bot_qrcode" || endpoint == "ilink/bot/get_qrcode_status" {
// QR routes have different headers sometimes, but let's stick to base ones // QR routes have different headers sometimes, but let's stick to base ones
if endpoint == "ilink/bot/get_qrcode_status" { if endpoint == "ilink/bot/get_qrcode_status" {
req.Header.Set("iLink-App-ClientVersion", "1") // Use direct map assignment to send exact header name the Tencent API expects
} req.Header["iLink-App-ClientVersion"] = []string{"1"}
}
} else { } else {
req.Header.Set("AuthorizationType", "ilink_bot_token") req.Header["AuthorizationType"] = []string{"ilink_bot_token"}
req.Header.Set("X-WECHAT-UIN", randomWechatUIN()) req.Header["X-WECHAT-UIN"] = []string{randomWechatUIN()}
if c.Token != "" { if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Authorization", "Bearer "+c.Token)
} }
} }
resp, err := c.HttpClient.Do(req) resp, err := c.HttpClient.Do(req)
@ -140,7 +141,7 @@ func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) error {
} }
func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) { func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) {
// get_bot_qrcode is GET, not POST // get_bot_qrcode is GET, not POST
u, err := url.Parse(c.BaseURL) u, err := url.Parse(c.BaseURL)
if err != nil { if err != nil {
return nil, err return nil, err
@ -177,7 +178,7 @@ func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeRespo
} }
func (c *ApiClient) GetQRCodeStatus(ctx context.Context, qrcode string) (*StatusResponse, error) { func (c *ApiClient) GetQRCodeStatus(ctx context.Context, qrcode string) (*StatusResponse, error) {
// get_qrcode_status is GET // get_qrcode_status is GET
u, err := url.Parse(c.BaseURL) u, err := url.Parse(c.BaseURL)
if err != nil { if err != nil {
return nil, err return nil, err
@ -191,7 +192,7 @@ func (c *ApiClient) GetQRCodeStatus(ctx context.Context, qrcode string) (*Status
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("iLink-App-ClientVersion", "1") req.Header["iLink-App-ClientVersion"] = []string{"1"}
resp, err := c.HttpClient.Do(req) resp, err := c.HttpClient.Do(req)
if err != nil { if err != nil {

View file

@ -7,10 +7,11 @@ import (
"time" "time"
"github.com/mdp/qrterminal/v3" "github.com/mdp/qrterminal/v3"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
// AuthFlow opts // AuthFlowOpts configures the interactive QR login flow.
type AuthFlowOpts struct { type AuthFlowOpts struct {
BaseURL string BaseURL string
BotType string BotType string
@ -20,7 +21,10 @@ type AuthFlowOpts struct {
// PerformLoginInteractive starts the Weixin QR login flow and blocks until login is successful or times out. // PerformLoginInteractive starts the Weixin QR login flow and blocks until login is successful or times out.
// It prints a QR code to the terminal for the user to scan. // It prints a QR code to the terminal for the user to scan.
// Returns the BotToken, UserID, AccountID, and BaseUrl on success. // Returns the BotToken, UserID, AccountID, and BaseUrl on success.
func PerformLoginInteractive(ctx context.Context, opts AuthFlowOpts) (botToken, userID, accountID, baseUrl string, err error) { func PerformLoginInteractive(
ctx context.Context,
opts AuthFlowOpts,
) (botToken, userID, accountID, baseUrl string, err error) {
if opts.BaseURL == "" { if opts.BaseURL == "" {
opts.BaseURL = "https://ilinkai.weixin.qq.com/" opts.BaseURL = "https://ilinkai.weixin.qq.com/"
} }
@ -49,8 +53,8 @@ func PerformLoginInteractive(ctx context.Context, opts AuthFlowOpts) (botToken,
// Create Small QR // Create Small QR
qrconfig := qrterminal.Config{ qrconfig := qrterminal.Config{
Level: qrterminal.L, Level: qrterminal.L,
Writer: os.Stdout, Writer: os.Stdout,
HalfBlocks: true, HalfBlocks: true,
} }
qrterminal.GenerateWithConfig(qrResp.QrcodeImgContent, qrconfig) qrterminal.GenerateWithConfig(qrResp.QrcodeImgContent, qrconfig)
@ -89,7 +93,7 @@ func PerformLoginInteractive(ctx context.Context, opts AuthFlowOpts) (botToken,
if statusResp.BotToken == "" || statusResp.IlinkBotID == "" { if statusResp.BotToken == "" || statusResp.IlinkBotID == "" {
return "", "", "", "", fmt.Errorf("login confirmed but missing bot_token or ilink_bot_id") return "", "", "", "", fmt.Errorf("login confirmed but missing bot_token or ilink_bot_id")
} }
logger.InfoCF("weixin", "Login successful", map[string]interface{}{ logger.InfoCF("weixin", "Login successful", map[string]any{
"account_id": statusResp.IlinkBotID, "account_id": statusResp.IlinkBotID,
}) })
@ -97,7 +101,7 @@ func PerformLoginInteractive(ctx context.Context, opts AuthFlowOpts) (botToken,
case "expired": case "expired":
return "", "", "", "", fmt.Errorf("qrcode expired, please try again") return "", "", "", "", fmt.Errorf("qrcode expired, please try again")
default: default:
logger.WarnCF("weixin", "Unknown QR code status", map[string]interface{}{ logger.WarnCF("weixin", "Unknown QR code status", map[string]any{
"status": statusResp.Status, "status": statusResp.Status,
}) })
} }

View file

@ -14,18 +14,18 @@ const (
) )
type GetUploadUrlReq struct { type GetUploadUrlReq struct {
Filekey string `json:"filekey,omitempty"` Filekey string `json:"filekey,omitempty"`
MediaType int `json:"media_type,omitempty"` MediaType int `json:"media_type,omitempty"`
ToUserID string `json:"to_user_id,omitempty"` ToUserID string `json:"to_user_id,omitempty"`
Rawsize int64 `json:"rawsize,omitempty"` Rawsize int64 `json:"rawsize,omitempty"`
RawfileMD5 string `json:"rawfilemd5,omitempty"` RawfileMD5 string `json:"rawfilemd5,omitempty"`
Filesize int64 `json:"filesize,omitempty"` Filesize int64 `json:"filesize,omitempty"`
ThumbRawsize int64 `json:"thumb_rawsize,omitempty"` ThumbRawsize int64 `json:"thumb_rawsize,omitempty"`
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"` // base64
BaseInfo BaseInfo `json:"base_info,omitempty"` BaseInfo BaseInfo `json:"base_info,omitempty"`
} }
type GetUploadUrlResp struct { type GetUploadUrlResp struct {
@ -77,12 +77,12 @@ type ImageItem struct {
} }
type VoiceItem struct { type VoiceItem struct {
Media *CDNMedia `json:"media,omitempty"` Media *CDNMedia `json:"media,omitempty"`
EncodeType int `json:"encode_type,omitempty"` EncodeType int `json:"encode_type,omitempty"`
BitsPerSample int `json:"bits_per_sample,omitempty"` BitsPerSample int `json:"bits_per_sample,omitempty"`
SampleRate int `json:"sample_rate,omitempty"` SampleRate int `json:"sample_rate,omitempty"`
Playtime int `json:"playtime,omitempty"` Playtime int `json:"playtime,omitempty"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
} }
type FileItem struct { type FileItem struct {
@ -109,34 +109,34 @@ type RefMessage struct {
} }
type MessageItem struct { type MessageItem struct {
Type int `json:"type,omitempty"` Type int `json:"type,omitempty"`
CreateTimeMs int64 `json:"create_time_ms,omitempty"` CreateTimeMs int64 `json:"create_time_ms,omitempty"`
UpdateTimeMs int64 `json:"update_time_ms,omitempty"` UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
IsCompleted bool `json:"is_completed,omitempty"` IsCompleted bool `json:"is_completed,omitempty"`
MsgID string `json:"msg_id,omitempty"` MsgID string `json:"msg_id,omitempty"`
RefMsg *RefMessage `json:"ref_msg,omitempty"` RefMsg *RefMessage `json:"ref_msg,omitempty"`
TextItem *TextItem `json:"text_item,omitempty"` TextItem *TextItem `json:"text_item,omitempty"`
ImageItem *ImageItem `json:"image_item,omitempty"` ImageItem *ImageItem `json:"image_item,omitempty"`
VoiceItem *VoiceItem `json:"voice_item,omitempty"` VoiceItem *VoiceItem `json:"voice_item,omitempty"`
FileItem *FileItem `json:"file_item,omitempty"` FileItem *FileItem `json:"file_item,omitempty"`
VideoItem *VideoItem `json:"video_item,omitempty"` VideoItem *VideoItem `json:"video_item,omitempty"`
} }
type WeixinMessage struct { type WeixinMessage struct {
Seq int `json:"seq,omitempty"` Seq int `json:"seq,omitempty"`
MessageID int64 `json:"message_id,omitempty"` MessageID int64 `json:"message_id,omitempty"`
FromUserID string `json:"from_user_id,omitempty"` FromUserID string `json:"from_user_id,omitempty"`
ToUserID string `json:"to_user_id,omitempty"` ToUserID string `json:"to_user_id,omitempty"`
ClientID string `json:"client_id,omitempty"` ClientID string `json:"client_id,omitempty"`
CreateTimeMs int64 `json:"create_time_ms,omitempty"` CreateTimeMs int64 `json:"create_time_ms,omitempty"`
UpdateTimeMs int64 `json:"update_time_ms,omitempty"` UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
DeleteTimeMs int64 `json:"delete_time_ms,omitempty"` DeleteTimeMs int64 `json:"delete_time_ms,omitempty"`
SessionID string `json:"session_id,omitempty"` SessionID string `json:"session_id,omitempty"`
GroupID string `json:"group_id,omitempty"` GroupID string `json:"group_id,omitempty"`
MessageType int `json:"message_type,omitempty"` MessageType int `json:"message_type,omitempty"`
MessageState int `json:"message_state,omitempty"` MessageState int `json:"message_state,omitempty"`
ItemList []MessageItem `json:"item_list,omitempty"` ItemList []MessageItem `json:"item_list,omitempty"`
ContextToken string `json:"context_token,omitempty"` ContextToken string `json:"context_token,omitempty"`
} }
type GetUpdatesReq struct { type GetUpdatesReq struct {

View file

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -18,11 +19,11 @@ import (
// WeixinChannel is the Weixin channel implementation over Tencent iLink REST API. // WeixinChannel is the Weixin channel implementation over Tencent iLink REST API.
type WeixinChannel struct { type WeixinChannel struct {
*channels.BaseChannel *channels.BaseChannel
api *ApiClient api *ApiClient
config config.WeixinConfig config config.WeixinConfig
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
bus *bus.MessageBus bus *bus.MessageBus
// 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
@ -76,7 +77,7 @@ func (c *WeixinChannel) Stop(ctx context.Context) error {
return nil return nil
} }
// pollLoop is the long-poll receive loop. It runs until ctx is cancelled. // pollLoop is the long-poll receive loop. It runs until ctx is canceled.
func (c *WeixinChannel) pollLoop(ctx context.Context) { func (c *WeixinChannel) pollLoop(ctx context.Context) {
const ( const (
defaultPollTimeoutMs = 35_000 defaultPollTimeoutMs = 35_000

View file

@ -501,12 +501,12 @@ type WeComAIBotConfig struct {
} }
type WeixinConfig struct { 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"`
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"`
} }
type PicoConfig struct { type PicoConfig struct {