188 lines
6.6 KiB
Go
188 lines
6.6 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Package source wraps the open-dingtalk Stream SDK and exposes a small
|
|
// blocking Start interface plus a connection state machine. The SDK is the
|
|
// only place in the bus that talks to the cloud; the rest of the bus stays
|
|
// vendor-agnostic and would be drop-in replaceable for a different Stream
|
|
// provider.
|
|
package source
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/event"
|
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/handler"
|
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
|
|
)
|
|
|
|
// Config carries the credentials and behavioural knobs needed to construct a
|
|
// DingtalkSource. ClientID/ClientSecret are required for app-credential SDK
|
|
// mode. Portal ticket normal mode uses portal-side managed credentials, so it
|
|
// does not require local app credentials.
|
|
type Config struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
// PortalTicket switches the source to the portal-managed user Stream
|
|
// ticket flow. When set, Start fetches endpoint+ticket over HTTP and
|
|
// dials the returned WebSocket directly instead of asking the SDK to
|
|
// open an app-credential connection.
|
|
PortalTicket *PortalTicketConfig
|
|
// Now is injected for tests. Defaults to time.Now when nil.
|
|
Now func() time.Time
|
|
}
|
|
|
|
// SourceOptions is the functional-option type reserved for future overlay
|
|
// extensions (e.g. inject trace IDs into emit, swap the underlying client
|
|
// for a fake). v1 takes no options; the type exists so callers can write
|
|
// `New(cfg)` today and `New(cfg, WithFoo())` tomorrow without an API break.
|
|
// See plan §7 Edition 扩展点.
|
|
type SourceOption func(*sourceConfig)
|
|
|
|
type sourceConfig struct {
|
|
// reserved for v2 hooks (pre-emit interceptor, etc.)
|
|
}
|
|
|
|
// DingtalkSource is the cloud-side adapter. It owns one StreamClient and one
|
|
// state Machine; lifecycle is bounded by the context passed to Start.
|
|
type DingtalkSource struct {
|
|
cfg Config
|
|
machine *Machine
|
|
cli streamClient
|
|
}
|
|
|
|
type streamClient interface {
|
|
RegisterAllEventRouter(handler.IFrameHandler)
|
|
Start(context.Context) error
|
|
Close()
|
|
}
|
|
|
|
var newStreamClient = func(options ...client.ClientOption) streamClient {
|
|
return client.NewStreamClient(options...)
|
|
}
|
|
|
|
// New constructs a DingtalkSource. Returns an error if required Config
|
|
// fields are missing — keep the boundary tight so misconfiguration fails
|
|
// loudly rather than at first-event time.
|
|
func New(cfg Config, _ ...SourceOption) (*DingtalkSource, error) {
|
|
if cfg.PortalTicket != nil {
|
|
if err := cfg.PortalTicket.Valid(); err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.PortalTicket.normalizedMode() == PortalTicketModeCustom && cfg.ClientID == "" {
|
|
return nil, errors.New("source: ClientID is required")
|
|
}
|
|
} else {
|
|
if cfg.ClientID == "" {
|
|
return nil, errors.New("source: ClientID is required")
|
|
}
|
|
if cfg.ClientSecret == "" {
|
|
return nil, errors.New("source: ClientSecret is required")
|
|
}
|
|
}
|
|
if cfg.Now == nil {
|
|
cfg.Now = time.Now
|
|
}
|
|
m := NewMachine()
|
|
m.now = cfg.Now
|
|
return &DingtalkSource{cfg: cfg, machine: m}, nil
|
|
}
|
|
|
|
// State returns the current Snapshot of the connection state machine.
|
|
func (s *DingtalkSource) State() Snapshot { return s.machine.Snapshot() }
|
|
|
|
// Start opens the Stream WebSocket and blocks until ctx is cancelled or a
|
|
// fatal SDK error occurs. Events are delivered to emit synchronously from
|
|
// the SDK callback goroutine — emit MUST return immediately (the SDK's
|
|
// processLoop is single-goroutine; see plan invariant #1 and P0 §1 row 2).
|
|
//
|
|
// Blocking semantics: the underlying StreamClient.Start returns as soon as
|
|
// dial succeeds (it spawns its own processLoop goroutine), so we wait on
|
|
// ctx.Done() before returning. cli.Close() is always called on exit; ctx.Err
|
|
// is returned for context cancellation, otherwise the underlying error.
|
|
func (s *DingtalkSource) Start(ctx context.Context, emit dwsevent.EmitFn) error {
|
|
if emit == nil {
|
|
return errors.New("source: emit is required")
|
|
}
|
|
if s.cli != nil {
|
|
return errors.New("source: Start called twice")
|
|
}
|
|
if s.cfg.PortalTicket != nil {
|
|
return s.startPortalTicket(ctx, emit)
|
|
}
|
|
|
|
options := []client.ClientOption{
|
|
client.WithAppCredential(client.NewAppCredentialConfig(s.cfg.ClientID, s.cfg.ClientSecret)),
|
|
}
|
|
s.cli = newStreamClient(options...)
|
|
s.cli.RegisterAllEventRouter(s.makeHandler(emit))
|
|
|
|
s.machine.OnConnecting()
|
|
if err := s.cli.Start(ctx); err != nil {
|
|
s.machine.OnStopped()
|
|
return err
|
|
}
|
|
s.machine.OnConnected()
|
|
|
|
<-ctx.Done()
|
|
s.cli.Close()
|
|
s.machine.OnStopped()
|
|
return ctx.Err()
|
|
}
|
|
|
|
// makeHandler returns the IFrameHandler closure the SDK invokes for every
|
|
// inbound event. The closure:
|
|
// 1. parses the EventHeader from the raw DataFrame,
|
|
// 2. builds a RawEvent with all 5 SDK header fields + payload + receive
|
|
// time + full header map (passthrough),
|
|
// 3. calls emit (non-blocking by contract),
|
|
// 4. updates the state machine,
|
|
// 5. returns SUCCESS (LATER is reserved for future explicit retry policy;
|
|
// v1 always Success and relies on dedup, see P0 §1 row 6).
|
|
func (s *DingtalkSource) makeHandler(emit dwsevent.EmitFn) func(context.Context, *payload.DataFrame) (*payload.DataFrameResponse, error) {
|
|
return func(_ context.Context, df *payload.DataFrame) (*payload.DataFrameResponse, error) {
|
|
hdr := event.NewEventHeaderFromDataFrame(df)
|
|
raw := &dwsevent.RawEvent{
|
|
EventID: hdr.EventId,
|
|
EventBornTime: hdr.EventBornTime,
|
|
EventCorpID: hdr.EventCorpId,
|
|
EventType: hdr.EventType,
|
|
EventUnifiedAppID: hdr.EventUnifiedAppId,
|
|
Data: df.Data,
|
|
Headers: copyHeaders(df.Headers),
|
|
ReceivedAt: s.cfg.Now().UTC(),
|
|
}
|
|
emit(raw)
|
|
s.machine.OnEvent()
|
|
|
|
resp := payload.NewSuccessDataFrameResponse()
|
|
_ = resp.SetJson(event.NewEventProcessResultSuccess())
|
|
return resp, nil
|
|
}
|
|
}
|
|
|
|
func copyHeaders(h payload.DataFrameHeader) map[string]string {
|
|
if len(h) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(h))
|
|
for k, v := range h {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|