WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/datasource"
|
||||
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// DriveConnector implements the datasource.Connector (and StreamingConnector)
|
||||
// interface for Feishu/Lark Drive (云盘) mode. It shares core.Client/core.Config/core.Region
|
||||
// and the export/download logic with the wiki Connector; only resource
|
||||
// enumeration and Fetch dispatch differ. See 飞书云盘数据源设计.md and
|
||||
// ADR-0001..0004.
|
||||
type DriveConnector struct {
|
||||
region core.Region
|
||||
}
|
||||
|
||||
// NewDriveConnector creates a Drive connector for the given region
|
||||
// (RegionFeishuDrive or RegionLarkDrive).
|
||||
func NewDriveConnector(region core.Region) *DriveConnector {
|
||||
return &DriveConnector{region: region}
|
||||
}
|
||||
|
||||
// Drive supports resumable streaming sync; the service prefers FetchStream over
|
||||
// FetchAll/FetchIncremental when a connector implements StreamingConnector.
|
||||
var _ datasource.StreamingConnector = (*DriveConnector)(nil)
|
||||
|
||||
// Type returns the connector type identifier.
|
||||
func (c *DriveConnector) Type() string {
|
||||
return c.region.ConnectorType
|
||||
}
|
||||
|
||||
// Validate verifies that the Drive configuration is valid by testing
|
||||
// connectivity. It does not validate folder_token here - that is done in
|
||||
// ListResources when the user loads the tree root. Mirrors the wiki Connector.
|
||||
func (c *DriveConnector) Validate(ctx context.Context, config *types.DataSourceConfig) error {
|
||||
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := core.NewClient(feishuConfig)
|
||||
if err := client.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("%s connection failed: %w", c.region.Label, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListResources lists Feishu Drive resources for selection, loading the tree
|
||||
// lazily one level at a time. Mirrors the wiki Connector.ListResources shape
|
||||
// but with the Drive difference that the root is user-supplied (config.
|
||||
// ResourceIDs[0]) rather than enumerated via ListWikiSpaces.
|
||||
//
|
||||
// - parentID == "" -> return the user-supplied root
|
||||
// folder (from config.ResourceIDs[0]) as the single root resource
|
||||
// (HasChildren=true). Drive has no "space list" API, so the root is
|
||||
// user-supplied. folder_token == "" is rejected (ADR-0004).
|
||||
// - parentID == folderToken -> ListDriveFiles(folderToken)
|
||||
// returns the direct children.
|
||||
// - parentID == "folderToken:subFolderToken" -> ListDriveFiles(subFolderToken)
|
||||
// returns that sub-folder's direct children.
|
||||
//
|
||||
// Each core.DriveFile becomes a Resource: folder HasChildren=true, others false.
|
||||
// resourceID encoding: root = folderToken; child = folderToken + ":" + fileToken
|
||||
// (reuses core.FeishuWikiNodeResourceSeparator). See ADR-0001 §3.4.
|
||||
func (c *DriveConnector) ListResources(
|
||||
ctx context.Context, config *types.DataSourceConfig, parentID string,
|
||||
) ([]types.Resource, error) {
|
||||
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := core.NewClient(feishuConfig)
|
||||
|
||||
if parentID == "" {
|
||||
// Root load: read the user-supplied folder_token from config.ResourceIDs.
|
||||
rootFolderToken := driveRootFolderToken(config)
|
||||
if rootFolderToken == "" {
|
||||
return nil, fmt.Errorf("folder_token is required; specify a Drive folder token")
|
||||
}
|
||||
// Validate access by listing the root's direct children (also lazy-loads
|
||||
// the first level for the picker). Reuse the list call rather than a
|
||||
// separate ping.
|
||||
files, err := client.ListDriveFilesAllPages(ctx, rootFolderToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list feishu drive folder %s: %w", rootFolderToken, err)
|
||||
}
|
||||
_ = files // children returned via the parentID == rootFolderToken branch below
|
||||
// Resolve the root folder's human-readable name via the folder meta API.
|
||||
// Best-effort: on failure (no permission / not found) fall back to the
|
||||
// folder_token so the picker still renders something usable.
|
||||
folderName := rootFolderToken
|
||||
if meta, mErr := client.GetDriveFolderMeta(ctx, rootFolderToken); mErr != nil {
|
||||
logger.Warnf(ctx, "[FeishuDrive] resolve root folder name failed: %v (falling back to token)", mErr)
|
||||
} else if meta.Data.Name != "" {
|
||||
folderName = meta.Data.Name
|
||||
}
|
||||
return []types.Resource{c.driveFolderToResource(rootFolderToken, "", rootFolderToken, folderName)}, nil
|
||||
}
|
||||
|
||||
// Lazy load: list only the direct children of the given folder.
|
||||
rootFolderToken, folderToken := parseDriveResourceID(parentID)
|
||||
if folderToken == "" {
|
||||
// parentID is a bare root folder token -> list its children.
|
||||
folderToken = rootFolderToken
|
||||
}
|
||||
files, err := client.ListDriveFilesAllPages(ctx, folderToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list feishu drive files under %s: %w", parentID, err)
|
||||
}
|
||||
|
||||
resources := make([]types.Resource, 0, len(files))
|
||||
for _, f := range files {
|
||||
resources = append(resources, c.driveFileToResource(rootFolderToken, f))
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// ResolveResourceAncestors returns the resource IDs of every parent folder that
|
||||
// has to be expanded so the lazily-loaded picker can reveal each selection.
|
||||
//
|
||||
// The wiki connector walks up via GetWikiNode (parent_node_token) in O(depth)
|
||||
// single-node queries. Drive has no single-file parent query API (verified -
|
||||
// metas/batch_query does not return parent), so we walk top-down from the root
|
||||
// folder with ListDriveFiles and share the traversal across all selections in
|
||||
// the same root. Best-effort: a broken path just stays collapsed. See ADR-0003.
|
||||
func (c *DriveConnector) ResolveResourceAncestors(
|
||||
ctx context.Context, config *types.DataSourceConfig, resourceIDs []string,
|
||||
) ([]string, error) {
|
||||
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := core.NewClient(feishuConfig)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
ancestors := make([]string, 0)
|
||||
add := func(id string) {
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
ancestors = append(ancestors, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Group selections by root folder so one shared traversal covers them all.
|
||||
type selection struct {
|
||||
fileToken string
|
||||
}
|
||||
rootSelections := make(map[string][]selection)
|
||||
for _, rid := range resourceIDs {
|
||||
rootFolderToken, fileToken := parseDriveResourceID(rid)
|
||||
if fileToken == "" {
|
||||
// A root-level selection is already a top-level node; nothing to reveal.
|
||||
continue
|
||||
}
|
||||
add(rootFolderToken)
|
||||
rootSelections[rootFolderToken] = append(rootSelections[rootFolderToken], selection{fileToken})
|
||||
}
|
||||
|
||||
// For each root, BFS from the root: at each folder, ListDriveFiles and check
|
||||
// which selections are direct children (record their parent chain) and which
|
||||
// sub-folders may still contain selections (enqueue). Shared traversal means
|
||||
// selections in the same subtree reuse list calls.
|
||||
for rootFolderToken, sels := range rootSelections {
|
||||
remaining := make(map[string]bool, len(sels))
|
||||
for _, s := range sels {
|
||||
remaining[s.fileToken] = true
|
||||
}
|
||||
// parentChain[fileToken] = resourceID of its parent folder
|
||||
parentChain := make(map[string]string)
|
||||
|
||||
queue := []string{rootFolderToken}
|
||||
for len(queue) > 0 && len(remaining) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
files, err := client.ListDriveFilesAllPages(ctx, cur)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[FeishuDrive] resolve ancestors: list %s: %v", cur, err)
|
||||
break // best-effort: stop this root's traversal
|
||||
}
|
||||
for _, f := range files {
|
||||
if remaining[f.Token] {
|
||||
delete(remaining, f.Token)
|
||||
// Record the parent chain from root down to this file's parent.
|
||||
chain := buildDriveAncestorChain(rootFolderToken, cur, parentChain)
|
||||
for _, a := range chain {
|
||||
add(a)
|
||||
}
|
||||
}
|
||||
if f.Type == "folder" {
|
||||
parentChain[f.Token] = makeDriveResourceID(rootFolderToken, cur)
|
||||
queue = append(queue, f.Token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ancestors, nil
|
||||
}
|
||||
|
||||
// buildDriveAncestorChain walks the parentChain map from cur up to root,
|
||||
// returning the resourceIDs (root, ... , cur's parent) in root-first order.
|
||||
func buildDriveAncestorChain(rootFolderToken, cur string, parentChain map[string]string) []string {
|
||||
var chain []string
|
||||
node := cur
|
||||
for node != "" && node != rootFolderToken {
|
||||
parent, ok := parentChain[node]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
chain = append([]string{parent}, chain...)
|
||||
_, parentFolderToken := parseDriveResourceID(parent)
|
||||
node = parentFolderToken
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
// FetchAll performs a full sync of all documents from the selected Drive
|
||||
// folders. Defensive fallback path - the service prefers FetchStream when the
|
||||
// connector implements StreamingConnector.
|
||||
func (c *DriveConnector) FetchAll(
|
||||
ctx context.Context, config *types.DataSourceConfig, resourceIDs []string,
|
||||
) ([]types.FetchedItem, error) {
|
||||
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := core.NewClient(feishuConfig)
|
||||
return core.FetchAllEngine(ctx, client, config, resourceIDs, driveOps{region: c.region})
|
||||
}
|
||||
|
||||
// FetchIncremental performs an incremental sync by comparing file modified_time
|
||||
// against the previously recorded state. Defensive fallback path - the service
|
||||
// prefers FetchStream. Routed through the same engine as FetchStream, so the
|
||||
// #2136 failure-doesn't-advance-cursor semantics apply here too.
|
||||
func (c *DriveConnector) FetchIncremental(
|
||||
ctx context.Context, config *types.DataSourceConfig, cursor *types.SyncCursor,
|
||||
) ([]types.FetchedItem, *types.SyncCursor, error) {
|
||||
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client := core.NewClient(feishuConfig)
|
||||
ops := driveOps{region: c.region}
|
||||
if len(config.ResourceIDs) == 0 {
|
||||
return nil, nil, errors.New(ops.EmptyResourceIDsError())
|
||||
}
|
||||
return core.FetchIncrementalEngine(ctx, client, config, cursor, ops)
|
||||
}
|
||||
|
||||
// FetchStream performs a resumable, memory-bounded sync. It unifies the full
|
||||
// and incremental paths: with cursor == nil it fetches everything, and with a
|
||||
// cursor it skips files whose recorded modified_time is unchanged - the same
|
||||
// mechanism that lets a sync which timed out mid-traversal resume from the last
|
||||
// checkpoint instead of restarting (Tencent/WeKnora#2136).
|
||||
//
|
||||
// The per-node loop lives in the shared engine (engine.go); this shell only
|
||||
// wires the Drive NodeOps adapter.
|
||||
func (c *DriveConnector) FetchStream(
|
||||
ctx context.Context, config *types.DataSourceConfig,
|
||||
cursor *types.SyncCursor, h datasource.StreamHandler,
|
||||
) (*types.SyncCursor, error) {
|
||||
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := core.NewClient(feishuConfig)
|
||||
ops := driveOps{region: c.region}
|
||||
if len(config.ResourceIDs) == 0 {
|
||||
return nil, errors.New(ops.EmptyResourceIDsError())
|
||||
}
|
||||
return core.FetchStreamEngine(ctx, client, config, cursor, h, ops)
|
||||
}
|
||||
|
||||
// driveOps adapts the Drive DriveConnector to the generic sync engine. It
|
||||
// carries the region (for channel + URL) and encodes/decodes the Drive cursor
|
||||
// wire format (core.FeishuDriveCursor / file_times) so the engine stays format-agnostic.
|
||||
type driveOps struct {
|
||||
region core.Region
|
||||
}
|
||||
|
||||
func (o driveOps) List(ctx context.Context, client *core.Client, resourceID string) ([]core.DriveFile, error, error) {
|
||||
files, err := listDriveFilesForResource(ctx, client, resourceID)
|
||||
if err == nil {
|
||||
return files, nil, nil
|
||||
}
|
||||
var partial *core.PartialDriveFileListError
|
||||
if errors.As(err, &partial) {
|
||||
return files, err, nil
|
||||
}
|
||||
return files, nil, err
|
||||
}
|
||||
|
||||
func (o driveOps) Token(n core.DriveFile) string { return n.Token }
|
||||
func (o driveOps) Title(n core.DriveFile) string { return n.Name }
|
||||
func (o driveOps) ObjType(n core.DriveFile) string { return n.Type }
|
||||
func (o driveOps) EditTime(n core.DriveFile) string { return n.ModifiedTime }
|
||||
|
||||
func (o driveOps) Fetch(ctx context.Context, client *core.Client, n core.DriveFile, resourceID string, multimodal bool) ([]*types.FetchedItem, error) {
|
||||
return fetchDriveFileContent(ctx, client, n, resourceID, multimodal, o.region)
|
||||
}
|
||||
|
||||
func (o driveOps) ListFailureItems(resourceID string, partial error) []types.FetchedItem {
|
||||
var pe *core.PartialDriveFileListError
|
||||
if errors.As(partial, &pe) {
|
||||
return appendDriveFileListFailureItems(nil, resourceID, o.channel(), pe.Failures)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o driveOps) channel() string {
|
||||
if o.region.ConnectorType == types.ConnectorTypeLarkDrive {
|
||||
return types.ChannelLarkDrive
|
||||
}
|
||||
return types.ChannelFeishuDrive
|
||||
}
|
||||
|
||||
func (o driveOps) ResourceNoun() string { return "files" }
|
||||
func (o driveOps) EmptyResourceIDsError() string {
|
||||
return "no resource IDs (Drive folder tokens) configured"
|
||||
}
|
||||
func (o driveOps) LogTag() string { return "[FeishuDrive]" }
|
||||
|
||||
func (o driveOps) DecodeCursorTimes(m map[string]interface{}) map[string]map[string]string {
|
||||
var prev core.FeishuDriveCursor
|
||||
b, _ := json.Marshal(m)
|
||||
_ = json.Unmarshal(b, &prev)
|
||||
return prev.FileTimes
|
||||
}
|
||||
|
||||
func (o driveOps) EncodeCursor(times map[string]map[string]string, lastSync time.Time) *types.SyncCursor {
|
||||
fc := core.FeishuDriveCursor{LastSyncTime: lastSync, FileTimes: times}
|
||||
m := make(map[string]interface{})
|
||||
b, _ := json.Marshal(fc)
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return &types.SyncCursor{LastSyncTime: lastSync, ConnectorCursor: m}
|
||||
}
|
||||
|
||||
// fetchDriveFileContent fetches the content of a single Drive file and converts
|
||||
// it to FetchedItems. Dispatches by file.Type, mirroring the wiki
|
||||
// fetchNodeContent. Shortcuts have already been expanded to their target by
|
||||
// ListDriveFilesRecursiveFrom, so this only sees the target type.
|
||||
//
|
||||
// - docx -> blocks API (Markdown) with export fallback; may return attachments/images
|
||||
// - doc/sheet/bitable -> ExportAndDownload -> docx/xlsx
|
||||
// - file -> DownloadDriveFile -> original file
|
||||
// - mindnote/slides/board -> Skip (no API), returns (nil, nil)
|
||||
func fetchDriveFileContent(
|
||||
ctx context.Context, client *core.Client, file core.DriveFile, resourceID string, multimodalEnabled bool, region core.Region,
|
||||
) ([]*types.FetchedItem, error) {
|
||||
if !core.IsSupportedDocType(file.Type) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
editTime := core.ParseFeishuTimestamp(file.ModifiedTime)
|
||||
// Channel marks the knowledge "source" label. Drive uses its own channel
|
||||
// (feishu_drive / lark_drive) so Drive docs show "飞书云盘" / "Lark 云盘"
|
||||
// distinct from the wiki connector's "飞书".
|
||||
channel := types.ChannelFeishuDrive
|
||||
if region.ConnectorType == types.ConnectorTypeLarkDrive {
|
||||
channel = types.ChannelLarkDrive
|
||||
}
|
||||
baseMeta := map[string]string{
|
||||
"obj_token": file.Token,
|
||||
"obj_type": file.Type,
|
||||
"file_token": file.Token,
|
||||
"folder_token": file.ParentToken,
|
||||
"channel": channel,
|
||||
}
|
||||
|
||||
switch file.Type {
|
||||
case "docx":
|
||||
return core.FetchDocxWithBlocks(ctx, client, core.DocxFetchInput{
|
||||
DocToken: file.Token,
|
||||
ObjToken: file.Token,
|
||||
Title: file.Name,
|
||||
URL: file.URL,
|
||||
ResourceID: resourceID,
|
||||
EditTime: editTime,
|
||||
BaseMeta: baseMeta,
|
||||
MultimodalEnabled: multimodalEnabled,
|
||||
})
|
||||
|
||||
case "doc", "sheet", "bitable":
|
||||
data, fileName, err := client.ExportAndDownload(ctx, file.Token, file.Type)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export %s (%s): %w", file.Name, file.Type, err)
|
||||
}
|
||||
|
||||
ext := core.ExportFileExtToSuffix[core.ObjTypeToExportFileExtension[file.Type]]
|
||||
if fileName == "" {
|
||||
fileName = core.SanitizeFileName(file.Name) + ext
|
||||
} else if !strings.HasSuffix(strings.ToLower(fileName), ext) {
|
||||
fileName = core.SanitizeFileName(fileName) + ext
|
||||
}
|
||||
|
||||
return []*types.FetchedItem{{
|
||||
ExternalID: file.Token,
|
||||
Title: file.Name,
|
||||
Content: data,
|
||||
ContentType: "application/octet-stream",
|
||||
FileName: fileName,
|
||||
URL: file.URL,
|
||||
UpdatedAt: editTime,
|
||||
SourceResourceID: resourceID,
|
||||
Metadata: baseMeta,
|
||||
}}, nil
|
||||
|
||||
case "file":
|
||||
data, err := client.DownloadDriveFile(ctx, file.Token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download file %s (%s): %w", file.Name, file.Token, err)
|
||||
}
|
||||
|
||||
fileName := file.Name
|
||||
if fileName == "" {
|
||||
fileName = file.Token
|
||||
}
|
||||
|
||||
return []*types.FetchedItem{{
|
||||
ExternalID: file.Token,
|
||||
Title: file.Name,
|
||||
Content: data,
|
||||
ContentType: "application/octet-stream",
|
||||
FileName: fileName,
|
||||
URL: file.URL,
|
||||
UpdatedAt: editTime,
|
||||
SourceResourceID: resourceID,
|
||||
Metadata: baseMeta,
|
||||
}}, nil
|
||||
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// makeDriveResourceID encodes a Drive ResourceID: "folderToken" (root) or
|
||||
// "folderToken:fileToken" (child). Reuses core.FeishuWikiNodeResourceSeparator.
|
||||
func makeDriveResourceID(rootFolderToken, fileToken string) string {
|
||||
if fileToken == "" {
|
||||
return rootFolderToken
|
||||
}
|
||||
return rootFolderToken + core.FeishuWikiNodeResourceSeparator + fileToken
|
||||
}
|
||||
|
||||
// parseDriveResourceID splits a Drive resourceID into (rootFolderToken, fileToken).
|
||||
// Mirrors parseWikiResourceID.
|
||||
func parseDriveResourceID(resourceID string) (rootFolderToken, fileToken string) {
|
||||
rootFolderToken, fileToken, _ = strings.Cut(resourceID, core.FeishuWikiNodeResourceSeparator)
|
||||
return rootFolderToken, fileToken
|
||||
}
|
||||
|
||||
// listDriveFilesForResource lists the files to sync for a given resourceID.
|
||||
// A resourceID is either a bare root folderToken (sync the whole subtree) or
|
||||
// "rootFolderToken:fileToken" (sync a single selected file or sub-folder).
|
||||
//
|
||||
// For a single-file selection we cannot pass the fileToken to
|
||||
// ListDriveFilesRecursiveFrom - that API expects a folder and returns 1061002
|
||||
// (params error) for a file token. Instead we walk the root folder subtree (the
|
||||
// file's parent) and filter to just the selected fileToken. This mirrors the
|
||||
// wiki connector, which resolves a single selected node via GetWikiNode; Drive
|
||||
// has no single-file meta API, so filtering the subtree walk is the equivalent.
|
||||
//
|
||||
// A sub-folder selection (fileToken is itself a folder) is handled by walking
|
||||
// that sub-folder's subtree directly - ListDriveFilesRecursiveFrom accepts a
|
||||
// folder token, so no filtering is needed there.
|
||||
func listDriveFilesForResource(
|
||||
ctx context.Context, client *core.Client, resourceID string,
|
||||
) ([]core.DriveFile, error) {
|
||||
rootFolderToken, fileToken := parseDriveResourceID(resourceID)
|
||||
if fileToken == "" {
|
||||
return client.ListDriveFilesRecursiveFrom(ctx, rootFolderToken)
|
||||
}
|
||||
files, err := client.ListDriveFilesRecursiveFrom(ctx, fileToken)
|
||||
if err == nil {
|
||||
return files, nil
|
||||
}
|
||||
if !isDriveNotFolderError(err) {
|
||||
return nil, err
|
||||
}
|
||||
all, walkErr := client.ListDriveFilesRecursiveFrom(ctx, rootFolderToken)
|
||||
if walkErr != nil {
|
||||
var partialErr *core.PartialDriveFileListError
|
||||
if !errors.As(walkErr, &partialErr) {
|
||||
return nil, walkErr
|
||||
}
|
||||
all = filterDriveFileByToken(all, fileToken)
|
||||
if len(all) == 0 {
|
||||
return nil, walkErr
|
||||
}
|
||||
return all, walkErr
|
||||
}
|
||||
return filterDriveFileByToken(all, fileToken), nil
|
||||
}
|
||||
|
||||
// isDriveNotFolderError reports whether err indicates the token was not a
|
||||
// folder (1061002 params error from the list API when a file token is passed).
|
||||
func isDriveNotFolderError(err error) bool {
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "1061002") || strings.Contains(s, "params error")
|
||||
}
|
||||
|
||||
// filterDriveFileByToken returns only the entries whose Token matches token.
|
||||
func filterDriveFileByToken(files []core.DriveFile, token string) []core.DriveFile {
|
||||
var out []core.DriveFile
|
||||
for _, f := range files {
|
||||
if f.Token == token {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// driveRootFolderToken extracts the user-supplied root folder_token from the
|
||||
// data source config (ResourceIDs[0]).
|
||||
func driveRootFolderToken(config *types.DataSourceConfig) string {
|
||||
if config == nil || len(config.ResourceIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
root, _ := parseDriveResourceID(config.ResourceIDs[0])
|
||||
return root
|
||||
}
|
||||
|
||||
// driveFolderToResource builds the root Resource for a Drive folder. The root
|
||||
// folder's name is resolved via GetDriveFolderMeta by the caller (best-effort,
|
||||
// falling back to the token). For sub-folders, use driveFileToResource instead -
|
||||
// the list API returns each child folder's Name.
|
||||
//
|
||||
// The root folder's ExternalID is the bare rootFolderToken (no ":fileToken"
|
||||
// suffix) so it matches the resource_id the user saved in
|
||||
// form.config.resource_ids = [folderToken]. A "token:token" encoding would
|
||||
// break selection matching on edit.
|
||||
func (c *DriveConnector) driveFolderToResource(rootFolderToken, parentToken, folderToken, name string) types.Resource {
|
||||
if name == "" {
|
||||
name = folderToken
|
||||
}
|
||||
return types.Resource{
|
||||
ExternalID: rootFolderToken,
|
||||
Name: name,
|
||||
Type: "drive_folder",
|
||||
URL: c.region.DriveFolderURL(folderToken),
|
||||
HasChildren: true,
|
||||
Metadata: map[string]interface{}{
|
||||
"folder_token": folderToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// driveFileToResource converts a core.DriveFile (list result) into a picker Resource.
|
||||
// The ParentID must match the parent folder's ExternalID: the root folder's
|
||||
// ExternalID is the bare rootFolderToken (see driveFolderToResource), while any
|
||||
// sub-folder's ExternalID is "rootFolderToken:folderToken". Direct children of
|
||||
// the root have file.ParentToken == rootFolderToken, so their ParentID is the
|
||||
// bare rootFolderToken; deeper descendants use the encoded form.
|
||||
func (c *DriveConnector) driveFileToResource(rootFolderToken string, file core.DriveFile) types.Resource {
|
||||
name := file.Name
|
||||
if name == "" {
|
||||
name = file.Token
|
||||
}
|
||||
|
||||
modifiedAt := core.ParseFeishuTimestamp(file.ModifiedTime)
|
||||
|
||||
parentID := makeDriveResourceID(rootFolderToken, file.ParentToken)
|
||||
if file.ParentToken == rootFolderToken || file.ParentToken == "" {
|
||||
// Direct child of the root folder: parent is the root, whose
|
||||
// ExternalID is the bare rootFolderToken (no ":token" suffix).
|
||||
parentID = rootFolderToken
|
||||
}
|
||||
|
||||
return types.Resource{
|
||||
ExternalID: makeDriveResourceID(rootFolderToken, file.Token),
|
||||
Name: name,
|
||||
Type: file.Type,
|
||||
URL: file.URL,
|
||||
ParentID: parentID,
|
||||
HasChildren: file.Type == "folder",
|
||||
ModifiedAt: modifiedAt,
|
||||
Metadata: map[string]interface{}{
|
||||
"file_token": file.Token,
|
||||
"obj_type": file.Type,
|
||||
"folder_token": file.ParentToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// appendDriveFileListFailureItems converts Drive listing failures into error
|
||||
// FetchedItems so the sync log surfaces which sub-folders could not be listed.
|
||||
// Mirrors appendWikiNodeListFailureItems.
|
||||
func appendDriveFileListFailureItems(items []types.FetchedItem, resourceID, channel string, failures []core.DriveFileListFailure) []types.FetchedItem {
|
||||
for _, failure := range failures {
|
||||
items = append(items, types.FetchedItem{
|
||||
ExternalID: failure.FolderToken,
|
||||
Title: failure.FolderToken,
|
||||
SourceResourceID: resourceID,
|
||||
Metadata: core.FeishuErrorItemMeta(failure.Err, map[string]string{
|
||||
"channel": channel,
|
||||
"folder_token": failure.FolderToken,
|
||||
"failure_stage": "list_children",
|
||||
}),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
|
||||
)
|
||||
|
||||
// TestDriveCursorRoundTrip verifies driveOps.EncodeCursor/DecodeCursorTimes
|
||||
// survive a JSON marshal/unmarshal cycle (snapshot isolation), mirroring the
|
||||
// wiki TestFeishuCursorRoundTrip. Design §3.2: driveOps is new code that
|
||||
// previously had no direct test.
|
||||
func TestDriveCursorRoundTrip(t *testing.T) {
|
||||
ops := driveOps{region: core.RegionFeishuDrive}
|
||||
times := map[string]map[string]string{
|
||||
"folder1": {"fdoc1": "100", "fdoc2": "200"},
|
||||
"folder1:fdoc1": {"fdoc1": "100"},
|
||||
"folder2:subA": {"fdoc3": "300"},
|
||||
}
|
||||
cur := ops.EncodeCursor(times, time.Unix(1000, 0))
|
||||
if cur == nil {
|
||||
t.Fatal("EncodeCursor returned nil")
|
||||
}
|
||||
|
||||
got := ops.DecodeCursorTimes(cur.ConnectorCursor)
|
||||
if len(got) != len(times) {
|
||||
t.Fatalf("decoded %d resources, want %d", len(got), len(times))
|
||||
}
|
||||
for rid, files := range times {
|
||||
gotFiles, ok := got[rid]
|
||||
if !ok {
|
||||
t.Errorf("resource %q missing from decoded cursor", rid)
|
||||
continue
|
||||
}
|
||||
for tok, mt := range files {
|
||||
if gotFiles[tok] != mt {
|
||||
t.Errorf("decoded[%q][%q] = %q, want %q", rid, tok, gotFiles[tok], mt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// makeDriveConfig builds a DataSourceConfig for the Drive connector, mirroring
|
||||
// makeConfig but with the drive connector type and a multimodal toggle.
|
||||
func makeDriveConfig(cfg *core.Config, resourceIDs []string, multimodal bool) *types.DataSourceConfig {
|
||||
c := makeConfig(cfg, resourceIDs)
|
||||
c.Type = types.ConnectorTypeFeishuDrive
|
||||
c.MultimodalEnabled = multimodal
|
||||
return c
|
||||
}
|
||||
|
||||
// fakeFeishuDriveDocx serves the Drive list endpoint plus the docx blocks /
|
||||
// media / export endpoints. blocksMode controls the blocks API behaviour:
|
||||
// - "ok": serve the given blocks
|
||||
// - "fail": HTTP 500 (missing scope) → exercises the export fallback
|
||||
// - "empty": serve only a page block → empty Markdown → export fallback
|
||||
//
|
||||
// The export trio is always registered so both fallback modes can complete.
|
||||
func fakeFeishuDriveDocx(t *testing.T, files []core.DriveFile, docToken string,
|
||||
blocks []core.DocxBlock, blocksMode string, mediaContent []byte,
|
||||
) (*httptest.Server, *core.Config) {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, core.TokenResponse{ApiResponse: core.ApiResponse{Code: 0}, TenantAccessToken: "fake-token", Expire: 7200})
|
||||
})
|
||||
|
||||
// Drive file listing (single page).
|
||||
mux.HandleFunc("/open-apis/drive/v1/files", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, core.DriveFileListResponse{
|
||||
ApiResponse: core.ApiResponse{Code: 0},
|
||||
Data: core.DriveFileListData{Files: files},
|
||||
})
|
||||
})
|
||||
|
||||
// Blocks API for the given docx document.
|
||||
blocksPath := "/open-apis/docx/v1/documents/" + docToken + "/blocks"
|
||||
switch blocksMode {
|
||||
case "fail":
|
||||
mux.HandleFunc(blocksPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"code":99991400,"msg":"insufficient scope"}`))
|
||||
})
|
||||
case "empty":
|
||||
mux.HandleFunc(blocksPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, core.DocxBlocksResponse{
|
||||
ApiResponse: core.ApiResponse{Code: 0},
|
||||
Data: core.DocxBlocksData{Items: []core.DocxBlock{{BlockID: "b1", BlockType: core.BlockTypePage}}},
|
||||
})
|
||||
})
|
||||
default: // "ok"
|
||||
mux.HandleFunc(blocksPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, core.DocxBlocksResponse{
|
||||
ApiResponse: core.ApiResponse{Code: 0},
|
||||
Data: core.DocxBlocksData{Items: blocks},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Media download for File/Image block tokens.
|
||||
mux.HandleFunc("/open-apis/drive/v1/medias/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/download") {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(mediaContent)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// Export trio for the fallback path.
|
||||
mux.HandleFunc("/open-apis/drive/v1/export_tasks", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, core.ExportTaskCreateResponse{
|
||||
ApiResponse: core.ApiResponse{Code: 0},
|
||||
Data: core.ExportTaskCreateData{Ticket: "ticket-drv"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/open-apis/drive/v1/export_tasks/ticket-drv", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, core.ExportTaskStatusResponse{
|
||||
ApiResponse: core.ApiResponse{Code: 0},
|
||||
Data: core.ExportTaskStatusData{
|
||||
Result: core.ExportTaskResult{FileToken: "ft-export-drv", FileSize: 512, JobStatus: 0, FileName: "drive-fallback.docx"},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/open-apis/drive/v1/export_tasks/file/ft-export-drv/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write([]byte("fake-drive-export-binary"))
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, &core.Config{AppID: "test-app-id", AppSecret: "test-app-secret", BaseURL: ts.URL}
|
||||
}
|
||||
|
||||
func driveDocxBlocks(attToken, attName string) []core.DocxBlock {
|
||||
return []core.DocxBlock{
|
||||
{BlockID: "b1", BlockType: core.BlockTypePage},
|
||||
{BlockID: "b2", BlockType: core.BlockTypeText, Text: &core.BlockText{
|
||||
Elements: []core.TextElement{{TextRun: &core.TextRun{Content: "Hello drive"}}},
|
||||
}},
|
||||
{BlockID: "b3", BlockType: core.BlockTypeFile, File: &core.BlockFileRef{Token: attToken, Name: attName}},
|
||||
}
|
||||
}
|
||||
|
||||
const driveDocxFileToken = "fdoc1"
|
||||
|
||||
func driveDocxFile() core.DriveFile {
|
||||
return core.DriveFile{
|
||||
Token: driveDocxFileToken,
|
||||
Type: "docx",
|
||||
Name: "Drive Doc",
|
||||
ModifiedTime: "500",
|
||||
ParentToken: "folder1",
|
||||
URL: "https://example.feishu.cn/file/" + driveDocxFileToken,
|
||||
}
|
||||
}
|
||||
|
||||
// A drive docx goes through the blocks path: main Markdown item (external_id =
|
||||
// file token, channel = feishu_drive) plus the attachment sub-item.
|
||||
func TestDriveFetchStream_DocxBlocksMultiItem(t *testing.T) {
|
||||
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
|
||||
const (
|
||||
attToken = "ft-drv-att"
|
||||
attName = "report.pdf"
|
||||
)
|
||||
attContent := bytes.Repeat([]byte("x"), core.MinAttachmentBytes+1)
|
||||
|
||||
_, cfg := fakeFeishuDriveDocx(t, []core.DriveFile{driveDocxFile()}, driveDocxFileToken,
|
||||
driveDocxBlocks(attToken, attName), "ok", attContent)
|
||||
|
||||
c := NewDriveConnector(core.RegionFeishuDrive)
|
||||
h := &recordingHandler{}
|
||||
_, err := c.FetchStream(context.Background(), makeDriveConfig(cfg, []string{"folder1"}, false), nil, h)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchStream() error: %v", err)
|
||||
}
|
||||
|
||||
if len(h.emitted) != 2 {
|
||||
t.Fatalf("expected 2 emitted items (main doc + attachment), got %d: %+v", len(h.emitted), h.emitted)
|
||||
}
|
||||
|
||||
main := h.emitted[0]
|
||||
if main.ExternalID != driveDocxFileToken {
|
||||
t.Errorf("items[0].ExternalID = %q, want %q", main.ExternalID, driveDocxFileToken)
|
||||
}
|
||||
if main.ContentType != "text/markdown" {
|
||||
t.Errorf("items[0].ContentType = %q, want text/markdown", main.ContentType)
|
||||
}
|
||||
if !main.ReplacesSubtree {
|
||||
t.Errorf("items[0].ReplacesSubtree = false, want true")
|
||||
}
|
||||
if main.Metadata["channel"] != types.ChannelFeishuDrive {
|
||||
t.Errorf("items[0].Metadata[channel] = %q, want %q", main.Metadata["channel"], types.ChannelFeishuDrive)
|
||||
}
|
||||
if main.URL != "https://example.feishu.cn/file/"+driveDocxFileToken {
|
||||
t.Errorf("items[0].URL = %q, want drive file URL passthrough", main.URL)
|
||||
}
|
||||
if !strings.Contains(string(main.Content), "Hello drive") {
|
||||
t.Errorf("items[0].Content missing expected text; got %q", string(main.Content))
|
||||
}
|
||||
|
||||
att := h.emitted[1]
|
||||
wantAttID := driveDocxFileToken + "#file#" + attToken
|
||||
if att.ExternalID != wantAttID {
|
||||
t.Errorf("items[1].ExternalID = %q, want %q", att.ExternalID, wantAttID)
|
||||
}
|
||||
if att.Metadata["attachment"] != "true" {
|
||||
t.Errorf("items[1].Metadata[attachment] = %q, want \"true\"", att.Metadata["attachment"])
|
||||
}
|
||||
}
|
||||
|
||||
// Blocks API failure falls back to export: exactly one octet-stream item, no
|
||||
// ReplacesSubtree (must not sweep good prior children on a transient failure).
|
||||
func TestDriveFetchStream_DocxBlocksFailFallsBackToExport(t *testing.T) {
|
||||
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
|
||||
_, cfg := fakeFeishuDriveDocx(t, []core.DriveFile{driveDocxFile()}, driveDocxFileToken, nil, "fail", nil)
|
||||
|
||||
c := NewDriveConnector(core.RegionFeishuDrive)
|
||||
h := &recordingHandler{}
|
||||
_, err := c.FetchStream(context.Background(), makeDriveConfig(cfg, []string{"folder1"}, false), nil, h)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchStream() error: %v", err)
|
||||
}
|
||||
|
||||
if len(h.emitted) != 1 {
|
||||
t.Fatalf("expected 1 emitted item (export fallback), got %d: %+v", len(h.emitted), h.emitted)
|
||||
}
|
||||
item := h.emitted[0]
|
||||
if item.ExternalID != driveDocxFileToken {
|
||||
t.Errorf("item.ExternalID = %q, want %q", item.ExternalID, driveDocxFileToken)
|
||||
}
|
||||
if item.ContentType != "application/octet-stream" {
|
||||
t.Errorf("item.ContentType = %q, want application/octet-stream", item.ContentType)
|
||||
}
|
||||
if !strings.HasSuffix(item.FileName, ".docx") {
|
||||
t.Errorf("item.FileName = %q, want .docx suffix", item.FileName)
|
||||
}
|
||||
if item.ReplacesSubtree {
|
||||
t.Error("export-fallback item must not set ReplacesSubtree")
|
||||
}
|
||||
}
|
||||
|
||||
// Blocks rendering to empty Markdown also falls back to export (a blank page
|
||||
// would otherwise ingest as a login-gated URL core.Fetch and fail).
|
||||
func TestDriveFetchStream_DocxBlocksEmptyFallsBackToExport(t *testing.T) {
|
||||
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
|
||||
_, cfg := fakeFeishuDriveDocx(t, []core.DriveFile{driveDocxFile()}, driveDocxFileToken, nil, "empty", nil)
|
||||
|
||||
c := NewDriveConnector(core.RegionFeishuDrive)
|
||||
h := &recordingHandler{}
|
||||
_, err := c.FetchStream(context.Background(), makeDriveConfig(cfg, []string{"folder1"}, false), nil, h)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchStream() error: %v", err)
|
||||
}
|
||||
|
||||
if len(h.emitted) != 1 {
|
||||
t.Fatalf("expected 1 emitted item (export fallback), got %d: %+v", len(h.emitted), h.emitted)
|
||||
}
|
||||
item := h.emitted[0]
|
||||
if item.ContentType != "application/octet-stream" {
|
||||
t.Errorf("item.ContentType = %q, want application/octet-stream", item.ContentType)
|
||||
}
|
||||
if item.ReplacesSubtree {
|
||||
t.Error("export-fallback item must not set ReplacesSubtree")
|
||||
}
|
||||
}
|
||||
|
||||
// With multimodal disabled, an embedded image is not downloaded or emitted,
|
||||
// but its external_id is still in SubtreeKeep so a later toggle-on does not
|
||||
// sweep it, and toggling VLM off later does not delete previously OCR'd images.
|
||||
func TestDriveFetchStream_DocxImageMultimodalOff(t *testing.T) {
|
||||
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
|
||||
const imgToken = "img-drv-1"
|
||||
blocks := []core.DocxBlock{
|
||||
{BlockID: "b1", BlockType: core.BlockTypePage},
|
||||
{BlockID: "b2", BlockType: core.BlockTypeText, Text: &core.BlockText{
|
||||
Elements: []core.TextElement{{TextRun: &core.TextRun{Content: "has image"}}},
|
||||
}},
|
||||
{BlockID: "b3", BlockType: core.BlockTypeImage, Image: &core.BlockTokenRef{Token: imgToken}},
|
||||
}
|
||||
_, cfg := fakeFeishuDriveDocx(t, []core.DriveFile{driveDocxFile()}, driveDocxFileToken, blocks, "ok", nil)
|
||||
|
||||
c := NewDriveConnector(core.RegionFeishuDrive)
|
||||
h := &recordingHandler{}
|
||||
_, err := c.FetchStream(context.Background(), makeDriveConfig(cfg, []string{"folder1"}, false), nil, h)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchStream() error: %v", err)
|
||||
}
|
||||
|
||||
if len(h.emitted) != 1 {
|
||||
t.Fatalf("expected 1 emitted item (main only, image core.Skipped), got %d: %+v", len(h.emitted), h.emitted)
|
||||
}
|
||||
main := h.emitted[0]
|
||||
wantKeep := driveDocxFileToken + "#image#" + imgToken
|
||||
found := false
|
||||
for _, k := range main.SubtreeKeep {
|
||||
if k == wantKeep {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("SubtreeKeep = %v, want it to contain %q", main.SubtreeKeep, wantKeep)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv("SSRF_WHITELIST", "127.0.0.1,localhost")
|
||||
secutils.ResetSSRFWhitelistForTest()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func makeConfig(cfg *core.Config, resourceIDs []string) *types.DataSourceConfig {
|
||||
creds := map[string]interface{}{
|
||||
"app_id": cfg.AppID,
|
||||
"app_secret": cfg.AppSecret,
|
||||
"base_url": cfg.BaseURL,
|
||||
}
|
||||
return &types.DataSourceConfig{
|
||||
Type: types.ConnectorTypeFeishu,
|
||||
Credentials: creds,
|
||||
ResourceIDs: resourceIDs,
|
||||
}
|
||||
}
|
||||
|
||||
type recordingHandler struct {
|
||||
emitted []types.FetchedItem
|
||||
checkpoints []core.FeishuDriveCursor
|
||||
emitErr func(item types.FetchedItem) error
|
||||
}
|
||||
|
||||
func (h *recordingHandler) Emit(_ context.Context, item types.FetchedItem) error {
|
||||
if h.emitErr != nil {
|
||||
if err := h.emitErr(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
h.emitted = append(h.emitted, item)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *recordingHandler) Checkpoint(_ context.Context, cursor *types.SyncCursor) error {
|
||||
var fc core.FeishuDriveCursor
|
||||
b, _ := json.Marshal(cursor.ConnectorCursor)
|
||||
_ = json.Unmarshal(b, &fc)
|
||||
h.checkpoints = append(h.checkpoints, fc)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user