9afb8b7b68
Lark and Feishu are the same product deployed on two isolated clouds (open.larksuite.com and open.feishu.cn). WeKnora touches both from two places — the IM bot and the Wiki data-source connector — and this brings Lark to parity across both. Neither API surface differs, so rather than duplicate either implementation, each is parametrized by a Region and registered twice. IM (internal/im/feishu): Every API call routes through a.api(), which prefixes the region's host, and the WebSocket client passes larkws.WithDomain — the SDK defaults to open.feishu.cn, so Lark long connections would otherwise fail to authenticate. Data source (internal/datasource/connector/feishu): Lark was half-supported: the client honoured a base_url override and a LarkBaseURL constant existed, but the connector registered only as "feishu" and the UI never exposed base_url, so Lark wikis could not be configured at all. Lark is now its own connector type. base_url is kept as an explicit override so data sources that pointed a "feishu" connector at open.larksuite.com keep working. Bugs found and fixed along the way: - Wiki resource links were hardcoded to https://feishu.cn/wiki/{token} in four places, so a Lark wiki linked into the wrong cloud entirely. - Bot identity is now platform-prefixed (feishu:cli_x vs lark:cli_x). BotIdentity carries a unique index, so without this a Lark channel would be rejected as a duplicate of an unrelated Feishu bot sharing an app_id. - The card image cache is now keyed by app_id. image_keys are issued per app on one cloud; a Feishu key handed to a Lark app fails the card update with code=200570. - The datasource doc claimed Lark "adapts automatically" — it could not, since the UI had no way to reach base_url. Permissions are where the clouds genuinely diverge. Identical APIs do not mean identical permission catalogues: aily:file:read, aily:file:write and corehr:file:download exist on Feishu but not on Lark, so pasting Feishu's scope JSON into Lark fails to import. That JSON is also over-broad (it covers products WeKnora never calls) and simultaneously incomplete (it omits the drive:*/docx:* scopes the connector needs). Lark therefore gets its own list, derived from the 17 endpoints the code actually calls and checked identifier-by-identifier against Lark's own API reference, plus a full Feishu→Lark mapping table marking each scope verified, unverified, or absent. Both features use tenant_access_token only — no user_access_ token exists anywhere in the repo — so the user dimension stays empty. Known limitation: Region.ThinkingText makes Lark's initial card placeholder English, but once streaming starts the think-block headers come from the shared MarkdownThinkStyle and are Chinese on every platform (Lark, Slack, Telegram, Mattermost alike). Fixing that means threading a locale through FormatIMDisplayContent, which has no platform context — a cross-platform change, not a Lark one. Documented as such. The channel-creation 400 message is now derived from validIMPlatforms so the list cannot drift as platforms are added. Tests: region invariants and per-region URL construction on both sides, the Feishu/Lark bot-identity split, platform-to-channel mapping, base_url override precedence, an end-to-end SendReply against a fake Open Platform (token fetch, reply, thread-unsupported fallback, hard-error surfacing), and a ListResources drive asserting wiki links never leak the other cloud's host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
8.3 KiB
Go
237 lines
8.3 KiB
Go
package datasource
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
)
|
|
|
|
// Connector is the interface that all external data source connectors must implement.
|
|
// Each connector (Feishu, Notion, Confluence, etc.) provides an implementation of this interface.
|
|
type Connector interface {
|
|
// Type returns the connector type identifier (e.g., "feishu", "notion")
|
|
Type() string
|
|
|
|
// Validate verifies that the provided configuration is valid by testing connectivity
|
|
// and checking credentials. Returns error if validation fails.
|
|
Validate(ctx context.Context, config *types.DataSourceConfig) error
|
|
|
|
// ListResources lists available resources that can be synced (documents, spaces, folders, etc.)
|
|
// Returns a list of Resource objects that the user can select for syncing.
|
|
//
|
|
// parentID controls lazy (on-demand) loading of hierarchical resources:
|
|
// - parentID == "" → return the top-level resources (e.g. Feishu wiki spaces).
|
|
// - parentID != "" → return only the direct children of that resource.
|
|
// Connectors whose listing is already flat or returns the full tree in a single
|
|
// call may ignore parentID for the root call and return an empty slice for any
|
|
// non-empty parentID.
|
|
ListResources(ctx context.Context, config *types.DataSourceConfig, parentID string) ([]types.Resource, error)
|
|
|
|
// ResolveResourceAncestors resolves, for each of the given resource IDs, the
|
|
// ExternalIDs of every ancestor whose direct children must be loaded so a
|
|
// lazily-loaded picker can reveal a pre-existing (possibly deeply nested)
|
|
// selection. The returned set is deduplicated and unordered.
|
|
//
|
|
// It exists so connectors that load their tree one level at a time (e.g. the
|
|
// Feishu wiki) can expose, in O(depth) per selection, the path back to the
|
|
// root without re-traversing the whole tree. Connectors that already return
|
|
// the full tree (Notion) or a flat list (Yuque) have nothing to reveal and
|
|
// return an empty slice.
|
|
ResolveResourceAncestors(
|
|
ctx context.Context, config *types.DataSourceConfig, resourceIDs []string,
|
|
) ([]string, error)
|
|
|
|
// FetchAll performs a full sync of the specified resources.
|
|
// Returns all items from the given resource IDs.
|
|
FetchAll(ctx context.Context, config *types.DataSourceConfig, resourceIDs []string) ([]types.FetchedItem, error)
|
|
|
|
// FetchIncremental performs an incremental sync based on the provided cursor.
|
|
// Returns items that have changed since the last sync, a new cursor for the next sync,
|
|
// and an error if the operation fails.
|
|
FetchIncremental(ctx context.Context, config *types.DataSourceConfig, cursor *types.SyncCursor) ([]types.FetchedItem, *types.SyncCursor, error)
|
|
}
|
|
|
|
// ConnectorRegistry manages the registration and lookup of available connectors
|
|
type ConnectorRegistry struct {
|
|
connectors map[string]Connector
|
|
}
|
|
|
|
// NewConnectorRegistry creates a new connector registry
|
|
func NewConnectorRegistry() *ConnectorRegistry {
|
|
return &ConnectorRegistry{
|
|
connectors: make(map[string]Connector),
|
|
}
|
|
}
|
|
|
|
// Register registers a connector with the registry
|
|
func (r *ConnectorRegistry) Register(connector Connector) error {
|
|
if connector == nil {
|
|
return ErrConnectorNil
|
|
}
|
|
if connector.Type() == "" {
|
|
return ErrConnectorTypeEmpty
|
|
}
|
|
r.connectors[connector.Type()] = connector
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves a connector by type
|
|
func (r *ConnectorRegistry) Get(connectorType string) (Connector, error) {
|
|
connector, exists := r.connectors[connectorType]
|
|
if !exists {
|
|
return nil, ErrConnectorNotFound
|
|
}
|
|
return connector, nil
|
|
}
|
|
|
|
// List returns all registered connector types
|
|
func (r *ConnectorRegistry) List() []string {
|
|
types := make([]string, 0, len(r.connectors))
|
|
for t := range r.connectors {
|
|
types = append(types, t)
|
|
}
|
|
return types
|
|
}
|
|
|
|
// ConnectorMetadata provides metadata about available connectors
|
|
type ConnectorMetadata struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Icon string `json:"icon,omitempty"`
|
|
Priority int `json:"priority"` // Priority order for UI display (lower = higher priority)
|
|
AuthType string `json:"auth_type"` // "oauth2", "api_key", "token", etc.
|
|
Capabilities []string `json:"capabilities"` // "incremental", "webhook", "deletion_sync", etc.
|
|
}
|
|
|
|
// GetConnectorMetadata returns metadata for all available connectors
|
|
// This is used by the frontend to display connector options
|
|
var ConnectorMetadataRegistry = map[string]ConnectorMetadata{
|
|
types.ConnectorTypeFeishu: {
|
|
Type: types.ConnectorTypeFeishu,
|
|
Name: "Feishu (飞书)",
|
|
Description: "Sync documents, wikis, and content from Feishu",
|
|
Priority: 0,
|
|
AuthType: "oauth2",
|
|
Capabilities: []string{"incremental", "deletion_sync"},
|
|
},
|
|
types.ConnectorTypeLark: {
|
|
Type: types.ConnectorTypeLark,
|
|
Name: "Lark",
|
|
Description: "Sync documents, wikis, and content from Lark (Feishu international)",
|
|
Priority: 0,
|
|
AuthType: "oauth2",
|
|
Capabilities: []string{"incremental", "deletion_sync"},
|
|
},
|
|
types.ConnectorTypeNotion: {
|
|
Type: types.ConnectorTypeNotion,
|
|
Name: "Notion",
|
|
Description: "Sync pages and databases from Notion",
|
|
Priority: 1,
|
|
AuthType: "api_key",
|
|
Capabilities: []string{"incremental"},
|
|
},
|
|
types.ConnectorTypeConfluence: {
|
|
Type: types.ConnectorTypeConfluence,
|
|
Name: "Confluence",
|
|
Description: "Sync spaces and pages from Atlassian Confluence",
|
|
Priority: 2,
|
|
AuthType: "api_key",
|
|
Capabilities: []string{"incremental"},
|
|
},
|
|
types.ConnectorTypeYuque: {
|
|
Type: types.ConnectorTypeYuque,
|
|
Name: "Yuque (语雀)",
|
|
Description: "Sync knowledge bases and documents from Yuque",
|
|
Priority: 3,
|
|
AuthType: "api_key",
|
|
Capabilities: []string{"incremental"},
|
|
},
|
|
types.ConnectorTypeGitHub: {
|
|
Type: types.ConnectorTypeGitHub,
|
|
Name: "GitHub",
|
|
Description: "Sync repositories, wikis, and issues from GitHub",
|
|
Priority: 4,
|
|
AuthType: "oauth2",
|
|
Capabilities: []string{"incremental"},
|
|
},
|
|
types.ConnectorTypeGoogleDrive: {
|
|
Type: types.ConnectorTypeGoogleDrive,
|
|
Name: "Google Drive",
|
|
Description: "Sync documents and files from Google Drive",
|
|
Priority: 5,
|
|
AuthType: "oauth2",
|
|
Capabilities: []string{"incremental"},
|
|
},
|
|
types.ConnectorTypeOneDrive: {
|
|
Type: types.ConnectorTypeOneDrive,
|
|
Name: "OneDrive / SharePoint",
|
|
Description: "Sync documents and files from Microsoft OneDrive",
|
|
Priority: 6,
|
|
AuthType: "oauth2",
|
|
Capabilities: []string{"incremental"},
|
|
},
|
|
types.ConnectorTypeDingTalk: {
|
|
Type: types.ConnectorTypeDingTalk,
|
|
Name: "DingTalk (钉钉)",
|
|
Description: "Sync documents and content from DingTalk",
|
|
Priority: 7,
|
|
AuthType: "api_key",
|
|
Capabilities: []string{"incremental"},
|
|
},
|
|
types.ConnectorTypeWebCrawler: {
|
|
Type: types.ConnectorTypeWebCrawler,
|
|
Name: "Web Crawler (Sitemap)",
|
|
Description: "Crawl websites via Sitemap.xml",
|
|
Priority: 9,
|
|
AuthType: "none",
|
|
Capabilities: []string{},
|
|
},
|
|
types.ConnectorTypeSlack: {
|
|
Type: types.ConnectorTypeSlack,
|
|
Name: "Slack",
|
|
Description: "Sync channel messages and files from Slack",
|
|
Priority: 10,
|
|
AuthType: "oauth2",
|
|
Capabilities: []string{"incremental"},
|
|
},
|
|
types.ConnectorTypeIMAP: {
|
|
Type: types.ConnectorTypeIMAP,
|
|
Name: "Email (IMAP)",
|
|
Description: "Sync email content from IMAP servers",
|
|
Priority: 11,
|
|
AuthType: "password",
|
|
Capabilities: []string{},
|
|
},
|
|
types.ConnectorTypeRSS: {
|
|
Type: types.ConnectorTypeRSS,
|
|
Name: "RSS / Atom Feed",
|
|
Description: "Sync articles from RSS/Atom feeds",
|
|
Priority: 12,
|
|
AuthType: "custom",
|
|
Capabilities: []string{"incremental"},
|
|
},
|
|
}
|
|
|
|
// ListAvailableConnectors returns all available connector metadata
|
|
// sorted by priority
|
|
func ListAvailableConnectors() []ConnectorMetadata {
|
|
metadata := make([]ConnectorMetadata, 0, len(ConnectorMetadataRegistry))
|
|
for _, meta := range ConnectorMetadataRegistry {
|
|
metadata = append(metadata, meta)
|
|
}
|
|
|
|
// Sort by priority (insertion sort for simplicity)
|
|
for i := 1; i < len(metadata); i++ {
|
|
key := metadata[i]
|
|
j := i - 1
|
|
for j >= 0 && metadata[j].Priority > key.Priority {
|
|
metadata[j+1] = metadata[j]
|
|
j--
|
|
}
|
|
metadata[j+1] = key
|
|
}
|
|
|
|
return metadata
|
|
}
|