picoclaw/pkg/channels/feishu/api_helpers.go
ywj c797d600d2 fix(providers): 增强 OpenAI 兼容接口报错信息,完善 SiliconFlow 兼容性并修复 Windows 下 Codex CLI 测试
- 优化 openai_compat 的 HTTP 错误处理,支持解析结构化 JSON 错误体
- 在报错中补充 api_base、model、path、message、code 等上下文信息
- 增加 SiliconFlow 作为一等 OpenAI-compatible provider 别名
- 支持在设置 api_base 时直接使用上游原始模型名(如 Pro/zai-org/GLM-4.7)
- 更新 README、README.zh.md 和 config.example.json 中的 SiliconFlow / api_base 配置示例
- 修复 Windows 下 Codex CLI mock 测试无法执行临时脚本的问题

update:主要完善了 PicoClaw 对飞书的支持,重点包括以下内容:
基于飞书官方 Go SDK 完善飞书通道能力
补全飞书消息收发相关实现
增强对飞书富文本/卡片消息的兼容处理
对齐 Python SDK 示例中的已有功能范围
增强飞书媒体消息处理能力
支持从飞书消息中递归提取所有图片、文件等附件信息
支持多图片、多附件消息的解析与处理
优化附件下载与媒体引用传递流程
接入现有图片/视觉处理链路
将飞书图片消息接入 PicoClaw 现有多模态处理流程
使图片能够随用户文本一起进入模型请求
为后续视觉模型识图能力打通基础链路
修复 Feishu/Windows amd64 相关构建与运行问题
清理 Feishu 包内重复定义与残留实现
修复 64 位 Windows 构建过程中的编译问题
修复因错误架构判断导致的飞书通道不可用问题
改进 OpenAI-compatible Provider 对 GLM/Zhipu 多模态请求的兼容性
针对 glm / zhipu 兼容端点增加图片请求格式适配
调整多模态消息序列化逻辑
对齐 glm-4.6v-flash 官方样例的请求结构
补充测试并完成回归验证
增加飞书消息解析、多附件提取、多模态序列化等测试
已通过 pkg/providers/openai_compat、pkg/channels/feishu、pkg/tools 相关测试
2026-03-13 14:45:40 +08:00

415 lines
11 KiB
Go

package feishu
import (
"bytes"
"encoding/json"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
)
func parseMessageContentPayload(messageData map[string]any) *ParsedMessage {
if len(messageData) == 0 {
return nil
}
items, _ := messageData["items"].([]any)
if len(items) == 0 {
if msgMap, ok := messageData["message"].(map[string]any); ok {
return parseMessageItem(msgMap)
}
return parseMessageItem(messageData)
}
first, _ := items[0].(map[string]any)
return parseMessageItem(first)
}
func parseMessageItem(item map[string]any) *ParsedMessage {
if len(item) == 0 {
return nil
}
body, _ := item["body"].(map[string]any)
contentStr, _ := body["content"].(string)
var content any
if contentStr != "" {
if err := json.Unmarshal([]byte(contentStr), &content); err != nil {
content = contentStr
}
}
return &ParsedMessage{
MessageID: stringFromAny(item["message_id"]),
ChatID: stringFromAny(item["chat_id"]),
MsgType: stringFromAny(item["msg_type"]),
CreateTime: stringFromAny(item["create_time"]),
Sender: mapFromAny(item["sender"]),
Content: content,
}
}
func parseCardSummary(card any) *CardSummary {
cardMap, ok := card.(map[string]any)
if !ok || len(cardMap) == 0 {
return nil
}
summary := &CardSummary{}
if title := extractCardTitle(cardMap); title != "" {
summary.Title = title
}
components := extractCardComponents(cardMap)
for i, component := range components {
compMap, _ := component.(map[string]any)
if len(compMap) == 0 {
continue
}
parsed := CardComponent{Index: i + 1, Tag: stringFromAny(compMap["tag"]), Details: map[string]any{}}
tag := parsed.Tag
switch tag {
case "img":
parsed.Details = map[string]any{
"image_key": stringFromAny(compMap["image_key"]),
"width": compMap["width"],
"height": compMap["height"],
"alt": compMap["alt"],
}
if key := stringFromAny(compMap["image_key"]); key != "" {
summary.ImageKeys = append(summary.ImageKeys, key)
}
case "text", "note", "header":
text := extractTextContent(compMap["text"])
parsed.Details = map[string]any{"text": text, "style": compMap["style"], "mode": compMap["mode"]}
if text != "" {
summary.TextContents = append(summary.TextContents, text)
}
case "div":
text := extractTextContent(compMap["text"])
parsed.Details = map[string]any{
"text": text,
"style": compMap["style"],
"mode": compMap["mode"],
"fields": compMap["fields"],
}
if text != "" {
summary.TextContents = append(summary.TextContents, text)
}
case "action":
actions, _ := compMap["actions"].([]any)
parsedActions := make([]map[string]any, 0, len(actions))
for _, rawAction := range actions {
action, _ := rawAction.(map[string]any)
btnText := extractTextContent(action["text"])
parsedActions = append(parsedActions, map[string]any{
"text": btnText,
"type": stringFromAny(action["type"]),
"style": mapFromAny(action["style"]),
})
if btnText != "" {
summary.ActionButtons = append(summary.ActionButtons, btnText)
}
}
parsed.Details = map[string]any{"action_count": len(actions), "actions": parsedActions}
case "at":
parsed.Details = map[string]any{
"user_id": stringFromAny(compMap["user_id"]),
"user_name": stringFromAny(compMap["user_name"]),
"user_avatar": stringFromAny(compMap["user_avatar"]),
"style": compMap["style"],
}
default:
parsed.Details = compMap
}
summary.Components = append(summary.Components, parsed)
}
return summary
}
func extractCardTitle(card map[string]any) string {
if title := stringFromAny(card["title"]); title != "" {
return title
}
if header, ok := card["header"].(map[string]any); ok {
if titleMap, ok := header["title"].(map[string]any); ok {
if text := stringFromAny(titleMap["content"]); text != "" {
return text
}
}
}
return ""
}
func extractCardComponents(card map[string]any) []any {
if body, ok := card["body"].(map[string]any); ok {
if elements, ok := body["elements"].([]any); ok {
return elements
}
}
if elements, ok := card["elements"].([]any); ok {
return elements
}
if content, ok := card["content"].([]any); ok {
return content
}
return nil
}
func extractTextContent(v any) string {
switch vv := v.(type) {
case string:
return vv
case map[string]any:
if s := stringFromAny(vv["content"]); s != "" {
return s
}
if s := stringFromAny(vv["text"]); s != "" {
return s
}
}
return ""
}
func extractShareLinkToken(shareLink string) string {
u, err := url.Parse(shareLink)
if err != nil {
return ""
}
return u.Query().Get("token")
}
func mapFromAny(v any) map[string]any {
m, _ := v.(map[string]any)
if m == nil {
return map[string]any{}
}
return m
}
func stringFromAny(v any) string {
switch vv := v.(type) {
case string:
return vv
case *string:
if vv != nil {
return *vv
}
case json.Number:
return vv.String()
case float64:
return strconv.FormatFloat(vv, 'f', -1, 64)
case int:
return strconv.Itoa(vv)
case int64:
return strconv.FormatInt(vv, 10)
case bool:
return strconv.FormatBool(vv)
}
return ""
}
func boolFromAny(v any) bool {
switch vv := v.(type) {
case bool:
return vv
case string:
return strings.EqualFold(vv, "true")
}
return false
}
func int64FromAny(v any) int64 {
switch vv := v.(type) {
case int:
return int64(vv)
case int64:
return vv
case float64:
return int64(vv)
case json.Number:
n, _ := vv.Int64()
return n
case string:
n, _ := strconv.ParseInt(strings.TrimSpace(vv), 10, 64)
return n
}
return 0
}
func normalizeMessageDetail(item map[string]any) MessageDetail {
detail := MessageDetail{
MessageID: stringFromAny(item["message_id"]),
ChatID: stringFromAny(item["chat_id"]),
RootID: stringFromAny(item["root_id"]),
ParentID: stringFromAny(item["parent_id"]),
MsgType: stringFromAny(item["msg_type"]),
Deleted: boolFromAny(item["deleted"]),
Updated: boolFromAny(item["updated"]),
CreateTime: stringFromAny(item["create_time"]),
UpdateTime: stringFromAny(item["update_time"]),
Sender: mapFromAny(item["sender"]),
Body: mapFromAny(item["body"]),
Raw: item,
}
if mentions, ok := item["mentions"].([]any); ok {
for _, mention := range mentions {
if m, ok := mention.(map[string]any); ok {
detail.Mentions = append(detail.Mentions, m)
}
}
}
if parsed := parseMessageItem(item); parsed != nil {
detail.Parsed = parsed
if parsed.MsgType == larkInteractiveMsgType() {
detail.CardParsed = parseCardSummary(parsed.Content)
}
}
return detail
}
func larkInteractiveMsgType() string { return "interactive" }
func normalizeDriveFile(item map[string]any) DriveFileSummary {
return DriveFileSummary{
FileToken: firstNonEmpty(stringFromAny(item["file_token"]), stringFromAny(item["token"]), stringFromAny(item["file_key"])),
Name: firstNonEmpty(stringFromAny(item["name"]), stringFromAny(item["file_name"])),
Type: stringFromAny(item["type"]),
ParentToken: firstNonEmpty(stringFromAny(item["parent_token"]), stringFromAny(item["parent_node"])),
Size: int64FromAny(item["size"]),
Extension: firstNonEmpty(stringFromAny(item["extension"]), stringFromAny(item["file_extension"])),
MimeType: firstNonEmpty(stringFromAny(item["mime_type"]), stringFromAny(item["content_type"])),
URL: firstNonEmpty(stringFromAny(item["url"]), stringFromAny(item["download_url"])),
CreatedTime: stringFromAny(item["created_time"]),
ModifiedTime: stringFromAny(item["modified_time"]),
Raw: item,
}
}
func normalizeDriveFolder(item map[string]any) DriveFolderSummary {
return DriveFolderSummary{
FolderToken: firstNonEmpty(stringFromAny(item["folder_token"]), stringFromAny(item["token"])),
Name: firstNonEmpty(stringFromAny(item["name"]), stringFromAny(item["folder_name"])),
ParentToken: firstNonEmpty(stringFromAny(item["parent_token"]), stringFromAny(item["parent_node"])),
URL: firstNonEmpty(stringFromAny(item["url"]), stringFromAny(item["folder_url"])),
CreatedTime: stringFromAny(item["created_time"]),
ModifiedTime: stringFromAny(item["modified_time"]),
Raw: item,
}
}
func filenameFromHeader(h http.Header) string {
cd := h.Get("Content-Disposition")
if cd == "" {
return ""
}
_, params, err := mime.ParseMediaType(cd)
if err != nil {
return ""
}
if v := params["filename*"]; v != "" {
if i := strings.Index(v, "''"); i >= 0 && i+2 < len(v) {
if decoded, err := url.QueryUnescape(v[i+2:]); err == nil {
return decoded
}
}
return v
}
return params["filename"]
}
func buildDriveUploadBody(parentToken, name string, r io.Reader) (*bytes.Buffer, string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.WriteField("file_name", name); err != nil {
return nil, "", err
}
if parentToken != "" {
if err := w.WriteField("parent_type", "explorer"); err != nil {
return nil, "", err
}
if err := w.WriteField("parent_node", parentToken); err != nil {
return nil, "", err
}
}
part, err := w.CreateFormFile("file", filepath.Base(name))
if err != nil {
return nil, "", err
}
if _, err := io.Copy(part, r); err != nil {
return nil, "", err
}
if err := w.Close(); err != nil {
return nil, "", err
}
return &buf, w.FormDataContentType(), nil
}
func buildMultipartChunkBody(seq int, data []byte) (*bytes.Buffer, string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.WriteField("seq", strconv.Itoa(seq)); err != nil {
return nil, "", err
}
part, err := w.CreateFormFile("file", "chunk")
if err != nil {
return nil, "", err
}
if _, err := part.Write(data); err != nil {
return nil, "", err
}
if err := w.Close(); err != nil {
return nil, "", err
}
return &buf, w.FormDataContentType(), nil
}
func normalizeUserSummary(item map[string]any) UserSummary {
return UserSummary{
ID: firstNonEmpty(stringFromAny(item["id"]), stringFromAny(item["open_id"]), stringFromAny(item["user_id"])),
OpenID: stringFromAny(item["open_id"]),
UserID: stringFromAny(item["user_id"]),
UnionID: stringFromAny(item["union_id"]),
Name: stringFromAny(item["name"]),
EnName: stringFromAny(item["en_name"]),
Email: stringFromAny(item["email"]),
Mobile: stringFromAny(item["mobile"]),
AvatarURL: stringFromAny(item["avatar_url"]),
Status: mapFromAny(item["status"]),
Raw: item,
}
}
func normalizeChatSummary(item map[string]any) ChatSummary {
ownerID := stringFromAny(item["owner_id"])
if ownerID == "" {
if ownerMap, ok := item["owner_id_type"].(map[string]any); ok {
ownerID = stringFromAny(ownerMap["user_id"])
}
}
return ChatSummary{
ChatID: firstNonEmpty(stringFromAny(item["chat_id"]), stringFromAny(item["open_chat_id"])),
Name: stringFromAny(item["name"]),
Description: stringFromAny(item["description"]),
ChatMode: stringFromAny(item["chat_mode"]),
ChatType: stringFromAny(item["chat_type"]),
OwnerID: ownerID,
OwnerOpenID: stringFromAny(item["owner_open_id"]),
External: boolFromAny(item["external"]),
TenantKey: stringFromAny(item["tenant_key"]),
Avatar: firstNonEmpty(stringFromAny(item["avatar"]), stringFromAny(item["avatar_path"])),
Raw: item,
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}