初始化仓库
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
package feishu
|
||||
|
||||
import "github.com/chenhg5/cc-connect/core"
|
||||
|
||||
var _ core.PreviewCleaner = (*Platform)(nil)
|
||||
var _ core.PreviewFinishPreference = (*Platform)(nil)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user