Merge pull request #16 from kevinWangSheng/fix/feishu-post-and-slash-passthrough
fix: 飞书 post 富文本支持 & slash 命令透传
This commit is contained in:
3 files changed
+189
-6
No files matched your search
+9
-6
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user