Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

169 lines
4.7 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package source
import (
"context"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
const maxEventBodyBytes = 1 << 20 // bound per-subscriber sendCh memory under runaway payloads
type FeishuSource struct {
AppID string
AppSecret string
Domain string
Logger *log.Logger
}
func (s *FeishuSource) Name() string { return "feishu-websocket" }
func (s *FeishuSource) Start(ctx context.Context, eventTypes []string, emit func(*event.RawEvent), notify StatusNotifier) error {
d := dispatcher.NewEventDispatcher("", "")
rawHandler := s.buildRawHandler(emit)
for _, et := range eventTypes {
d.OnCustomizedEvent(et, rawHandler)
}
opts := []larkws.ClientOption{larkws.WithEventHandler(d)}
if s.Domain != "" {
opts = append(opts, larkws.WithDomain(s.Domain))
}
if s.Logger != nil || notify != nil {
opts = append(opts, larkws.WithLogLevel(larkcore.LogLevelInfo))
opts = append(opts, larkws.WithLogger(&sdkLogger{l: s.Logger, notify: notify}))
}
if notify != nil {
notify(protocol.SourceStateConnecting, "")
}
cli := larkws.NewClient(s.AppID, s.AppSecret, opts...)
errCh := make(chan error, 1)
go func() { errCh <- cli.Start(ctx) }()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-errCh:
return err
}
}
// buildRawHandler is extracted from Start so unit tests can exercise it without a WS client.
func (s *FeishuSource) buildRawHandler(emit func(*event.RawEvent)) func(context.Context, *larkevent.EventReq) error {
return func(_ context.Context, e *larkevent.EventReq) error {
if e.Body == nil {
return nil
}
if len(e.Body) > maxEventBodyBytes {
if s.Logger != nil {
s.Logger.Printf("[feishu] drop oversized event: %d bytes > cap %d", len(e.Body), maxEventBodyBytes)
}
return nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
}
if err := json.Unmarshal(e.Body, &envelope); err != nil {
if s.Logger != nil {
preview := string(e.Body)
if len(preview) > 200 {
preview = preview[:200] + "...(truncated)"
}
s.Logger.Printf("[feishu] drop malformed event: unmarshal error: %v body=%s", err, preview)
}
return nil
}
if envelope.Header.EventID == "" || envelope.Header.EventType == "" {
if s.Logger != nil {
s.Logger.Printf("[feishu] drop event missing header fields: event_id=%q event_type=%q",
envelope.Header.EventID, envelope.Header.EventType)
}
return nil
}
emit(&event.RawEvent{
EventID: envelope.Header.EventID,
EventType: envelope.Header.EventType,
SourceTime: envelope.Header.CreateTime,
Payload: json.RawMessage(e.Body),
Timestamp: time.Now(),
})
return nil
}
}
// sdkLogger forwards every SDK line to bus.log; lifecycle lines also fire notify.
type sdkLogger struct {
l *log.Logger
notify StatusNotifier
}
func (a *sdkLogger) Debug(_ context.Context, _ ...interface{}) {}
func (a *sdkLogger) Info(_ context.Context, args ...interface{}) {
msg := fmt.Sprint(args...)
if a.l != nil {
a.l.Output(2, "[SDK] "+msg)
}
a.tryNotify(msg, "")
}
func (a *sdkLogger) Warn(_ context.Context, args ...interface{}) {
msg := fmt.Sprint(args...)
if a.l != nil {
a.l.Output(2, "[SDK WARN] "+msg)
}
a.tryNotify(msg, "")
}
func (a *sdkLogger) Error(_ context.Context, args ...interface{}) {
msg := fmt.Sprint(args...)
if a.l != nil {
a.l.Output(2, "[SDK ERROR] "+msg)
}
// Errors usually manifest as disconnects; pass msg as detail.
a.tryNotify(msg, msg)
}
var reconnectAttemptRe = regexp.MustCompile(`reconnect:?\s*(\d+)`)
// tryNotify uses HasPrefix (not Contains): "connected to" matches inside "disconnected to" otherwise.
func (a *sdkLogger) tryNotify(msg, errDetail string) {
if a.notify == nil {
return
}
lower := strings.ToLower(msg)
switch {
case strings.HasPrefix(lower, sdkLogReconnecting):
detail := ""
if m := reconnectAttemptRe.FindStringSubmatch(lower); len(m) == 2 {
detail = "attempt " + m[1]
}
a.notify(protocol.SourceStateReconnecting, detail)
case strings.HasPrefix(lower, sdkLogDisconnected):
a.notify(protocol.SourceStateDisconnected, errDetail)
case strings.HasPrefix(lower, sdkLogConnected):
a.notify(protocol.SourceStateConnected, "")
}
}
var _ larkcore.Logger = (*sdkLogger)(nil)