Files
jason1804 7e51666357 fix(feishu): parse bot/v3/info top-level bot object for mention detection (#1189)
The legacy /open-apis/bot/v3/info endpoint returns the bot object at the
top level of the response, not inside "data" like newer Lark APIs. The
probe always failed to extract open_id, leaving botOpenID empty, which
made every group mention count as a bot mention — the bot replied to
messages mentioning other users even with requireMention enabled.

- Read top-level "bot" first, fall back to data.bot defensively
- Retry the startup probe with backoff to survive transient failures
- Fix test fixtures to match the real API response shape

Fixes #895
2026-06-21 03:34:07 +07:00

383 lines
11 KiB
Go

package feishu
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/url"
"strconv"
)
// --- IM API: Messages ---
type SendMessageResp struct {
MessageID string `json:"message_id"`
}
func (c *LarkClient) SendMessage(ctx context.Context, receiveIDType, receiveID, msgType, content string) (*SendMessageResp, error) {
path := "/open-apis/im/v1/messages?receive_id_type=" + receiveIDType
body := map[string]string{
"receive_id": receiveID,
"msg_type": msgType,
"content": content,
}
resp, err := c.doJSON(ctx, "POST", path, body)
if err != nil {
return nil, err
}
if resp.Code != 0 {
return nil, fmt.Errorf("send message: code=%d msg=%s", resp.Code, resp.Msg)
}
var data SendMessageResp
if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return &data, nil
}
// ReplyMessage posts a reply to an existing Lark message via
// POST /open-apis/im/v1/messages/{message_id}/reply.
//
// When replyInThread is true and the target message is inside a thread,
// the reply stays nested in that thread. When the target is not threaded,
// the reply renders as an inline quote at chat level.
//
// NOTE: `content` must be a double-encoded JSON string (e.g. `{"text":"hi"}`),
// not a Go struct. The Lark API rejects object-typed `content`.
func (c *LarkClient) ReplyMessage(ctx context.Context, rootMessageID, msgType, content string, replyInThread bool) (*SendMessageResp, error) {
if rootMessageID == "" {
return nil, fmt.Errorf("reply message: empty root message id")
}
// Defensive escaping: Lark message IDs are currently `om_` + alphanumeric,
// but any future schema change that permits `/` or reserved chars would
// otherwise silently break URL routing.
path := fmt.Sprintf("/open-apis/im/v1/messages/%s/reply", url.PathEscape(rootMessageID))
body := map[string]any{
"msg_type": msgType,
"content": content,
"reply_in_thread": replyInThread,
}
resp, err := c.doJSON(ctx, "POST", path, body)
if err != nil {
return nil, err
}
if resp.Code != 0 {
return nil, fmt.Errorf("reply message: code=%d msg=%s", resp.Code, resp.Msg)
}
var data SendMessageResp
if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return &data, nil
}
// --- IM API: Images ---
func (c *LarkClient) DownloadImage(ctx context.Context, imageKey string) ([]byte, error) {
path := "/open-apis/im/v1/images/" + imageKey
data, _, err := c.doDownload(ctx, path)
return data, err
}
func (c *LarkClient) UploadImage(ctx context.Context, data io.Reader) (string, error) {
resp, err := c.doMultipart(ctx, "/open-apis/im/v1/images",
map[string]string{"image_type": "message"},
"image", data, "image.png")
if err != nil {
return "", err
}
if resp.Code != 0 {
return "", fmt.Errorf("upload image: code=%d msg=%s", resp.Code, resp.Msg)
}
var result struct {
ImageKey string `json:"image_key"`
}
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.ImageKey, nil
}
// --- IM API: Files ---
func (c *LarkClient) UploadFile(ctx context.Context, data io.Reader, fileName, fileType string, durationMs int) (string, error) {
fields := map[string]string{
"file_type": fileType,
"file_name": fileName,
}
if durationMs > 0 {
fields["duration"] = strconv.Itoa(durationMs)
}
resp, err := c.doMultipart(ctx, "/open-apis/im/v1/files", fields, "file", data, fileName)
if err != nil {
return "", err
}
if resp.Code != 0 {
return "", fmt.Errorf("upload file: code=%d msg=%s", resp.Code, resp.Msg)
}
var result struct {
FileKey string `json:"file_key"`
}
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.FileKey, nil
}
// --- IM API: Get Message ---
// GetMessageResp holds the response from GET /open-apis/im/v1/messages/{message_id}.
type GetMessageResp struct {
Items []struct {
MessageID string `json:"message_id"`
MsgType string `json:"msg_type"`
Body struct {
Content string `json:"content"`
} `json:"body"`
Sender struct {
ID string `json:"id"`
IDType string `json:"id_type"`
SenderType string `json:"sender_type"`
} `json:"sender"`
} `json:"items"`
}
// GetMessage retrieves a message by ID.
// Lark API: GET /open-apis/im/v1/messages/{message_id}
func (c *LarkClient) GetMessage(ctx context.Context, messageID string) (*GetMessageResp, error) {
path := fmt.Sprintf("/open-apis/im/v1/messages/%s", messageID)
resp, err := c.doJSON(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
if resp.Code != 0 {
return nil, fmt.Errorf("get message: code=%d msg=%s", resp.Code, resp.Msg)
}
var data GetMessageResp
if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, fmt.Errorf("unmarshal get message: %w", err)
}
return &data, nil
}
// --- IM API: Message Resources ---
func (c *LarkClient) DownloadMessageResource(ctx context.Context, messageID, fileKey, resourceType string) ([]byte, string, error) {
path := fmt.Sprintf("/open-apis/im/v1/messages/%s/resources/%s?type=%s", messageID, fileKey, resourceType)
return c.doDownload(ctx, path)
}
// --- CardKit API ---
func (c *LarkClient) CreateCard(ctx context.Context, cardType, data string) (string, error) {
resp, err := c.doJSON(ctx, "POST", "/open-apis/cardkit/v1/cards", map[string]string{
"type": cardType,
"data": data,
})
if err != nil {
return "", err
}
if resp.Code != 0 {
return "", fmt.Errorf("create card: code=%d msg=%s", resp.Code, resp.Msg)
}
var result struct {
CardID string `json:"card_id"`
}
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.CardID, nil
}
func (c *LarkClient) UpdateCardSettings(ctx context.Context, cardID, settings string, seq int, uuid string) error {
path := "/open-apis/cardkit/v1/cards/" + cardID
resp, err := c.doJSON(ctx, "PATCH", path, map[string]any{
"settings": settings,
"sequence": seq,
"uuid": uuid,
})
if err != nil {
return err
}
if resp.Code != 0 {
return fmt.Errorf("update card settings: code=%d msg=%s", resp.Code, resp.Msg)
}
return nil
}
func (c *LarkClient) UpdateCardElement(ctx context.Context, cardID, elementID, content string, seq int, uuid string) error {
path := fmt.Sprintf("/open-apis/cardkit/v1/cards/%s/elements/%s", cardID, elementID)
resp, err := c.doJSON(ctx, "PATCH", path, map[string]any{
"content": content,
"sequence": seq,
"uuid": uuid,
})
if err != nil {
return err
}
if resp.Code != 0 {
slog.Debug("lark update card element failed", "code", resp.Code, "msg", resp.Msg)
return fmt.Errorf("update card element: code=%d msg=%s", resp.Code, resp.Msg)
}
return nil
}
// --- IM API: Reactions ---
// AddMessageReaction adds an emoji reaction to a message.
// Returns the reaction_id for later removal. emojiType: e.g. "Typing", "THUMBSUP".
// Lark API: POST /open-apis/im/v1/messages/{message_id}/reactions
func (c *LarkClient) AddMessageReaction(ctx context.Context, messageID, emojiType string) (string, error) {
path := fmt.Sprintf("/open-apis/im/v1/messages/%s/reactions", messageID)
body := map[string]any{
"reaction_type": map[string]string{
"emoji_type": emojiType,
},
}
resp, err := c.doJSON(ctx, "POST", path, body)
if err != nil {
return "", err
}
if resp.Code != 0 {
return "", fmt.Errorf("add reaction: code=%d msg=%s", resp.Code, resp.Msg)
}
var result struct {
ReactionID string `json:"reaction_id"`
}
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.ReactionID, nil
}
// DeleteMessageReaction removes a reaction from a message.
// Lark API: DELETE /open-apis/im/v1/messages/{message_id}/reactions/{reaction_id}
func (c *LarkClient) DeleteMessageReaction(ctx context.Context, messageID, reactionID string) error {
path := fmt.Sprintf("/open-apis/im/v1/messages/%s/reactions/%s", messageID, reactionID)
resp, err := c.doJSON(ctx, "DELETE", path, nil)
if err != nil {
return err
}
if resp.Code != 0 {
return fmt.Errorf("delete reaction: code=%d msg=%s", resp.Code, resp.Msg)
}
return nil
}
// --- Bot API ---
// GetBotInfo fetches the bot's identity from /open-apis/bot/v3/info.
// Returns the bot's open_id which is needed for mention detection in groups.
// This legacy endpoint returns the bot object at the TOP level of the
// response ({"code":0,"msg":"ok","bot":{...}}), not inside "data" like
// newer Lark APIs — read resp.Bot first, with a data.bot fallback.
func (c *LarkClient) GetBotInfo(ctx context.Context) (string, error) {
resp, err := c.doJSON(ctx, "GET", "/open-apis/bot/v3/info", nil)
if err != nil {
return "", err
}
if resp.Code != 0 {
return "", fmt.Errorf("get bot info: code=%d msg=%s", resp.Code, resp.Msg)
}
var botInfo struct {
OpenID string `json:"open_id"`
}
if len(resp.Bot) > 0 {
if err := json.Unmarshal(resp.Bot, &botInfo); err != nil {
return "", fmt.Errorf("unmarshal bot info: %w", err)
}
return botInfo.OpenID, nil
}
// Fallback: some deployments may wrap the bot object inside "data".
if len(resp.Data) > 0 {
var wrapped struct {
Bot struct {
OpenID string `json:"open_id"`
} `json:"bot"`
}
if err := json.Unmarshal(resp.Data, &wrapped); err != nil {
return "", fmt.Errorf("unmarshal bot info data: %w", err)
}
return wrapped.Bot.OpenID, nil
}
return "", fmt.Errorf("get bot info: response missing bot object")
}
// --- IM API: Chat Members ---
// ChatMember represents a member of a Lark group chat.
type ChatMember struct {
MemberID string `json:"member_id"`
MemberIDType string `json:"member_id_type"`
Name string `json:"name"`
TenantKey string `json:"tenant_key"`
}
// ListChatMembers returns all members of a group chat, handling pagination automatically.
// Lark API: GET /open-apis/im/v1/chats/{chat_id}/members
// Requires scope: im:chat.members:read
func (c *LarkClient) ListChatMembers(ctx context.Context, chatID string) ([]ChatMember, error) {
var all []ChatMember
pageToken := ""
for {
path := fmt.Sprintf("/open-apis/im/v1/chats/%s/members?member_id_type=open_id&page_size=100", url.PathEscape(chatID))
if pageToken != "" {
path += "&page_token=" + url.QueryEscape(pageToken)
}
resp, err := c.doJSON(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
if resp.Code != 0 {
return nil, fmt.Errorf("list chat members: code=%d msg=%s", resp.Code, resp.Msg)
}
var result struct {
Items []ChatMember `json:"items"`
PageToken string `json:"page_token"`
HasMore bool `json:"has_more"`
}
if err := json.Unmarshal(resp.Data, &result); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
all = append(all, result.Items...)
if !result.HasMore || result.PageToken == "" {
break
}
pageToken = result.PageToken
}
return all, nil
}
// --- Contact API ---
func (c *LarkClient) GetUser(ctx context.Context, userID, userIDType string) (string, error) {
path := fmt.Sprintf("/open-apis/contact/v3/users/%s?user_id_type=%s", userID, userIDType)
resp, err := c.doJSON(ctx, "GET", path, nil)
if err != nil {
return "", err
}
if resp.Code != 0 {
return "", fmt.Errorf("get user: code=%d msg=%s", resp.Code, resp.Msg)
}
var result struct {
User struct {
Name string `json:"name"`
} `json:"user"`
}
if err := json.Unmarshal(resp.Data, &result); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
return result.User.Name, nil
}