初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+464
View File
@@ -0,0 +1,464 @@
package feishu
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"github.com/chenhg5/cc-connect/core"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
func plainText(content string) map[string]any {
return map[string]any{"tag": "plain_text", "content": content}
}
// ReplyCard sends a structured card as a reply to the original message.
func (p *interactivePlatform) ReplyCard(ctx context.Context, rctx any, card *core.Card) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("%s: invalid reply context type %T", p.tag(), rctx)
}
cardJSON := renderCard(card, rc.sessionKey)
if !p.shouldUseThreadOrReplyAPI(rc) {
if rc.chatID == "" {
return fmt.Errorf("%s: chatID is empty, cannot send card", p.tag())
}
return p.createMessage(ctx, rc.chatID, larkim.MsgTypeInteractive, cardJSON, "send card")
}
return p.replyMessage(ctx, rc, larkim.MsgTypeInteractive, cardJSON)
}
// SendCard sends a structured card as a new message to the chat.
func (p *interactivePlatform) SendCard(ctx context.Context, rctx any, card *core.Card) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("%s: invalid reply context type %T", p.tag(), rctx)
}
if rc.chatID == "" {
return fmt.Errorf("%s: chatID is empty, cannot send card", p.tag())
}
if !p.noReplyToTrigger && p.shouldReplyInThread(rc) {
return p.ReplyCard(ctx, rctx, card)
}
cardJSON := renderCard(card, rc.sessionKey)
return p.createMessage(ctx, rc.chatID, larkim.MsgTypeInteractive, cardJSON, "send card")
}
// RefreshCard updates a previously rendered card in-place using the Patch API.
// It looks up the messageID stored from the most recent card action callback
// for the given session key and patches that message with the new card content.
func (p *interactivePlatform) RefreshCard(ctx context.Context, sessionKey string, card *core.Card) error {
p.cardActionMsgMu.Lock()
msgID := p.cardActionMsgIDs[sessionKey]
p.cardActionMsgMu.Unlock()
if msgID == "" {
return fmt.Errorf("%s: no tracked card messageID for session %q", p.tag(), sessionKey)
}
cardJSON := renderCard(card, sessionKey)
req := larkim.NewPatchMessageReqBuilder().
MessageId(msgID).
Body(larkim.NewPatchMessageReqBodyBuilder().
Content(cardJSON).
Build()).
Build()
return p.withTransientRetry(ctx, "refresh card", func() error {
return p.withFreshTenantAccessTokenRetry(ctx, "refresh card", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error {
resp, err := client.Im.Message.Patch(ctx, req, options...)
if err != nil {
return fmt.Errorf("%s: refresh card: %w", p.tag(), err)
}
if !resp.Success() {
return fmt.Errorf("%s: refresh card code=%d msg=%s", p.tag(), resp.Code, resp.Msg)
}
return nil
})
})
}
// renderCardMap converts a core.Card into the Feishu Interactive Card map
// using the v1 format. Used both for message API (via renderCard) and
// callback responses (CardActionTriggerResponse).
func renderCardMap(card *core.Card, sessionKey string) map[string]any {
result := map[string]any{
"config": map[string]any{
"wide_screen_mode": true,
},
}
if card == nil {
return result
}
if card.Header != nil && card.Header.Title != "" {
color := card.Header.Color
if color == "" {
color = "blue"
}
result["header"] = map[string]any{
"title": plainText(card.Header.Title),
"template": color,
}
}
if transformed, ok := renderDeleteModeCheckerCard(card, result); ok {
return transformed
}
var elements []map[string]any
for _, elem := range card.Elements {
switch e := elem.(type) {
case core.CardMarkdown:
elements = append(elements, map[string]any{
"tag": "markdown",
"content": e.Content,
})
case core.CardDivider:
elements = append(elements, map[string]any{
"tag": "hr",
})
case core.CardActions:
var actions []map[string]any
for _, btn := range e.Buttons {
btnType := btn.Type
if btnType == "" {
btnType = "default"
}
valMap := map[string]string{"action": btn.Value}
if sessionKey != "" {
valMap["session_key"] = sessionKey
}
for k, v := range btn.Extra {
valMap[k] = v
}
action := map[string]any{
"tag": "button",
"text": plainText(btn.Text),
"type": btnType,
"value": valMap,
}
if e.Layout == core.CardActionLayoutEqualColumns {
action["width"] = "fill"
}
actions = append(actions, action)
}
if len(actions) > 0 {
if e.Layout == core.CardActionLayoutEqualColumns {
columns := make([]map[string]any, 0, len(actions))
for _, action := range actions {
columns = append(columns, map[string]any{
"tag": "column",
"width": "weighted",
"weight": 1,
"vertical_align": "center",
"horizontal_align": "center",
"elements": []map[string]any{action},
})
}
columnSet := map[string]any{
"tag": "column_set",
"columns": columns,
}
if len(actions) == 2 {
columnSet["flex_mode"] = "bisect"
}
elements = append(elements, columnSet)
} else {
elements = append(elements, map[string]any{
"tag": "action",
"actions": actions,
})
}
}
case core.CardListItem:
btnType := e.BtnType
if btnType == "" {
btnType = "default"
}
valMap := map[string]string{"action": e.BtnValue}
if sessionKey != "" {
valMap["session_key"] = sessionKey
}
for k, v := range e.Extra {
valMap[k] = v
}
elements = append(elements, map[string]any{
"tag": "column_set",
"flex_mode": "none",
"columns": []map[string]any{
{
"tag": "column",
"width": "weighted",
"weight": 5,
"vertical_align": "center",
"elements": []map[string]any{
{
"tag": "markdown",
"content": e.Text,
},
},
},
{
"tag": "column",
"width": "auto",
"vertical_align": "center",
"elements": []map[string]any{
{
"tag": "button",
"text": plainText(e.BtnText),
"type": btnType,
"value": valMap,
},
},
},
},
})
case core.CardSelect:
var options []map[string]any
for _, opt := range e.Options {
options = append(options, map[string]any{
"text": plainText(opt.Text),
"value": opt.Value,
})
}
selectElem := map[string]any{
"tag": "select_static",
"placeholder": plainText(e.Placeholder),
"options": options,
}
if sessionKey != "" {
selectElem["value"] = map[string]string{"session_key": sessionKey}
}
if e.InitValue != "" {
selectElem["initial_option"] = e.InitValue
}
elements = append(elements, map[string]any{
"tag": "action",
"actions": []map[string]any{selectElem},
})
case core.CardNote:
elements = append(elements, map[string]any{
"tag": "note",
"elements": []map[string]any{plainText(e.Text)},
})
}
}
if len(elements) == 0 {
elements = []map[string]any{{"tag": "markdown", "content": " "}}
}
result["elements"] = elements
return result
}
type deleteModeCheckerRow struct {
id string
text string
checked bool
}
func renderDeleteModeCheckerCard(card *core.Card, base map[string]any) (map[string]any, bool) {
if card == nil {
return nil, false
}
formRowElements := make([]map[string]any, 0)
notes := make([]core.CardNote, 0)
navRows := make([]core.CardActions, 0)
submitText := ""
cancelText := ""
for _, elem := range card.Elements {
switch e := elem.(type) {
case core.CardListItem:
id, selectable, ok := parseDeleteModeListItemAction(e.BtnValue)
if !ok {
return nil, false
}
text := normalizeDeleteModeCheckerText(e.Text)
if !selectable {
formRowElements = append(formRowElements, map[string]any{
"tag": "markdown",
"content": "▶ " + text,
})
continue
}
row := deleteModeCheckerRow{
id: id,
text: text,
checked: strings.Contains(e.Text, "☑"),
}
formRowElements = append(formRowElements, map[string]any{
"tag": "checker",
"name": deleteModeCheckerName(row.id),
"checked": row.checked,
"text": map[string]any{
"tag": "lark_md",
"content": row.text,
},
})
case core.CardNote:
notes = append(notes, e)
case core.CardActions:
remaining := make([]core.CardButton, 0, len(e.Buttons))
for _, btn := range e.Buttons {
switch btn.Value {
case "act:/delete-mode confirm":
submitText = btn.Text
case "act:/delete-mode cancel":
cancelText = btn.Text
default:
remaining = append(remaining, btn)
}
}
if len(remaining) > 0 {
navRows = append(navRows, core.CardActions{Buttons: remaining, Layout: e.Layout})
}
case core.CardMarkdown, core.CardDivider, core.CardSelect:
return nil, false
}
}
if len(formRowElements) == 0 || submitText == "" {
return nil, false
}
elements := make([]map[string]any, 0, len(notes)+1+len(navRows))
for _, n := range notes {
if n.Text == "" {
continue
}
if n.Tag == "delete-mode-selected-count" {
continue
}
elements = append(elements, map[string]any{
"tag": "note",
"elements": []map[string]any{plainText(n.Text)},
})
}
formElements := append([]map[string]any{}, formRowElements...)
buttonColumns := []map[string]any{
{
"tag": "column",
"width": "auto",
"vertical_align": "center",
"elements": []map[string]any{
{
"tag": "button",
"text": plainText(submitText),
"type": "danger",
"name": "delete_mode_submit",
"form_action_type": "submit",
"value": map[string]string{"action": "act:/delete-mode form-submit"},
},
},
},
}
if cancelText != "" {
buttonColumns = append(buttonColumns, map[string]any{
"tag": "column",
"width": "auto",
"vertical_align": "center",
"elements": []map[string]any{
{
"tag": "button",
"text": plainText(cancelText),
"type": "default",
"name": "delete_mode_cancel",
"value": map[string]string{"action": "act:/delete-mode cancel"},
},
},
})
}
formElements = append(formElements, map[string]any{
"tag": "column_set",
"horizontal_align": "left",
"columns": buttonColumns,
})
elements = append(elements, map[string]any{
"tag": "form",
"name": "delete_mode_form",
"elements": formElements,
})
for _, row := range navRows {
actions := make([]map[string]any, 0, len(row.Buttons))
for _, btn := range row.Buttons {
btnType := btn.Type
if btnType == "" {
btnType = "default"
}
valMap := map[string]string{"action": btn.Value}
for k, v := range btn.Extra {
valMap[k] = v
}
action := map[string]any{
"tag": "button",
"text": plainText(btn.Text),
"type": btnType,
"value": valMap,
}
if row.Layout == core.CardActionLayoutEqualColumns {
action["width"] = "fill"
}
actions = append(actions, action)
}
if len(actions) > 0 {
elements = append(elements, map[string]any{
"tag": "action",
"actions": actions,
})
}
}
base["elements"] = elements
return base, true
}
func normalizeDeleteModeCheckerText(text string) string {
trimmed := strings.TrimSpace(text)
for _, prefix := range []string{"☑ ▶", "◻ ▶", "▶", "☑", "◻"} {
if strings.HasPrefix(trimmed, prefix) {
trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, prefix))
break
}
}
return trimmed
}
func parseDeleteModeListItemAction(action string) (id string, selectable bool, ok bool) {
const (
togglePrefix = "act:/delete-mode toggle "
noopPrefix = "act:/delete-mode noop "
)
switch {
case strings.HasPrefix(action, togglePrefix):
id = strings.TrimSpace(strings.TrimPrefix(action, togglePrefix))
return id, true, id != ""
case strings.HasPrefix(action, noopPrefix):
id = strings.TrimSpace(strings.TrimPrefix(action, noopPrefix))
return id, false, id != ""
default:
return "", false, false
}
}
// renderCard converts a core.Card into the Feishu Interactive Card JSON string.
func renderCard(card *core.Card, sessionKey string) string {
b, err := json.Marshal(renderCardMap(card, sessionKey))
if err != nil {
slog.Error("feishu: renderCard marshal failed", "error", err)
return `{"config":{"wide_screen_mode":true},"elements":[]}`
}
return string(b)
}
+267
View File
@@ -0,0 +1,267 @@
package feishu
import (
"encoding/json"
"strings"
"testing"
"github.com/chenhg5/cc-connect/core"
)
func decodeRenderedCard(t *testing.T, card *core.Card) map[string]any {
t.Helper()
var got map[string]any
if err := json.Unmarshal([]byte(renderCard(card, "")), &got); err != nil {
t.Fatalf("renderCard JSON decode failed: %v", err)
}
return got
}
func TestRenderCardMap_EqualColumnsActionsUseColumnSet(t *testing.T) {
buttons := []core.CardButton{
core.PrimaryBtn("Session Management", "nav:/help session"),
core.DefaultBtn("Agent Configuration", "nav:/help agent"),
core.DefaultBtn("Tools & Automation", "nav:/help tools"),
core.DefaultBtn("System", "nav:/help system"),
}
card := core.NewCard().ButtonsEqual(buttons...).Build()
got := decodeRenderedCard(t, card)
elements, ok := got["elements"].([]any)
if !ok || len(elements) != 1 {
t.Fatalf("elements = %#v, want one element", got["elements"])
}
columnSet, ok := elements[0].(map[string]any)
if !ok {
t.Fatalf("first element = %#v, want object", elements[0])
}
if tag := columnSet["tag"]; tag != "column_set" {
t.Fatalf("tag = %#v, want column_set", tag)
}
columns, ok := columnSet["columns"].([]any)
if !ok || len(columns) != len(buttons) {
t.Fatalf("columns = %#v, want %d columns", columnSet["columns"], len(buttons))
}
for i, want := range buttons {
col, ok := columns[i].(map[string]any)
if !ok {
t.Fatalf("column %d = %#v, want object", i, columns[i])
}
if width := col["width"]; width != "weighted" {
t.Fatalf("column %d width = %#v, want weighted", i, width)
}
if weight := col["weight"]; weight != float64(1) {
t.Fatalf("column %d weight = %#v, want 1", i, weight)
}
innerElems, ok := col["elements"].([]any)
if !ok || len(innerElems) != 1 {
t.Fatalf("column %d elements = %#v, want one button", i, col["elements"])
}
btn, ok := innerElems[0].(map[string]any)
if !ok {
t.Fatalf("column %d button = %#v, want object", i, innerElems[0])
}
if tag := btn["tag"]; tag != "button" {
t.Fatalf("column %d tag = %#v, want button", i, tag)
}
text, ok := btn["text"].(map[string]any)
if !ok || text["content"] != want.Text {
t.Fatalf("column %d text = %#v, want %q", i, btn["text"], want.Text)
}
if btnType := btn["type"]; btnType != want.Type {
t.Fatalf("column %d type = %#v, want %q", i, btnType, want.Type)
}
value, ok := btn["value"].(map[string]any)
if !ok || value["action"] != want.Value {
t.Fatalf("column %d value = %#v, want %q", i, btn["value"], want.Value)
}
}
}
func TestRenderCardMap_TwoEqualColumnsUseBisectAndCenteredButtons(t *testing.T) {
buttons := []core.CardButton{
core.PrimaryBtn("Session Management", "nav:/help session"),
core.DefaultBtn("Agent Configuration", "nav:/help agent"),
}
card := core.NewCard().ButtonsEqual(buttons...).Build()
got := decodeRenderedCard(t, card)
elements, ok := got["elements"].([]any)
if !ok || len(elements) != 1 {
t.Fatalf("elements = %#v, want one element", got["elements"])
}
columnSet, ok := elements[0].(map[string]any)
if !ok {
t.Fatalf("first element = %#v, want object", elements[0])
}
if flexMode := columnSet["flex_mode"]; flexMode != "bisect" {
t.Fatalf("flex_mode = %#v, want bisect", flexMode)
}
columns, ok := columnSet["columns"].([]any)
if !ok || len(columns) != len(buttons) {
t.Fatalf("columns = %#v, want %d columns", columnSet["columns"], len(buttons))
}
for i := range buttons {
col, ok := columns[i].(map[string]any)
if !ok {
t.Fatalf("column %d = %#v, want object", i, columns[i])
}
if align := col["horizontal_align"]; align != "center" {
t.Fatalf("column %d horizontal_align = %#v, want center", i, align)
}
innerElems, ok := col["elements"].([]any)
if !ok || len(innerElems) != 1 {
t.Fatalf("column %d elements = %#v, want one button", i, col["elements"])
}
btn, ok := innerElems[0].(map[string]any)
if !ok {
t.Fatalf("column %d button = %#v, want object", i, innerElems[0])
}
if width := btn["width"]; width != "fill" {
t.Fatalf("column %d button width = %#v, want fill", i, width)
}
}
}
func TestRenderCardMap_DefaultActionsStayActionRow(t *testing.T) {
buttons := []core.CardButton{
core.PrimaryBtn("Yes", "act:/yes"),
core.DefaultBtn("No", "act:/no"),
}
card := core.NewCard().Buttons(buttons...).Build()
got := decodeRenderedCard(t, card)
elements, ok := got["elements"].([]any)
if !ok || len(elements) != 1 {
t.Fatalf("elements = %#v, want one element", got["elements"])
}
actionRow, ok := elements[0].(map[string]any)
if !ok {
t.Fatalf("first element = %#v, want object", elements[0])
}
if tag := actionRow["tag"]; tag != "action" {
t.Fatalf("tag = %#v, want action", tag)
}
actions, ok := actionRow["actions"].([]any)
if !ok || len(actions) != len(buttons) {
t.Fatalf("actions = %#v, want %d buttons", actionRow["actions"], len(buttons))
}
for i, want := range buttons {
btn, ok := actions[i].(map[string]any)
if !ok {
t.Fatalf("button %d = %#v, want object", i, actions[i])
}
if tag := btn["tag"]; tag != "button" {
t.Fatalf("button %d tag = %#v, want button", i, tag)
}
text, ok := btn["text"].(map[string]any)
if !ok || text["content"] != want.Text {
t.Fatalf("button %d text = %#v, want %q", i, btn["text"], want.Text)
}
if btnType := btn["type"]; btnType != want.Type {
t.Fatalf("button %d type = %#v, want %q", i, btnType, want.Type)
}
value, ok := btn["value"].(map[string]any)
if !ok || value["action"] != want.Value {
t.Fatalf("button %d value = %#v, want %q", i, btn["value"], want.Value)
}
}
}
func TestRenderCardMap_DeleteModeUsesCheckerForm(t *testing.T) {
card := core.NewCard().
Title("删除会话", "carmine").
ListItemBtn("☑ **1.** One · **10** msgs · 03-13 20:00", "已选择", "primary", "act:/delete-mode toggle session-1").
ListItemBtn("▶ **2.** Active · **30** msgs · 03-13 20:01", "当前会话", "primary", "act:/delete-mode noop session-2").
ListItemBtn("◻ **3.** Three · **20** msgs · 03-13 20:02", "选择", "default", "act:/delete-mode toggle session-3").
Note("2 selected").
Buttons(
core.DangerBtn("删除已选", "act:/delete-mode confirm"),
core.DefaultBtn("取消", "act:/delete-mode cancel"),
).
Buttons(core.DefaultBtn("下一页 →", "act:/delete-mode page 2")).
Build()
got := decodeRenderedCard(t, card)
raw, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal rendered card failed: %v", err)
}
s := string(raw)
if !strings.Contains(s, `"tag":"form"`) || !strings.Contains(s, `"tag":"checker"`) {
t.Fatalf("expected form+checker rendering, got %s", s)
}
if got := strings.Count(s, `"tag":"checker"`); got != 2 {
t.Fatalf("checker count = %d, want 2, got %s", got, s)
}
if !strings.Contains(s, deleteModeCheckerName("session-1")) {
t.Fatalf("selectable session checker missing, got %s", s)
}
if strings.Contains(s, deleteModeCheckerName("session-2")) {
t.Fatalf("active session should not render checker, got %s", s)
}
if !strings.Contains(s, deleteModeCheckerName("session-3")) {
t.Fatalf("second selectable session checker missing, got %s", s)
}
activeIdx := strings.Index(s, `▶ **2.** Active`)
firstIdx := strings.Index(s, deleteModeCheckerName("session-1"))
thirdIdx := strings.Index(s, deleteModeCheckerName("session-3"))
if activeIdx < 0 || firstIdx < 0 || thirdIdx < 0 {
t.Fatalf("missing expected order markers in rendered card: %s", s)
}
if !(firstIdx < activeIdx && activeIdx < thirdIdx) {
t.Fatalf("row order changed unexpectedly, got %s", s)
}
if !strings.Contains(s, `"name":"delete_mode_form"`) {
t.Fatalf("expected form name for feishu validation, got %s", s)
}
if !strings.Contains(s, `"name":"delete_mode_submit"`) || !strings.Contains(s, `"name":"delete_mode_cancel"`) {
t.Fatalf("expected button names inside form, got %s", s)
}
if !strings.Contains(s, `"form_action_type":"submit"`) || !strings.Contains(s, `act:/delete-mode form-submit`) {
t.Fatalf("expected form submit action, got %s", s)
}
if strings.Contains(s, `act:/delete-mode toggle`) {
t.Fatalf("expected no toggle buttons in rendered card, got %s", s)
}
}
func TestRenderCardMap_InjectsSessionKeyIntoCallbacks(t *testing.T) {
card := core.NewCard().
Buttons(core.PrimaryBtn("Open", "nav:/help session")).
ListItem("Choose", "Confirm", "act:/confirm").
Select("Pick one", []core.CardSelectOption{{Text: "A", Value: "askq:0:1"}}, "").
Build()
got := renderCardMap(card, "feishu:oc_chat:root:om_root")
elements, ok := got["elements"].([]map[string]any)
if !ok || len(elements) != 3 {
t.Fatalf("elements = %#v, want 3 elements", got["elements"])
}
actionRow := elements[0]
actions := actionRow["actions"].([]map[string]any)
firstButton := actions[0]
value := firstButton["value"].(map[string]string)
if value["session_key"] != "feishu:oc_chat:root:om_root" {
t.Fatalf("button session_key = %#v, want thread session key", value["session_key"])
}
listRow := elements[1]
columns := listRow["columns"].([]map[string]any)
actionCol := columns[1]
listBtn := actionCol["elements"].([]map[string]any)[0]
listValue := listBtn["value"].(map[string]string)
if listValue["session_key"] != "feishu:oc_chat:root:om_root" {
t.Fatalf("list item session_key = %#v, want thread session key", listValue["session_key"])
}
selectRow := elements[2]
selectActions := selectRow["actions"].([]map[string]any)
selectValue := selectActions[0]["value"].(map[string]string)
if selectValue["session_key"] != "feishu:oc_chat:root:om_root" {
t.Fatalf("select session_key = %#v, want thread session key", selectValue["session_key"])
}
}
+67
View File
@@ -0,0 +1,67 @@
package feishu
import (
"encoding/hex"
"sort"
"strings"
)
const deleteModeCheckerNamePrefix = "delete_sel_"
func deleteModeCheckerName(sessionID string) string {
return deleteModeCheckerNamePrefix + hex.EncodeToString([]byte(sessionID))
}
func parseDeleteModeCheckerName(name string) (string, bool) {
if !strings.HasPrefix(name, deleteModeCheckerNamePrefix) {
return "", false
}
raw := strings.TrimPrefix(name, deleteModeCheckerNamePrefix)
if raw == "" {
return "", false
}
b, err := hex.DecodeString(raw)
if err != nil {
return "", false
}
return string(b), true
}
func collectDeleteModeSelectedFromFormValue(formValue map[string]any) []string {
if len(formValue) == 0 {
return nil
}
ids := make([]string, 0, len(formValue))
seen := make(map[string]struct{}, len(formValue))
for key, val := range formValue {
sessionID, ok := parseDeleteModeCheckerName(key)
if !ok || !isTruthyFormValue(val) {
continue
}
if _, exists := seen[sessionID]; exists {
continue
}
seen[sessionID] = struct{}{}
ids = append(ids, sessionID)
}
sort.Strings(ids)
return ids
}
func isTruthyFormValue(v any) bool {
switch x := v.(type) {
case bool:
return x
case string:
s := strings.ToLower(strings.TrimSpace(x))
return s == "true" || s == "1" || s == "yes" || s == "on"
case float64:
return x != 0
case int:
return x != 0
case int64:
return x != 0
default:
return false
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
package feishu
import (
"context"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
type recordingLarkLogger struct {
debugCalls int
debugArgs [][]interface{}
}
func (l *recordingLarkLogger) Debug(_ context.Context, args ...interface{}) {
l.debugCalls++
cp := make([]interface{}, len(args))
copy(cp, args)
l.debugArgs = append(l.debugArgs, cp)
}
func (l *recordingLarkLogger) Info(context.Context, ...interface{}) {}
func (l *recordingLarkLogger) Warn(context.Context, ...interface{}) {}
func (l *recordingLarkLogger) Error(context.Context, ...interface{}) {}
var _ larkcore.Logger = (*recordingLarkLogger)(nil)
func TestSanitizingLogger_DropsHeartbeatDebugLogs(t *testing.T) {
inner := &recordingLarkLogger{}
logger := &sanitizingLogger{inner: inner}
logger.Debug(context.Background(), "ping success", "conn_id=123")
logger.Debug(context.Background(), "receive pong", "conn_id=123")
if inner.debugCalls != 0 {
t.Fatalf("debug calls = %d, want 0 for heartbeat noise", inner.debugCalls)
}
}
func TestSanitizingLogger_KeepOtherDebugAndMaskSecrets(t *testing.T) {
inner := &recordingLarkLogger{}
logger := &sanitizingLogger{inner: inner}
logger.Debug(context.Background(), "websocket message", "https://x.test/ws?token=abc&conn_id=123")
if inner.debugCalls != 1 {
t.Fatalf("debug calls = %d, want 1", inner.debugCalls)
}
if len(inner.debugArgs) != 1 || len(inner.debugArgs[0]) < 2 {
t.Fatalf("debug args = %#v, want one call with args", inner.debugArgs)
}
urlArg, ok := inner.debugArgs[0][1].(string)
if !ok {
t.Fatalf("second arg type = %T, want string", inner.debugArgs[0][1])
}
if strings.Contains(urlArg, "token=abc") || strings.Contains(urlArg, "conn_id=123") {
t.Fatalf("url arg not masked: %q", urlArg)
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
package feishu
import "github.com/chenhg5/cc-connect/core"
var _ core.PreviewCleaner = (*Platform)(nil)
var _ core.PreviewFinishPreference = (*Platform)(nil)
+265
View File
@@ -0,0 +1,265 @@
package feishu
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
)
func TestReplyRefreshesTenantTokenAfterInvalidCachedToken(t *testing.T) {
const appID = "cli_reply_retry"
const appSecret = "secret-reply-retry"
authCalls := 0
replyCalls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
authCalls++
token := "stale-token"
if authCalls >= 2 {
token = "fresh-token"
}
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"expire": 7200,
"tenant_access_token": token,
})
case strings.HasSuffix(r.URL.Path, "/reply"):
replyCalls++
authz := r.Header.Get("Authorization")
switch replyCalls {
case 1, 2:
if authz != "Bearer stale-token" {
t.Fatalf("reply call %d Authorization = %q, want stale token", replyCalls, authz)
}
writeJSON(t, w, map[string]any{
"code": 99991663,
"msg": "Invalid access token for authorization",
})
case 3:
if authz != "Bearer fresh-token" {
t.Fatalf("reply retry Authorization = %q, want fresh token", authz)
}
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"data": map[string]any{"message_id": "om_reply_ok"},
})
default:
t.Fatalf("unexpected extra reply call %d", replyCalls)
}
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p := &Platform{
platformName: "feishu",
domain: srv.URL,
appID: appID,
appSecret: appSecret,
client: lark.NewClient(appID, appSecret,
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
replayClient: lark.NewClient(appID, appSecret,
lark.WithEnableTokenCache(false),
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
}
err := p.Reply(context.Background(), replyContext{messageID: "om_root", chatID: "oc_chat"}, "hello")
if err != nil {
t.Fatalf("Reply() error = %v", err)
}
if authCalls != 2 {
t.Fatalf("authCalls = %d, want 2", authCalls)
}
if replyCalls != 3 {
t.Fatalf("replyCalls = %d, want 3", replyCalls)
}
}
func TestSendNewMessageToChatRefreshesTenantTokenAfterInvalidCachedToken(t *testing.T) {
const appID = "cli_create_retry"
const appSecret = "secret-create-retry"
authCalls := 0
createCalls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
authCalls++
token := "stale-token"
if authCalls >= 2 {
token = "fresh-token"
}
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"expire": 7200,
"tenant_access_token": token,
})
case r.URL.Path == "/open-apis/im/v1/messages":
createCalls++
authz := r.Header.Get("Authorization")
switch createCalls {
case 1, 2:
if authz != "Bearer stale-token" {
t.Fatalf("create call %d Authorization = %q, want stale token", createCalls, authz)
}
writeJSON(t, w, map[string]any{
"code": 99991663,
"msg": "Invalid access token for authorization",
})
case 3:
if authz != "Bearer fresh-token" {
t.Fatalf("create retry Authorization = %q, want fresh token", authz)
}
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"data": map[string]any{"message_id": "om_create_ok"},
})
default:
t.Fatalf("unexpected extra create call %d", createCalls)
}
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p := &Platform{
platformName: "feishu",
domain: srv.URL,
appID: appID,
appSecret: appSecret,
client: lark.NewClient(appID, appSecret,
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
replayClient: lark.NewClient(appID, appSecret,
lark.WithEnableTokenCache(false),
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
}
err := p.sendNewMessageToChat(context.Background(), replyContext{chatID: "oc_chat"}, "text", `{"text":"hello"}`)
if err != nil {
t.Fatalf("sendNewMessageToChat() error = %v", err)
}
if authCalls != 2 {
t.Fatalf("authCalls = %d, want 2", authCalls)
}
if createCalls != 3 {
t.Fatalf("createCalls = %d, want 3", createCalls)
}
}
func TestReplyDoesNotRefreshTenantTokenOnNonTokenError(t *testing.T) {
const appID = "cli_non_token_error"
const appSecret = "secret-non-token-error"
authCalls := 0
replyCalls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
authCalls++
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"expire": 7200,
"tenant_access_token": "normal-token",
})
case strings.HasSuffix(r.URL.Path, "/reply"):
replyCalls++
writeJSON(t, w, map[string]any{
"code": 230001,
"msg": "rate limited",
})
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p := &Platform{
platformName: "feishu",
domain: srv.URL,
appID: appID,
appSecret: appSecret,
client: lark.NewClient(appID, appSecret,
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
replayClient: lark.NewClient(appID, appSecret,
lark.WithEnableTokenCache(false),
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
}
err := p.Reply(context.Background(), replyContext{messageID: "om_root", chatID: "oc_chat"}, "hello")
if err == nil {
t.Fatal("Reply() error = nil, want failure")
}
if !strings.Contains(err.Error(), "rate limited") {
t.Fatalf("Reply() error = %v, want rate limited", err)
}
if authCalls > 1 {
t.Fatalf("authCalls = %d, want <= 1", authCalls)
}
if replyCalls != 1 {
t.Fatalf("replyCalls = %d, want 1", replyCalls)
}
}
func TestIsTenantAccessTokenInvalid(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "nil", err: nil, want: false},
{name: "code only", err: testError("feishu: reply failed code=99991663 msg=something"), want: true},
{name: "message only", err: testError("feishu: reply api call: Invalid access token for authorization"), want: true},
{name: "other error", err: testError("feishu: reply failed code=230001 msg=rate limited"), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isTenantAccessTokenInvalid(tt.err); got != tt.want {
t.Fatalf("isTenantAccessTokenInvalid() = %v, want %v", got, tt.want)
}
})
}
}
type testError string
func (e testError) Error() string { return string(e) }
func writeJSON(t *testing.T, w http.ResponseWriter, body map[string]any) {
t.Helper()
if err := json.NewEncoder(w).Encode(body); err != nil {
t.Fatalf("encode json: %v", err)
}
}
+523
View File
@@ -0,0 +1,523 @@
package feishu
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
)
// ─── isTransientError unit tests ───────────────────────────────────────────
func TestIsTransientError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "nil", err: nil, want: false},
{name: "connection reset by peer", err: fmt.Errorf("write tcp: connection reset by peer"), want: true},
{name: "broken pipe", err: fmt.Errorf("write: broken pipe"), want: true},
{name: "i/o timeout", err: fmt.Errorf("dial tcp: i/o timeout"), want: true},
{name: "TLS handshake timeout", err: fmt.Errorf("net/http: TLS handshake timeout"), want: true},
{name: "connection refused", err: fmt.Errorf("dial tcp 127.0.0.1:443: connection refused"), want: true},
{name: "no such host (permanent)", err: fmt.Errorf("dial tcp: lookup example.invalid: no such host"), want: false},
{name: "server misbehaving", err: fmt.Errorf("lookup example.com: server misbehaving"), want: true},
{name: "EOF", err: io.EOF, want: true},
{name: "unexpected EOF", err: io.ErrUnexpectedEOF, want: true},
{name: "wrapped EOF", err: fmt.Errorf("feishu: reply api call: %w", io.EOF), want: true},
{name: "syscall ECONNRESET", err: fmt.Errorf("write tcp: %w", syscall.ECONNRESET), want: true},
{name: "syscall EPIPE", err: fmt.Errorf("write tcp: %w", syscall.EPIPE), want: true},
{name: "rate limited", err: fmt.Errorf("feishu: reply failed code=230001 msg=rate limited"), want: false},
{name: "invalid token", err: fmt.Errorf("feishu: reply failed code=99991663"), want: false},
{name: "generic error", err: fmt.Errorf("something went wrong"), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isTransientError(tt.err); got != tt.want {
t.Fatalf("isTransientError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestIsTransientError_NetTimeout(t *testing.T) {
err := &net.OpError{Op: "dial", Err: &timeoutError{}}
if !isTransientError(err) {
t.Fatal("expected net timeout to be transient")
}
}
type timeoutError struct{}
func (e *timeoutError) Error() string { return "i/o timeout" }
func (e *timeoutError) Timeout() bool { return true }
func (e *timeoutError) Temporary() bool { return true }
// ─── withTransientRetry unit tests ─────────────────────────────────────────
func TestWithTransientRetry_SucceedsFirstAttempt(t *testing.T) {
p := &Platform{platformName: "feishu"}
calls := 0
err := p.withTransientRetry(context.Background(), "test", func() error {
calls++
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls != 1 {
t.Fatalf("calls = %d, want 1", calls)
}
}
func TestWithTransientRetry_RetriesOnTransientThenSucceeds(t *testing.T) {
p := &Platform{platformName: "feishu"}
calls := 0
err := p.withTransientRetry(context.Background(), "test", func() error {
calls++
if calls <= 2 {
return fmt.Errorf("write tcp: connection reset by peer")
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
}
func TestWithTransientRetry_DoesNotRetryNonTransient(t *testing.T) {
p := &Platform{platformName: "feishu"}
calls := 0
err := p.withTransientRetry(context.Background(), "test", func() error {
calls++
return fmt.Errorf("feishu: reply failed code=230001 msg=rate limited")
})
if err == nil {
t.Fatal("expected error")
}
if calls != 1 {
t.Fatalf("calls = %d, want 1 (no retry for non-transient errors)", calls)
}
}
func TestWithTransientRetry_GivesUpAfterMaxRetries(t *testing.T) {
p := &Platform{platformName: "feishu"}
calls := 0
err := p.withTransientRetry(context.Background(), "test", func() error {
calls++
return io.EOF
})
if err == nil {
t.Fatal("expected error after max retries")
}
// 1 initial + 3 retries = 4 total calls
if calls != maxTransientRetries+1 {
t.Fatalf("calls = %d, want %d", calls, maxTransientRetries+1)
}
if !strings.Contains(err.Error(), "failed after") {
t.Fatalf("error = %q, want 'failed after' message", err.Error())
}
if !errors.Is(err, io.EOF) {
t.Fatalf("error should wrap io.EOF, got %v", err)
}
}
func TestWithTransientRetry_RespectsContextCancellation(t *testing.T) {
p := &Platform{platformName: "feishu"}
ctx, cancel := context.WithCancel(context.Background())
calls := 0
// Cancel after first call to trigger cancellation during backoff wait
err := p.withTransientRetry(ctx, "test", func() error {
calls++
if calls == 1 {
cancel()
}
return fmt.Errorf("connection reset by peer")
})
if err == nil {
t.Fatal("expected error on context cancellation")
}
if !strings.Contains(err.Error(), "retry cancelled") {
t.Fatalf("error = %q, want retry cancelled message", err.Error())
}
}
// ─── Integration tests: transient retry with Feishu API ────────────────────
func TestReplyRetriesOnTransientNetworkError(t *testing.T) {
const appID = "cli_transient_retry"
const appSecret = "secret"
var replyCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"expire": 7200,
"tenant_access_token": "valid-token",
})
case strings.HasSuffix(r.URL.Path, "/reply"):
call := replyCalls.Add(1)
if call <= 2 {
// Simulate transient error by closing connection abruptly
hj, ok := w.(http.Hijacker)
if !ok {
t.Fatalf("response writer does not support hijacking")
}
conn, _, err := hj.Hijack()
if err != nil {
t.Fatalf("hijack failed: %v", err)
}
conn.Close()
return
}
// 3rd call succeeds
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"data": map[string]any{"message_id": "om_reply_ok"},
})
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p := &Platform{
platformName: "feishu",
domain: srv.URL,
appID: appID,
appSecret: appSecret,
client: lark.NewClient(appID, appSecret,
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
replayClient: lark.NewClient(appID, appSecret,
lark.WithEnableTokenCache(false),
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
}
err := p.Reply(context.Background(), replyContext{messageID: "om_root", chatID: "oc_chat"}, "hello")
if err != nil {
t.Fatalf("Reply() error = %v", err)
}
if got := replyCalls.Load(); got < 3 {
t.Fatalf("replyCalls = %d, want >= 3 (initial + retries)", got)
}
}
func TestCreateMessageRetriesOnTransientNetworkError(t *testing.T) {
const appID = "cli_transient_create"
const appSecret = "secret"
var createCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"expire": 7200,
"tenant_access_token": "valid-token",
})
case r.URL.Path == "/open-apis/im/v1/messages":
call := createCalls.Add(1)
if call == 1 {
hj, ok := w.(http.Hijacker)
if !ok {
t.Fatalf("response writer does not support hijacking")
}
conn, _, err := hj.Hijack()
if err != nil {
t.Fatalf("hijack failed: %v", err)
}
conn.Close()
return
}
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"data": map[string]any{"message_id": "om_create_ok"},
})
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p := &Platform{
platformName: "feishu",
domain: srv.URL,
appID: appID,
appSecret: appSecret,
client: lark.NewClient(appID, appSecret,
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
replayClient: lark.NewClient(appID, appSecret,
lark.WithEnableTokenCache(false),
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
}
err := p.sendNewMessageToChat(context.Background(), replyContext{chatID: "oc_chat"}, "text", `{"text":"hello"}`)
if err != nil {
t.Fatalf("sendNewMessageToChat() error = %v", err)
}
if got := createCalls.Load(); got < 2 {
t.Fatalf("createCalls = %d, want >= 2 (initial + retry)", got)
}
}
func TestReplyDoesNotRetryOnNonTransientAPIError(t *testing.T) {
const appID = "cli_no_transient_retry"
const appSecret = "secret"
var replyCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"expire": 7200,
"tenant_access_token": "valid-token",
})
case strings.HasSuffix(r.URL.Path, "/reply"):
replyCalls.Add(1)
// Return a non-transient API error (rate limit)
writeJSON(t, w, map[string]any{
"code": 230001,
"msg": "send too fast, please retry later",
})
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p := &Platform{
platformName: "feishu",
domain: srv.URL,
appID: appID,
appSecret: appSecret,
client: lark.NewClient(appID, appSecret,
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
replayClient: lark.NewClient(appID, appSecret,
lark.WithEnableTokenCache(false),
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
}
err := p.Reply(context.Background(), replyContext{messageID: "om_root", chatID: "oc_chat"}, "hello")
if err == nil {
t.Fatal("Reply() error = nil, want failure")
}
if !strings.Contains(err.Error(), "send too fast") {
t.Fatalf("Reply() error = %v, want rate limited error", err)
}
// Should only make 1 attempt (no retry on API-level errors)
if got := replyCalls.Load(); got != 1 {
t.Fatalf("replyCalls = %d, want 1 (no retry)", got)
}
}
func TestPatchMessageRetriesOnTransientError(t *testing.T) {
const appID = "cli_patch_retry"
const appSecret = "secret"
var patchCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"expire": 7200,
"tenant_access_token": "valid-token",
})
case strings.Contains(r.URL.Path, "/messages/") && r.Method == http.MethodPatch:
call := patchCalls.Add(1)
if call == 1 {
hj, ok := w.(http.Hijacker)
if !ok {
t.Fatalf("response writer does not support hijacking")
}
conn, _, err := hj.Hijack()
if err != nil {
t.Fatalf("hijack failed: %v", err)
}
conn.Close()
return
}
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
})
default:
// Allow other paths (e.g. token fetch)
json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok"})
}
}))
defer srv.Close()
p := &Platform{
platformName: "feishu",
domain: srv.URL,
appID: appID,
appSecret: appSecret,
useInteractiveCard: true,
client: lark.NewClient(appID, appSecret,
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
replayClient: lark.NewClient(appID, appSecret,
lark.WithEnableTokenCache(false),
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
}
err := p.UpdateMessage(context.Background(), &feishuPreviewHandle{messageID: "om_card_1", chatID: "oc_chat"}, "updated content")
if err != nil {
t.Fatalf("UpdateMessage() error = %v", err)
}
if got := patchCalls.Load(); got < 2 {
t.Fatalf("patchCalls = %d, want >= 2", got)
}
}
// ─── Test: transient retry + token refresh work together ───────────────────
func TestReplyTransientRetryThenTokenRefresh(t *testing.T) {
// Scenario: first call gets connection reset (transient), retry gets
// invalid token error, which triggers token refresh, then succeeds.
const appID = "cli_combined_retry"
const appSecret = "secret"
var authCalls, replyCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/open-apis/auth/v3/tenant_access_token/internal":
authCalls.Add(1)
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"expire": 7200,
"tenant_access_token": "fresh-token",
})
case strings.HasSuffix(r.URL.Path, "/reply"):
call := replyCalls.Add(1)
switch call {
case 1:
// First attempt: transient error
hj, ok := w.(http.Hijacker)
if !ok {
t.Fatalf("hijack not supported")
}
conn, _, _ := hj.Hijack()
conn.Close()
return
case 2:
// Second attempt (after transient retry): invalid token
writeJSON(t, w, map[string]any{
"code": 99991663,
"msg": "Invalid access token for authorization",
})
case 3:
// Third attempt (after token refresh): success
writeJSON(t, w, map[string]any{
"code": 0,
"msg": "success",
"data": map[string]any{"message_id": "om_reply_ok"},
})
default:
t.Fatalf("unexpected reply call %d", call)
}
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
p := &Platform{
platformName: "feishu",
domain: srv.URL,
appID: appID,
appSecret: appSecret,
client: lark.NewClient(appID, appSecret,
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
replayClient: lark.NewClient(appID, appSecret,
lark.WithEnableTokenCache(false),
lark.WithOpenBaseUrl(srv.URL),
lark.WithHttpClient(srv.Client()),
),
}
err := p.Reply(context.Background(), replyContext{messageID: "om_root", chatID: "oc_chat"}, "hello")
if err != nil {
t.Fatalf("Reply() error = %v", err)
}
if got := replyCalls.Load(); got != 3 {
t.Fatalf("replyCalls = %d, want 3", got)
}
}
// ─── Timing test: verify backoff delay is reasonable ───────────────────────
func TestWithTransientRetry_BackoffTiming(t *testing.T) {
p := &Platform{platformName: "feishu"}
start := time.Now()
calls := 0
_ = p.withTransientRetry(context.Background(), "test", func() error {
calls++
if calls <= 2 {
return io.EOF
}
return nil
})
elapsed := time.Since(start)
// 2 retries: base delays 500ms + 1000ms = ~1500ms, plus up to 25% jitter each.
// Allow generous margin for CI environments.
if elapsed < 500*time.Millisecond {
t.Fatalf("elapsed = %v, expected at least 500ms of backoff", elapsed)
}
if elapsed > 10*time.Second {
t.Fatalf("elapsed = %v, backoff took unreasonably long", elapsed)
}
}
+84
View File
@@ -0,0 +1,84 @@
package feishu
import (
"log/slog"
"sync"
)
// sharedWSGroup tracks all Platform instances sharing the same Feishu app
// WebSocket connection. When multiple projects use the same app_id, Feishu's
// server load-balances messages across WebSocket connections. By sharing a
// single connection and fanning out events to all platforms, every project
// receives every message and can apply its own allow_chat / allow_from filters.
type sharedWSGroup struct {
mu sync.RWMutex
platforms []*Platform
}
var (
sharedWSMu sync.Mutex
sharedWSGroups = map[string]*sharedWSGroup{} // key: app_id "|" domain
)
func sharedWSKey(appID, domain string) string {
return appID + "|" + domain
}
// registerSharedWS registers a platform in the shared WebSocket group for its
// app_id+domain. Returns the group and whether this platform is the primary
// (first to register and responsible for owning the WebSocket connection).
func registerSharedWS(p *Platform) (group *sharedWSGroup, isPrimary bool) {
key := sharedWSKey(p.appID, p.domain)
sharedWSMu.Lock()
defer sharedWSMu.Unlock()
g, exists := sharedWSGroups[key]
if !exists {
g = &sharedWSGroup{}
sharedWSGroups[key] = g
}
g.mu.Lock()
defer g.mu.Unlock()
g.platforms = append(g.platforms, p)
isPrimary = len(g.platforms) == 1
if !isPrimary {
slog.Info("feishu: sharing WebSocket connection",
"app_id", p.appID, "platforms", len(g.platforms))
}
return g, isPrimary
}
// unregisterSharedWS removes a platform from its shared group.
// Returns the number of platforms remaining in the group.
func unregisterSharedWS(p *Platform) int {
key := sharedWSKey(p.appID, p.domain)
sharedWSMu.Lock()
defer sharedWSMu.Unlock()
g, exists := sharedWSGroups[key]
if !exists {
return 0
}
g.mu.Lock()
defer g.mu.Unlock()
for i, plat := range g.platforms {
if plat == p {
g.platforms = append(g.platforms[:i], g.platforms[i+1:]...)
break
}
}
remaining := len(g.platforms)
if remaining == 0 {
delete(sharedWSGroups, key)
}
return remaining
}
// allPlatforms returns a snapshot of all platforms in the group.
func (g *sharedWSGroup) allPlatforms() []*Platform {
g.mu.RLock()
defer g.mu.RUnlock()
result := make([]*Platform, len(g.platforms))
copy(result, g.platforms)
return result
}
+110
View File
@@ -0,0 +1,110 @@
package feishu
import (
"testing"
)
func TestSharedWSGroup_RegisterAndAllPlatforms(t *testing.T) {
// Clean up global state for test isolation.
cleanup := func() {
sharedWSMu.Lock()
defer sharedWSMu.Unlock()
for k := range sharedWSGroups {
delete(sharedWSGroups, k)
}
}
cleanup()
defer cleanup()
p1 := &Platform{appID: "cli_test", domain: "feishu.cn"}
p2 := &Platform{appID: "cli_test", domain: "feishu.cn"}
// Register first platform — should be primary.
g1, isPrimary1 := registerSharedWS(p1)
if !isPrimary1 {
t.Fatal("first platform should be primary")
}
if len(g1.allPlatforms()) != 1 {
t.Fatalf("expected 1 platform, got %d", len(g1.allPlatforms()))
}
// Register second platform — should be secondary, same group.
g2, isPrimary2 := registerSharedWS(p2)
if isPrimary2 {
t.Fatal("second platform should not be primary")
}
if g1 != g2 {
t.Fatal("both platforms should share the same group")
}
if len(g1.allPlatforms()) != 2 {
t.Fatalf("expected 2 platforms, got %d", len(g1.allPlatforms()))
}
}
func TestSharedWSGroup_Unregister(t *testing.T) {
cleanup := func() {
sharedWSMu.Lock()
defer sharedWSMu.Unlock()
for k := range sharedWSGroups {
delete(sharedWSGroups, k)
}
}
cleanup()
defer cleanup()
p1 := &Platform{appID: "cli_test", domain: "feishu.cn"}
p2 := &Platform{appID: "cli_test", domain: "feishu.cn"}
g, _ := registerSharedWS(p1)
registerSharedWS(p2)
// Unregister first — one remains.
remaining := unregisterSharedWS(p1)
if remaining != 1 {
t.Fatalf("expected 1 remaining, got %d", remaining)
}
platforms := g.allPlatforms()
if len(platforms) != 1 || platforms[0] != p2 {
t.Fatal("expected only p2 to remain")
}
// Unregister last — group deleted.
remaining = unregisterSharedWS(p2)
if remaining != 0 {
t.Fatalf("expected 0 remaining, got %d", remaining)
}
sharedWSMu.Lock()
_, exists := sharedWSGroups[sharedWSKey("cli_test", "feishu.cn")]
sharedWSMu.Unlock()
if exists {
t.Fatal("group should be deleted when empty")
}
}
func TestSharedWSGroup_DifferentAppIDs(t *testing.T) {
cleanup := func() {
sharedWSMu.Lock()
defer sharedWSMu.Unlock()
for k := range sharedWSGroups {
delete(sharedWSGroups, k)
}
}
cleanup()
defer cleanup()
p1 := &Platform{appID: "cli_aaa", domain: "feishu.cn"}
p2 := &Platform{appID: "cli_bbb", domain: "feishu.cn"}
g1, isPrimary1 := registerSharedWS(p1)
g2, isPrimary2 := registerSharedWS(p2)
if !isPrimary1 || !isPrimary2 {
t.Fatal("different app_ids should each be primary")
}
if g1 == g2 {
t.Fatal("different app_ids should have separate groups")
}
unregisterSharedWS(p1)
unregisterSharedWS(p2)
}