Merge pull request #16 from kevinWangSheng/fix/feishu-post-and-slash-passthrough

fix: 飞书 post 富文本支持 & slash 命令透传
This commit is contained in:
cg33 authored and GitHub committed 2026-03-04 18:11:17 +08:00
commit 1c69c843e4
3 files changed
+189 -6

No files matched your search

+9 -6
View File
@@ -289,8 +289,10 @@ func (e *Engine) handleMessage(p Platform, msg *Message) {
}
if len(msg.Images) == 0 && strings.HasPrefix(content, "/") {
e.handleCommand(p, msg, content)
return
if e.handleCommand(p, msg, content) {
return
}
// unrecognized slash command — fall through to agent
}
// Permission responses bypass the session lock
@@ -735,7 +737,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess
// Command handling
// ──────────────────────────────────────────────────────────────
func (e *Engine) handleCommand(p Platform, msg *Message, raw string) {
func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool {
parts := strings.Fields(raw)
cmd := strings.ToLower(parts[0])
args := parts[1:]
@@ -789,14 +791,15 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) {
cmdName := strings.TrimPrefix(cmd, "/")
if custom, ok := e.commands.Resolve(cmdName); ok {
e.executeCustomCommand(p, msg, custom, args)
return
return true
}
if skill := e.skills.Resolve(cmdName); skill != nil {
e.executeSkill(p, msg, skill, args)
return
return true
}
e.reply(p, msg.ReplyCtx, fmt.Sprintf("Unknown command: %s\nType /help for available commands.", cmd))
return false // unrecognized, let caller forward to agent
}
return true
}
func (e *Engine) cmdNew(p Platform, msg *Message, args []string) {
+77
View File
@@ -233,6 +233,19 @@ func (p *Platform) onMessage(event *larkim.P2MessageReceiveV1) error {
ReplyCtx: rctx,
})
case "post":
textParts, images := p.parsePostContent(messageID, *msg.Content)
text := strings.Join(textParts, "\n")
if text == "" && len(images) == 0 {
return nil
}
p.handler(p, &core.Message{
SessionKey: sessionKey, Platform: "feishu",
UserID: userID, UserName: userName,
Content: text, Images: images,
ReplyCtx: rctx,
})
default:
slog.Debug("feishu: ignoring unsupported message type", "type", msgType)
}
@@ -448,3 +461,67 @@ func (p *Platform) Stop() error {
}
return nil
}
type postElement struct {
Tag string `json:"tag"`
Text string `json:"text,omitempty"`
ImageKey string `json:"image_key,omitempty"`
Href string `json:"href,omitempty"`
}
type postLang struct {
Title string `json:"title"`
Content [][]postElement `json:"content"`
}
// parsePostContent handles both formats of feishu post content:
// 1. {"title":"...", "content":[[...]]} (receive event)
// 2. {"zh_cn":{"title":"...", "content":[[...]]}} (some SDK versions)
func (p *Platform) parsePostContent(messageID, raw string) ([]string, []core.ImageAttachment) {
// try flat format first
var flat postLang
if err := json.Unmarshal([]byte(raw), &flat); err == nil && flat.Content != nil {
return p.extractPostParts(messageID, &flat)
}
// try language-keyed format
var langMap map[string]postLang
if err := json.Unmarshal([]byte(raw), &langMap); err == nil {
for _, lang := range langMap {
return p.extractPostParts(messageID, &lang)
}
}
slog.Error("feishu: failed to parse post content", "raw", raw)
return nil, nil
}
func (p *Platform) extractPostParts(messageID string, post *postLang) ([]string, []core.ImageAttachment) {
var textParts []string
var images []core.ImageAttachment
if post.Title != "" {
textParts = append(textParts, post.Title)
}
for _, line := range post.Content {
for _, elem := range line {
switch elem.Tag {
case "text":
if elem.Text != "" {
textParts = append(textParts, elem.Text)
}
case "a":
if elem.Text != "" {
textParts = append(textParts, elem.Text)
}
case "img":
if elem.ImageKey != "" {
imgData, mimeType, err := p.downloadImage(messageID, elem.ImageKey)
if err != nil {
slog.Error("feishu: download post image failed", "error", err, "key", elem.ImageKey)
continue
}
images = append(images, core.ImageAttachment{MimeType: mimeType, Data: imgData})
}
}
}
}
return textParts, images
}
+103
View File
@@ -0,0 +1,103 @@
package feishu
import (
"testing"
)
func TestExtractPostParts_TextOnly(t *testing.T) {
p := &Platform{}
post := &postLang{
Title: "标题",
Content: [][]postElement{
{
{Tag: "text", Text: "第一行"},
{Tag: "text", Text: "接着"},
},
{
{Tag: "text", Text: "第二行"},
},
},
}
texts, images := p.extractPostParts("", post)
if len(texts) != 4 {
t.Fatalf("expected 4 text parts, got %d", len(texts))
}
if texts[0] != "标题" {
t.Errorf("expected title '标题', got %q", texts[0])
}
if texts[1] != "第一行" {
t.Errorf("expected '第一行', got %q", texts[1])
}
if len(images) != 0 {
t.Errorf("expected 0 images, got %d", len(images))
}
}
func TestExtractPostParts_WithLink(t *testing.T) {
p := &Platform{}
post := &postLang{
Content: [][]postElement{
{
{Tag: "text", Text: "点击 "},
{Tag: "a", Text: "这里", Href: "https://example.com"},
},
},
}
texts, _ := p.extractPostParts("", post)
if len(texts) != 2 {
t.Fatalf("expected 2 text parts, got %d", len(texts))
}
if texts[0] != "点击 " || texts[1] != "这里" {
t.Errorf("unexpected texts: %v", texts)
}
}
func TestExtractPostParts_EmptyContent(t *testing.T) {
p := &Platform{}
post := &postLang{}
texts, images := p.extractPostParts("", post)
if len(texts) != 0 || len(images) != 0 {
t.Errorf("expected empty results, got texts=%d images=%d", len(texts), len(images))
}
}
func TestExtractPostParts_NoTitle(t *testing.T) {
p := &Platform{}
post := &postLang{
Content: [][]postElement{
{
{Tag: "text", Text: "只有正文"},
},
},
}
texts, _ := p.extractPostParts("", post)
if len(texts) != 1 || texts[0] != "只有正文" {
t.Errorf("unexpected texts: %v", texts)
}
}
func TestParsePostContent_FlatFormat(t *testing.T) {
p := &Platform{}
raw := `{"title":"test","content":[[{"tag":"text","text":"hello"}]]}`
texts, _ := p.parsePostContent("", raw)
if len(texts) != 2 || texts[0] != "test" || texts[1] != "hello" {
t.Errorf("unexpected result: %v", texts)
}
}
func TestParsePostContent_LangKeyedFormat(t *testing.T) {
p := &Platform{}
raw := `{"zh_cn":{"title":"标题","content":[[{"tag":"text","text":"内容"}]]}}`
texts, _ := p.parsePostContent("", raw)
if len(texts) != 2 || texts[0] != "标题" || texts[1] != "内容" {
t.Errorf("unexpected result: %v", texts)
}
}
func TestParsePostContent_InvalidJSON(t *testing.T) {
p := &Platform{}
texts, images := p.parsePostContent("", "not json")
if texts != nil || images != nil {
t.Errorf("expected nil results for invalid json")
}
}