mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 19:43:43 +08:00
feat: 内置微信连接器并完善 Agent 查询能力
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
|
||||
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
|
||||
statusWait = "wait"
|
||||
statusScanned = "scaned"
|
||||
statusConfirmed = "confirmed"
|
||||
statusExpired = "expired"
|
||||
)
|
||||
|
||||
// FetchQRCode retrieves a new QR code for login.
|
||||
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
|
||||
c := NewUnauthenticatedClient()
|
||||
var resp QRCodeResponse
|
||||
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
|
||||
return nil, fmt.Errorf("fetch QR code: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// PollQRStatus polls for QR code scan status until confirmed or expired.
|
||||
// It calls onStatus for each status change so the caller can display progress.
|
||||
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
|
||||
c := NewUnauthenticatedClient()
|
||||
url := qrStatusURL + qrcode
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||
var resp QRStatusResponse
|
||||
err := c.doGet(pollCtx, url, &resp)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
// Timeout is normal for long-poll, retry
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if onStatus != nil {
|
||||
onStatus(resp.Status)
|
||||
}
|
||||
|
||||
switch resp.Status {
|
||||
case statusConfirmed:
|
||||
creds := &Credentials{
|
||||
BotToken: resp.BotToken,
|
||||
ILinkBotID: resp.ILinkBotID,
|
||||
BaseURL: resp.BaseURL,
|
||||
ILinkUserID: resp.ILinkUserID,
|
||||
}
|
||||
return creds, nil
|
||||
case statusExpired:
|
||||
return nil, fmt.Errorf("QR code expired")
|
||||
case statusWait, statusScanned:
|
||||
// Continue polling
|
||||
default:
|
||||
// Unknown status, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AccountsDir returns the directory where account credentials are stored.
|
||||
func AccountsDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil
|
||||
}
|
||||
|
||||
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
|
||||
func NormalizeAccountID(raw string) string {
|
||||
s := raw
|
||||
for _, ch := range []string{"@", ".", ":"} {
|
||||
s = filepath.Clean(s)
|
||||
s = replaceAll(s, ch, "-")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func replaceAll(s, old, new string) string {
|
||||
for {
|
||||
i := indexOf(s, old)
|
||||
if i < 0 {
|
||||
return s
|
||||
}
|
||||
s = s[:i] + new + s[i+len(old):]
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := range s {
|
||||
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// SaveCredentials saves the latest credentials and removes older accounts.
|
||||
// The new credential is written first so a failed login never destroys the
|
||||
// previously working credential.
|
||||
func SaveCredentials(creds *Credentials) error {
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create accounts dir: %w", err)
|
||||
}
|
||||
|
||||
id := NormalizeAccountID(creds.ILinkBotID)
|
||||
path := filepath.Join(dir, id+".json")
|
||||
|
||||
data, err := json.MarshalIndent(creds, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write credentials: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune old credentials: %w", err)
|
||||
}
|
||||
keepPrefix := id + "."
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAllCredentials loads all saved account credentials.
|
||||
func LoadAllCredentials() ([]*Credentials, error) {
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read accounts dir: %w", err)
|
||||
}
|
||||
|
||||
var result []*Credentials
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var creds Credentials
|
||||
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
|
||||
result = append(result, &creds)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CredentialsPath returns the path for display purposes.
|
||||
func CredentialsPath() (string, error) {
|
||||
return AccountsDir()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
|
||||
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
|
||||
if err := SaveCredentials(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveCredentials(latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := LoadAllCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
|
||||
t.Fatalf("accounts = %#v", accounts)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old sync state still exists: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
longPollTimeout = 35 * time.Second
|
||||
sendTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// Client is an iLink HTTP API client.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
botToken string
|
||||
botID string
|
||||
httpClient *http.Client
|
||||
wechatUIN string
|
||||
}
|
||||
|
||||
// NewClient creates a new iLink API client.
|
||||
func NewClient(creds *Credentials) *Client {
|
||||
baseURL := creds.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
botToken: creds.BotToken,
|
||||
botID: creds.ILinkBotID,
|
||||
httpClient: &http.Client{},
|
||||
wechatUIN: generateWechatUIN(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewUnauthenticatedClient creates a client without credentials for login flow.
|
||||
func NewUnauthenticatedClient() *Client {
|
||||
return &Client{
|
||||
baseURL: defaultBaseURL,
|
||||
httpClient: &http.Client{Timeout: 40 * time.Second},
|
||||
wechatUIN: generateWechatUIN(),
|
||||
}
|
||||
}
|
||||
|
||||
// BotID returns the bot's user ID.
|
||||
func (c *Client) BotID() string {
|
||||
return c.botID
|
||||
}
|
||||
|
||||
// GetUpdates performs a long-poll for new messages.
|
||||
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
|
||||
reqBody := GetUpdatesRequest{
|
||||
GetUpdatesBuf: buf,
|
||||
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var resp GetUpdatesResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SendMessage sends a message through iLink.
|
||||
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
var resp SendMessageResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetConfig fetches bot config for a user (includes typing_ticket).
|
||||
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := GetConfigRequest{
|
||||
ILinkUserID: userID,
|
||||
ContextToken: contextToken,
|
||||
BaseInfo: BaseInfo{},
|
||||
}
|
||||
|
||||
var resp GetConfigResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SendTyping sends a typing indicator to a user.
|
||||
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := SendTypingRequest{
|
||||
ILinkUserID: userID,
|
||||
TypingTicket: typingTicket,
|
||||
Status: status,
|
||||
BaseInfo: BaseInfo{},
|
||||
}
|
||||
|
||||
var resp SendTypingResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Ret != 0 {
|
||||
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUploadURL gets a pre-signed CDN upload URL for media files.
|
||||
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
var resp GetUploadURLResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// BaseURL returns the base URL for CDN operations.
|
||||
func (c *Client) BaseURL() string {
|
||||
return c.baseURL
|
||||
}
|
||||
|
||||
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
req.Header.Set("Authorization", "Bearer "+c.botToken)
|
||||
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
|
||||
}
|
||||
|
||||
func generateWechatUIN() string {
|
||||
var n uint32
|
||||
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
|
||||
s := fmt.Sprintf("%d", n)
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConsecutiveFailures = 5
|
||||
initialBackoff = 3 * time.Second
|
||||
maxBackoff = 60 * time.Second
|
||||
sessionExpiredBackoff = 5 * time.Second
|
||||
errCodeSessionExpired = -14
|
||||
)
|
||||
|
||||
// MessageHandler is called for each received message.
|
||||
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
|
||||
|
||||
// Monitor manages the long-poll loop for receiving messages.
|
||||
type Monitor struct {
|
||||
client *Client
|
||||
handler MessageHandler
|
||||
getUpdatesBuf string
|
||||
bufPath string
|
||||
failures int
|
||||
lastActivity time.Time
|
||||
}
|
||||
|
||||
// NewMonitor creates a new long-poll monitor.
|
||||
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountID := NormalizeAccountID(client.BotID())
|
||||
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
|
||||
|
||||
m := &Monitor{
|
||||
client: client,
|
||||
handler: handler,
|
||||
bufPath: bufPath,
|
||||
lastActivity: time.Now(),
|
||||
}
|
||||
m.loadBuf()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Run starts the long-poll loop. It blocks until ctx is cancelled.
|
||||
// Automatically recovers from errors with exponential backoff.
|
||||
func (m *Monitor) Run(ctx context.Context) error {
|
||||
log.Println("[monitor] starting long-poll loop")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("[monitor] shutting down")
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
m.failures++
|
||||
backoff := m.calcBackoff()
|
||||
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
|
||||
m.failures, maxConsecutiveFailures, backoff, err)
|
||||
if m.failures == maxConsecutiveFailures {
|
||||
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from WechatExplorer if this persists.", maxConsecutiveFailures)
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset failure counter on any successful response
|
||||
m.failures = 0
|
||||
m.lastActivity = time.Now()
|
||||
|
||||
// Session expired — reset sync buf and reconnect silently
|
||||
if resp.ErrCode == errCodeSessionExpired {
|
||||
if m.getUpdatesBuf != "" {
|
||||
log.Printf("[monitor] session expired, resetting sync buf")
|
||||
m.getUpdatesBuf = ""
|
||||
m.saveBuf()
|
||||
} else {
|
||||
// Sync buf already empty but still getting session expired:
|
||||
// the bot token itself has expired. The user needs to re-login.
|
||||
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from WechatExplorer.")
|
||||
}
|
||||
select {
|
||||
case <-time.After(sessionExpiredBackoff):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Other server errors
|
||||
if resp.Ret != 0 && resp.ErrCode != 0 {
|
||||
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update buf for next poll
|
||||
if resp.GetUpdatesBuf != "" {
|
||||
m.getUpdatesBuf = resp.GetUpdatesBuf
|
||||
m.saveBuf()
|
||||
}
|
||||
|
||||
// Process messages concurrently — don't block the poll loop
|
||||
for _, msg := range resp.Msgs {
|
||||
go m.handler(ctx, m.client, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
|
||||
func (m *Monitor) calcBackoff() time.Duration {
|
||||
d := initialBackoff
|
||||
for i := 1; i < m.failures; i++ {
|
||||
d *= 2
|
||||
if d > maxBackoff {
|
||||
return maxBackoff
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type syncData struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
}
|
||||
|
||||
func (m *Monitor) loadBuf() {
|
||||
data, err := os.ReadFile(m.bufPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var s syncData
|
||||
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
|
||||
m.getUpdatesBuf = s.GetUpdatesBuf
|
||||
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) saveBuf() {
|
||||
dir := filepath.Dir(m.bufPath)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
log.Printf("[monitor] failed to create buf dir: %v", err)
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
|
||||
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
|
||||
log.Printf("[monitor] failed to save buf: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatMessageSummary returns a short description of a message for logging.
|
||||
func FormatMessageSummary(msg WeixinMessage) string {
|
||||
text := ""
|
||||
for _, item := range msg.ItemList {
|
||||
if item.Type == ItemTypeText && item.TextItem != nil {
|
||||
text = item.TextItem.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(text) > 50 {
|
||||
text = text[:50] + "..."
|
||||
}
|
||||
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package ilink
|
||||
|
||||
// Message types
|
||||
const (
|
||||
MessageTypeNone = 0
|
||||
MessageTypeUser = 1
|
||||
MessageTypeBot = 2
|
||||
)
|
||||
|
||||
// Message states
|
||||
const (
|
||||
MessageStateNew = 0
|
||||
MessageStateGenerating = 1
|
||||
MessageStateFinish = 2
|
||||
)
|
||||
|
||||
// Item types
|
||||
const (
|
||||
ItemTypeNone = 0
|
||||
ItemTypeText = 1
|
||||
ItemTypeImage = 2
|
||||
ItemTypeVoice = 3
|
||||
ItemTypeFile = 4
|
||||
ItemTypeVideo = 5
|
||||
)
|
||||
|
||||
// QRCodeResponse is the response from get_bot_qrcode.
|
||||
type QRCodeResponse struct {
|
||||
QRCode string `json:"qrcode"`
|
||||
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||
}
|
||||
|
||||
// QRStatusResponse is the response from get_qrcode_status.
|
||||
type QRStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
BotToken string `json:"bot_token"`
|
||||
ILinkBotID string `json:"ilink_bot_id"`
|
||||
BaseURL string `json:"baseurl"`
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
}
|
||||
|
||||
// Credentials stores login session data.
|
||||
type Credentials struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ILinkBotID string `json:"ilink_bot_id"`
|
||||
BaseURL string `json:"baseurl"`
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
}
|
||||
|
||||
// BaseInfo is included in request bodies.
|
||||
type BaseInfo struct {
|
||||
ChannelVersion string `json:"channel_version,omitempty"`
|
||||
}
|
||||
|
||||
// GetUpdatesRequest is the body for getupdates.
|
||||
type GetUpdatesRequest struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// GetUpdatesResponse is the response from getupdates.
|
||||
type GetUpdatesResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Msgs []WeixinMessage `json:"msgs"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
|
||||
}
|
||||
|
||||
// WeixinMessage represents a message from WeChat.
|
||||
type WeixinMessage struct {
|
||||
Seq int `json:"seq,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
MessageState int `json:"message_state"`
|
||||
ItemList []MessageItem `json:"item_list"`
|
||||
ContextToken string `json:"context_token"`
|
||||
}
|
||||
|
||||
// MessageItem is a single item in a message.
|
||||
type MessageItem struct {
|
||||
Type int `json:"type"`
|
||||
TextItem *TextItem `json:"text_item,omitempty"`
|
||||
ImageItem *ImageItem `json:"image_item,omitempty"`
|
||||
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
|
||||
VideoItem *VideoItem `json:"video_item,omitempty"`
|
||||
FileItem *FileItem `json:"file_item,omitempty"`
|
||||
}
|
||||
|
||||
// CDN media type constants.
|
||||
const (
|
||||
CDNMediaTypeImage = 1
|
||||
CDNMediaTypeVideo = 2
|
||||
CDNMediaTypeFile = 3
|
||||
)
|
||||
|
||||
// GetUploadURLRequest is the body for getuploadurl.
|
||||
type GetUploadURLRequest struct {
|
||||
FileKey string `json:"filekey"`
|
||||
MediaType int `json:"media_type"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
RawSize int `json:"rawsize"`
|
||||
RawFileMD5 string `json:"rawfilemd5"`
|
||||
FileSize int `json:"filesize"`
|
||||
NoNeedThumb bool `json:"no_need_thumb"`
|
||||
AESKey string `json:"aeskey"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// GetUploadURLResponse is the response from getuploadurl.
|
||||
type GetUploadURLResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
UploadParam string `json:"upload_param"`
|
||||
UploadFullURL string `json:"upload_full_url,omitempty"`
|
||||
}
|
||||
|
||||
// TextItem holds text content.
|
||||
type TextItem struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// MediaInfo holds CDN media reference for uploaded files.
|
||||
type MediaInfo struct {
|
||||
EncryptQueryParam string `json:"encrypt_query_param"`
|
||||
AESKey string `json:"aes_key"` // base64-encoded
|
||||
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
|
||||
}
|
||||
|
||||
// VoiceItem holds voice content.
|
||||
type VoiceItem struct {
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
VoiceSize int `json:"voice_size,omitempty"`
|
||||
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
|
||||
BitsPerSample int `json:"bits_per_sample,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"` // Hz
|
||||
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
|
||||
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
|
||||
}
|
||||
|
||||
// ImageItem holds image content.
|
||||
type ImageItem struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
MidSize int `json:"mid_size,omitempty"` // ciphertext size
|
||||
}
|
||||
|
||||
// VideoItem holds video content.
|
||||
type VideoItem struct {
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
VideoSize int `json:"video_size,omitempty"`
|
||||
}
|
||||
|
||||
// FileItem holds file content.
|
||||
type FileItem struct {
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
Len string `json:"len,omitempty"` // plaintext size as string
|
||||
}
|
||||
|
||||
// SendMessageRequest is the body for sendmessage.
|
||||
type SendMessageRequest struct {
|
||||
Msg SendMsg `json:"msg"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// SendMsg is the message payload for sending.
|
||||
type SendMsg struct {
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
MessageState int `json:"message_state"`
|
||||
ItemList []MessageItem `json:"item_list"`
|
||||
ContextToken string `json:"context_token"`
|
||||
}
|
||||
|
||||
// SendMessageResponse is the response from sendmessage.
|
||||
type SendMessageResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
}
|
||||
|
||||
// Typing status constants.
|
||||
const (
|
||||
TypingStatusTyping = 1
|
||||
TypingStatusCancel = 2
|
||||
)
|
||||
|
||||
// GetConfigRequest is the body for getconfig.
|
||||
type GetConfigRequest struct {
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// GetConfigResponse is the response from getconfig.
|
||||
type GetConfigResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||
}
|
||||
|
||||
// SendTypingRequest is the body for sendtyping.
|
||||
type SendTypingRequest struct {
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
TypingTicket string `json:"typing_ticket"`
|
||||
Status int `json:"status"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// SendTypingResponse is the response from sendtyping.
|
||||
type SendTypingResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user