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