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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+403
View File
@@ -0,0 +1,403 @@
// Package ima implements the WeKnora data source connector for Tencent IMA
// (ima.qq.com), syncing documents and files out of IMA knowledge bases via the
// /openapi/wiki/v1 OpenAPI.
package ima
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/utils"
)
const (
defaultTimeout = 60 * time.Second
defaultPageSize = 50
userAgent = "WeKnora-IMA-Connector/1.0"
// IMA hard limit: get_knowledge_list max limit=50; search_knowledge_base max=20.
searchPageSize = 20
// Default per-item download timeout — IMA COS URLs can be slow for large PDFs.
downloadTimeout = 120 * time.Second
// Max body we accept from get_media_info-URLs. 200MB matches IMA's largest
maxDownloadBytes = 200 * 1024 * 1024
)
// client wraps the IMA OpenAPI. It is safe for concurrent use.
type client struct {
baseURL string
clientID string
apiKey string
httpClient *http.Client
// downloadClient is a separate client with a longer timeout for pulling
// media bodies from COS/CDN URLs returned by get_media_info.
downloadClient *http.Client
logCredentialOnce sync.Once
}
// newClient constructs a client with a normalized base URL.
func newClient(cfg *Config) *client {
return &client{
baseURL: cfg.GetBaseURL(),
clientID: cfg.ClientID,
apiKey: cfg.APIKey,
httpClient: datasource.NewConnectorHTTPClient(defaultTimeout),
downloadClient: datasource.NewConnectorHTTPClient(downloadTimeout),
}
}
// callAPI executes an authenticated POST to /openapi/wiki/v1/<action>.
func (c *client) callAPI(ctx context.Context, action string, req interface{}, result interface{}) error {
return c.callAPIAt(ctx, apiBasePath, action, req, result)
}
// callAPIAt executes an authenticated POST to <basePath>/<action>. It parses
// the standard {code, msg, data} envelope and unmarshals `data` into `result`.
// A non-zero business code is returned as a plain error so the caller can
// surface `msg` to the user.
//
// basePath is a parameter because notes live under a second namespace
// (/openapi/note/v1) that shares this authentication and envelope.
func (c *client) callAPIAt(
ctx context.Context, basePath, action string, req interface{}, result interface{},
) error {
const (
maxRetries = 3
max5xxRetries = 1
retry5xxDelay = 2 * time.Second
)
backoff := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
c.logCredentialOnce.Do(func() {
logger.Infof(ctx, "[IMA] client configured client_id=%s api_key=%s base=%s",
redact(c.clientID), redact(c.apiKey), c.baseURL)
})
var body []byte
if req != nil {
b, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
body = b
} else {
body = []byte("{}")
}
url := c.baseURL + basePath + "/" + action
// Both namespaces expose distinct actions, but log the full path so the two
// are never ambiguous.
label := basePath + "/" + action
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("ima-openapi-clientid", c.clientID)
httpReq.Header.Set("ima-openapi-apikey", c.apiKey)
httpReq.Header.Set("Content-Type", "application/json; charset=utf-8")
httpReq.Header.Set("User-Agent", userAgent)
if attempt == 0 {
logger.Infof(ctx, "[IMA] POST %s", label)
} else {
logger.Infof(ctx, "[IMA] POST %s (retry %d/%d)", label, attempt, maxRetries)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
lastErr = fmt.Errorf("execute request: %w", err)
if attempt < maxRetries {
if sErr := sleepCtx(ctx, backoff[attempt]); sErr != nil {
return sErr
}
continue
}
return lastErr
}
respBody, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
lastErr = fmt.Errorf("read response: %w", readErr)
if attempt < maxRetries {
if sErr := sleepCtx(ctx, backoff[attempt]); sErr != nil {
return sErr
}
continue
}
return lastErr
}
// The body is only ever logged on an error path: successful responses
// (get_media_info in particular) carry pre-signed storage URLs and
// per-request auth headers that must not land in the server log.
bodyPreview := truncate(string(respBody), 500)
logger.Infof(ctx, "[IMA] POST %s → status=%d bodyLen=%d", label, resp.StatusCode, len(respBody))
// HTTP-level auth failures: surface as ErrInvalidCredentials.
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%w: status=%d body=%s",
datasource.ErrInvalidCredentials, resp.StatusCode, bodyPreview)
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := backoff[minInt(attempt, len(backoff)-1)]
lastErr = fmt.Errorf("ima rate limited: status=429 body=%s", bodyPreview)
if attempt < maxRetries {
if sErr := sleepCtx(ctx, wait); sErr != nil {
return sErr
}
continue
}
return lastErr
}
if resp.StatusCode >= 500 && resp.StatusCode < 600 {
lastErr = fmt.Errorf("ima server error: status=%d body=%s", resp.StatusCode, bodyPreview)
if attempt < max5xxRetries {
if sErr := sleepCtx(ctx, retry5xxDelay); sErr != nil {
return sErr
}
continue
}
return lastErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ima api http error: status=%d body=%s", resp.StatusCode, bodyPreview)
}
var env apiEnvelope
if err := json.Unmarshal(respBody, &env); err != nil {
return fmt.Errorf("decode envelope: %w", err)
}
// Business-level errors.
// 110030 (无权限) is mapped to ErrInvalidCredentials so the service marks
// the source in `error` state and stops scheduling until re-authenticated.
// 110021 (限频) is retried. Everything else — including 110001 (参数非法),
// which is a bug on our side rather than a credential problem — surfaces
// verbatim so the user sees IMA's own message.
if code := env.statusCode(); code != 0 {
if code == 110030 {
return fmt.Errorf("%w: ima code=%d msg=%s",
datasource.ErrInvalidCredentials, code, env.message())
}
if code == 110021 && attempt < maxRetries {
lastErr = fmt.Errorf("ima rate limited: code=%d msg=%s", code, env.message())
if sErr := sleepCtx(ctx, backoff[minInt(attempt, len(backoff)-1)]); sErr != nil {
return sErr
}
continue
}
return fmt.Errorf("ima api error: code=%d msg=%s", code, env.message())
}
if result != nil && len(env.Data) > 0 && string(env.Data) != "null" {
if err := json.Unmarshal(env.Data, result); err != nil {
return fmt.Errorf("decode data: %w", err)
}
}
return nil
}
return lastErr
}
// SearchKnowledgeBase — POST /search_knowledge_base. Empty query returns the
// list of every knowledge base visible to the current token (see api.md §7).
func (c *client) SearchKnowledgeBase(
ctx context.Context, query, cursor string, limit int,
) (*searchKnowledgeBaseResp, error) {
if limit <= 0 || limit > searchPageSize {
limit = searchPageSize
}
req := map[string]interface{}{
"query": query,
"cursor": cursor,
"limit": uint64(limit),
}
var resp searchKnowledgeBaseResp
if err := c.callAPI(ctx, "search_knowledge_base", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetKnowledgeBase — POST /get_knowledge_base for a batch of ids (1-20).
func (c *client) GetKnowledgeBase(ctx context.Context, ids []string) (map[string]knowledgeBaseInfo, error) {
if len(ids) == 0 {
return map[string]knowledgeBaseInfo{}, nil
}
req := map[string]interface{}{"ids": ids}
var resp getKnowledgeBaseResp
if err := c.callAPI(ctx, "get_knowledge_base", req, &resp); err != nil {
return nil, err
}
if resp.Infos == nil {
resp.Infos = map[string]knowledgeBaseInfo{}
}
return resp.Infos, nil
}
// GetAddableKnowledgeBaseList — POST /get_addable_knowledge_base_list.
// Lists knowledge bases the current token has permission to add content to.
// This is the authoritative "what KBs can this credential see?" endpoint
// (see api.md §7). Used as the primary enumeration source in ListResources —
// search_knowledge_base with empty query has been observed to return an empty
// list even when the token owns KBs, so we prefer this endpoint.
func (c *client) GetAddableKnowledgeBaseList(
ctx context.Context, cursor string, limit int,
) (*getAddableKnowledgeBaseListResp, error) {
if limit <= 0 || limit > defaultPageSize {
limit = defaultPageSize
}
req := map[string]interface{}{
"cursor": cursor,
"limit": uint64(limit),
}
var resp getAddableKnowledgeBaseListResp
if err := c.callAPI(ctx, "get_addable_knowledge_base_list", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetKnowledgeList — POST /get_knowledge_list. folderID may be empty for root.
func (c *client) GetKnowledgeList(
ctx context.Context, kbID, folderID, cursor string, limit int,
) (*getKnowledgeListResp, error) {
if limit <= 0 || limit > defaultPageSize {
limit = defaultPageSize
}
req := map[string]interface{}{
"cursor": cursor,
"limit": uint64(limit),
"knowledge_base_id": kbID,
}
if folderID != "" {
req["folder_id"] = folderID
}
var resp getKnowledgeListResp
if err := c.callAPI(ctx, "get_knowledge_list", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetMediaInfo — POST /get_media_info. Returns URL access info and (for notes)
// the notebook_id under notebook_ext_info.
func (c *client) GetMediaInfo(ctx context.Context, mediaID string) (*getMediaInfoResp, error) {
req := map[string]interface{}{"media_id": mediaID}
var resp getMediaInfoResp
if err := c.callAPI(ctx, "get_media_info", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetNoteContent — POST /openapi/note/v1/get_doc_content. Returns an IMA note's
// body as plain text (target_content_format=0).
//
// noteID is the notebook_id that get_media_info reports for a note: the wiki
// namespace never exposes note bodies, it only points at them.
func (c *client) GetNoteContent(ctx context.Context, noteID string) (string, error) {
req := map[string]interface{}{
"note_id": noteID,
"target_content_format": 0,
}
var resp getDocContentResp
if err := c.callAPIAt(ctx, noteBasePath, "get_doc_content", req, &resp); err != nil {
return "", err
}
return resp.Content, nil
}
// DownloadURL fetches a URL returned by GetMediaInfo, respecting the auth
// headers IMA may return alongside it. Enforces maxDownloadBytes to avoid a
// runaway body blowing up sync memory.
func (c *client) DownloadURL(ctx context.Context, u urlInfo) ([]byte, string, error) {
if u.URL == "" {
return nil, "", fmt.Errorf("empty url")
}
// The URL comes from an API response, so it is attacker-influenced as far
// as this process is concerned. downloadClient already guards the dial and
// every redirect hop; this rejects an unsafe target before the first
// connection is even attempted.
if err := utils.ValidateURLForSSRF(u.URL); err != nil {
return nil, "", fmt.Errorf("media URL rejected: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.URL, nil)
if err != nil {
return nil, "", fmt.Errorf("create download request: %w", err)
}
for k, v := range u.Headers {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", userAgent)
resp, err := c.downloadClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("download http error: status=%d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadBytes+1))
if err != nil {
return nil, "", fmt.Errorf("read download body: %w", err)
}
if int64(len(body)) > maxDownloadBytes {
return nil, "", fmt.Errorf("download body exceeds %d bytes", maxDownloadBytes)
}
return body, resp.Header.Get("Content-Type"), nil
}
// sleepCtx pauses for d, returning early if ctx is cancelled.
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// redact returns a masked form of a credential for logging (never log full).
func redact(t string) string {
if len(t) < 12 {
return "***"
}
return t[:6] + "..." + t[len(t)-4:]
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,612 @@
package ima
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// Compile-time proof that *Connector satisfies the datasource.Connector interface.
var _ datasource.Connector = (*Connector)(nil)
// Connector implements datasource.Connector for Tencent IMA (ima.qq.com).
type Connector struct{}
// NewConnector creates a new IMA connector.
func NewConnector() *Connector { return &Connector{} }
// Type returns the connector type identifier.
func (c *Connector) Type() string { return types.ConnectorTypeIMA }
// Validate verifies the credentials by calling get_addable_knowledge_base_list
// — the endpoint most likely to succeed even when the token has zero KBs, and
// the same one ListResources uses. It returns 110030 (无权限) when the token
// itself is invalid, which client.callAPI already maps to ErrInvalidCredentials.
func (c *Connector) Validate(ctx context.Context, config *types.DataSourceConfig) error {
cfg, err := parseIMAConfig(config)
if err != nil {
return err
}
cli := newClient(cfg)
if _, err := cli.GetAddableKnowledgeBaseList(ctx, "", defaultPageSize); err != nil {
return fmt.Errorf("ima connection failed: %w", err)
}
return nil
}
// ResolveResourceAncestors returns nothing: IMA knowledge bases are exposed as
// a flat list of top-level resources, so a lazy-loaded picker has no ancestors
// to reveal.
func (c *Connector) ResolveResourceAncestors(
_ context.Context, _ *types.DataSourceConfig, _ []string,
) ([]string, error) {
return []string{}, nil
}
// ListResources returns the flat list of knowledge bases the token can read.
// parentID is honoured only for the "no children" contract — see the note on
// Connector.ListResources in internal/datasource/connector.go.
//
// Primary source is get_addable_knowledge_base_list, which returns the KBs the
// current OpenAPI credential has permission to operate on. When that endpoint
// returns an empty list (e.g. a legacy tenant only exposes read scopes) we
// fall back to search_knowledge_base with an empty query so users still see
// something to pick.
func (c *Connector) ListResources(
ctx context.Context, config *types.DataSourceConfig, parentID string,
) ([]types.Resource, error) {
if parentID != "" {
return []types.Resource{}, nil
}
cfg, err := parseIMAConfig(config)
if err != nil {
return nil, err
}
cli := newClient(cfg)
type kbLite struct {
ID string
Name string
CoverURL string
}
var bases []kbLite
cursor := ""
for {
resp, err := cli.GetAddableKnowledgeBaseList(ctx, cursor, defaultPageSize)
if err != nil {
return nil, fmt.Errorf("get_addable_knowledge_base_list: %w", err)
}
for _, b := range resp.AddableKnowledgeBaseList {
bases = append(bases, kbLite{ID: b.ID, Name: b.Name})
}
if resp.IsEnd || resp.NextCursor == "" {
break
}
cursor = resp.NextCursor
}
logger.Infof(ctx, "[IMA] get_addable_knowledge_base_list returned %d knowledge bases", len(bases))
if len(bases) == 0 {
cursor = ""
for {
resp, err := cli.SearchKnowledgeBase(ctx, "", cursor, searchPageSize)
if err != nil {
return nil, fmt.Errorf("search_knowledge_base fallback: %w", err)
}
for _, b := range resp.InfoList {
bases = append(bases, kbLite(b))
}
if resp.IsEnd || resp.NextCursor == "" {
break
}
cursor = resp.NextCursor
}
logger.Infof(ctx, "[IMA] search_knowledge_base fallback returned %d knowledge bases", len(bases))
}
ids := make([]string, 0, len(bases))
for _, b := range bases {
ids = append(ids, b.ID)
}
details := map[string]knowledgeBaseInfo{}
for i := 0; i < len(ids); i += 20 {
end := i + 20
if end > len(ids) {
end = len(ids)
}
batch, err := cli.GetKnowledgeBase(ctx, ids[i:end])
if err != nil {
logger.Warnf(ctx, "[IMA] get_knowledge_base batch failed (skipping enrichment): %v", err)
continue
}
for k, v := range batch {
details[k] = v
}
}
out := make([]types.Resource, 0, len(bases))
for _, b := range bases {
desc := ""
coverURL := b.CoverURL
if d, ok := details[b.ID]; ok {
desc = d.Description
if coverURL == "" {
coverURL = d.CoverURL
}
}
out = append(out, types.Resource{
ExternalID: b.ID,
Name: b.Name,
Type: "knowledge_base",
Description: desc,
URL: cfg.GetBaseURL(),
Metadata: map[string]interface{}{
"cover_url": coverURL,
},
})
}
sort.Slice(out, func(i, j int) bool { return out[i].ExternalID < out[j].ExternalID })
logger.Infof(ctx, "[IMA] ListResources returning %d knowledge bases to UI", len(out))
return out, nil
}
// FetchAll performs a full sync of every knowledge base in resourceIDs.
func (c *Connector) FetchAll(
ctx context.Context, config *types.DataSourceConfig, resourceIDs []string,
) ([]types.FetchedItem, error) {
items, _, err := c.walk(ctx, config, resourceIDs, nil, false)
return items, err
}
// FetchIncremental syncs new / replaced / removed items against the cursor.
//
// Each IMA item is identified by a stable logical_key (see logicalKey), NOT by
// media_id — because IMA reassigns media_id whenever a same-named file is
// replaced in place, and we want that replacement to surface as an UPDATE to
// the same knowledge item rather than a delete-plus-insert pair.
//
// Emitted this cycle:
//
// - logical_key new since last sync → add (new content)
// - logical_key present, media_id changed → update (re-fetched content;
// ingest layer's existing
// external_id match triggers
// delete-and-recreate)
// - logical_key unchanged, media_id unchanged → skip
// - logical_key present last sync, absent this sync → IsDeleted tombstone
//
// LIMITATION: IMA still exposes no per-item updated_at, so an in-place edit
// that keeps the SAME media_id (same file bytes replaced under the hood by
// IMA, if that ever happens) is invisible to us. Users needing a full content
// refresh should periodically run a Full sync.
func (c *Connector) FetchIncremental(
ctx context.Context, config *types.DataSourceConfig, cursor *types.SyncCursor,
) ([]types.FetchedItem, *types.SyncCursor, error) {
resourceIDs := config.ResourceIDs
if len(resourceIDs) == 0 {
return nil, nil, fmt.Errorf("no resource IDs (knowledge base IDs) configured")
}
var prev *imaCursor
if cursor != nil && cursor.ConnectorCursor != nil {
var p imaCursor
b, _ := json.Marshal(cursor.ConnectorCursor)
_ = json.Unmarshal(b, &p)
prev = &p
}
items, newCursor, err := c.walk(ctx, config, resourceIDs, prev, true)
if err != nil {
return nil, nil, err
}
cursorMap := make(map[string]interface{})
b, _ := json.Marshal(newCursor)
_ = json.Unmarshal(b, &cursorMap)
return items, &types.SyncCursor{
LastSyncTime: newCursor.LastSyncTime,
ConnectorCursor: cursorMap,
}, nil
}
// walk is the shared implementation for FetchAll / FetchIncremental.
// When incremental is false, prev is ignored and returned cursor is nil.
func (c *Connector) walk(
ctx context.Context,
config *types.DataSourceConfig,
resourceIDs []string,
prev *imaCursor,
incremental bool,
) ([]types.FetchedItem, *imaCursor, error) {
cfg, err := parseIMAConfig(config)
if err != nil {
return nil, nil, err
}
cli := newClient(cfg)
newCursor := &imaCursor{
LastSyncTime: time.Now(),
KBLogical: make(map[string]map[string]string),
}
var out []types.FetchedItem
for _, kbID := range resourceIDs {
files, folderPath, err := listAllKBFiles(ctx, cli, kbID)
if err != nil {
return nil, nil, fmt.Errorf("list KB %s: %w", kbID, err)
}
// present holds every logical_key IMA listed this cycle; it drives
// delete detection. syncedLogical holds only the keys we actually
// resolved this cycle and is what gets persisted as the cursor, so a
// transient failure is retried next run rather than being remembered
// as "unchanged" forever.
present := make(map[string]struct{}, len(files))
syncedLogical := make(map[string]string, len(files))
keys := make([]string, len(files))
for i, f := range files {
key := logicalKey(kbID, f.ParentFolderID, f.Title)
keys[i] = key
present[key] = struct{}{}
}
newCursor.KBLogical[kbID] = syncedLogical
var replaced, failed int
for i, f := range files {
key := keys[i]
// Incremental skip / replacement detection.
if incremental && prev != nil && prev.KBLogical != nil {
if prevSet, ok := prev.KBLogical[kbID]; ok {
if prevMediaID, seen := prevSet[key]; seen {
if prevMediaID == f.MediaID {
// Unchanged: carry the entry forward so it is not
// re-fetched next cycle either.
syncedLogical[key] = f.MediaID
continue
}
replaced++ // media_id changed → replacement, fall through to re-fetch
}
}
}
item, outcome := fetchOneMedia(ctx, cli, kbID, key, f, folderPath)
switch outcome {
case fetchOK:
syncedLogical[key] = f.MediaID
out = append(out, item)
case fetchSkipped:
// Deterministic skip (unsupported media type, no URL): record it
// so we do not pay for get_media_info on every future sync.
syncedLogical[key] = f.MediaID
case fetchFailed:
// Transient: leave the key out of the cursor so the next run
// retries. `present` still contains it, so it is not mistaken
// for a deletion.
failed++
}
}
// Deletion detection (incremental only) — based on logical_key vanishing
// from the listing, so neither a same-name replacement (media_id churns
// but logical_key stays) nor a failed download fires a spurious delete.
deleted := 0
if incremental && prev != nil && prev.KBLogical != nil {
if prevSet, ok := prev.KBLogical[kbID]; ok {
for prevKey := range prevSet {
if _, still := present[prevKey]; !still {
out = append(out, types.FetchedItem{
ExternalID: prevKey,
IsDeleted: true,
SourceResourceID: kbID,
})
deleted++
}
}
}
}
logger.Infof(ctx, "[IMA] KB %s: total=%d replaced=%d failed=%d deleted=%d",
kbID, len(files), replaced, failed, deleted)
}
if !incremental {
return out, nil, nil
}
return out, newCursor, nil
}
// walkedFile is an internal representation of a knowledge item after we've
// resolved its folder location within the KB tree.
type walkedFile struct {
knowledgeInfo
// FolderPath is the "/A/B" style path (folder names, no leading slash)
// of the containing folder — empty for root-level items.
FolderPath string
}
// listAllKBFiles recursively enumerates a knowledge base into a flat list of
// files, resolving each item's folder path from the on-the-fly folder tree.
// folderPath maps folder_id → readable path (for metadata).
func listAllKBFiles(
ctx context.Context, cli *client, kbID string,
) ([]walkedFile, map[string]string, error) {
folderPath := map[string]string{}
var out []walkedFile
// BFS/DFS over folders. Start from root (empty folder_id).
type todo struct {
folderID string
path string
}
stack := []todo{{folderID: "", path: ""}}
for len(stack) > 0 {
cur := stack[len(stack)-1]
stack = stack[:len(stack)-1]
cursor := ""
for {
resp, err := cli.GetKnowledgeList(ctx, kbID, cur.folderID, cursor, defaultPageSize)
if err != nil {
return nil, nil, err
}
for _, raw := range resp.KnowledgeList {
// Probe each entry: an entry with a non-empty folder_id is a
// folder; otherwise it's a knowledge item (file / note / etc.).
var probe struct {
FolderID string `json:"folder_id"`
MediaID string `json:"media_id"`
}
_ = json.Unmarshal(raw, &probe)
if probe.FolderID != "" && probe.MediaID == "" {
var fi folderInfo
if err := json.Unmarshal(raw, &fi); err != nil {
continue
}
child := cur.path
if child == "" {
child = fi.Name
} else {
child = cur.path + "/" + fi.Name
}
folderPath[fi.FolderID] = child
stack = append(stack, todo{folderID: fi.FolderID, path: child})
continue
}
if probe.MediaID == "" {
continue // unrecognized shape, skip defensively
}
var ki knowledgeInfo
if err := json.Unmarshal(raw, &ki); err != nil {
continue
}
out = append(out, walkedFile{
knowledgeInfo: ki,
FolderPath: cur.path,
})
}
if resp.IsEnd || resp.NextCursor == "" {
break
}
cursor = resp.NextCursor
}
}
return out, folderPath, nil
}
// fetchNote resolves an IMA note (media_type=11). Notes carry no downloadable
// body: get_media_info only reports the notebook_id, and the text has to be
// read from the separate note namespace.
//
// The body arrives as plain text but is ingested as Markdown, because IMA notes
// are authored as rich text and the export keeps its heading and list markers —
// parsing it as Markdown preserves that structure for chunking, and text with
// no markers is unaffected.
func fetchNote(
ctx context.Context, cli *client,
kbID string, externalID string, f walkedFile, folderPath map[string]string,
info *getMediaInfoResp,
) (types.FetchedItem, fetchOutcome) {
noteID := info.NotebookExtInfo.NotebookID
if noteID == "" {
logger.Warnf(ctx, "[IMA] note %s (title=%q) has no notebook_id, skipping", f.MediaID, f.Title)
return types.FetchedItem{}, fetchSkipped
}
content, err := cli.GetNoteContent(ctx, noteID)
if err != nil {
logger.Warnf(ctx, "[IMA] get_doc_content(note %s, title=%q) failed, will retry next sync: %v",
noteID, f.Title, err)
return types.FetchedItem{}, fetchFailed
}
if strings.TrimSpace(content) == "" {
logger.Infof(ctx, "[IMA] note %s (title=%q) is empty, skipping", noteID, f.Title)
return types.FetchedItem{}, fetchSkipped
}
fileName := sanitizeFileName(f.Title)
if !strings.HasSuffix(strings.ToLower(fileName), ".md") {
fileName += ".md"
}
return types.FetchedItem{
ExternalID: externalID,
Title: f.Title,
Content: []byte(content),
ContentType: "text/markdown",
FileName: fileName,
SourceResourceID: kbID,
UpdatedAt: time.Now(),
Metadata: baseMetadata(externalID, f, folderPath, info, kbID),
}, fetchOK
}
// fetchOutcome distinguishes the three ways fetchOneMedia can end, because the
// caller has to treat them differently when building the sync cursor: only a
// transient failure must stay out of the cursor so it is retried next run.
type fetchOutcome int
const (
// fetchOK: the item was resolved and should be ingested.
fetchOK fetchOutcome = iota
// fetchSkipped: IMA cannot give us this item's content and never will
// (unsupported media type, no URL). Deterministic, so it is safe to
// remember in the cursor and stop re-probing it.
fetchSkipped
// fetchFailed: a transient error (API call or download). Must be retried.
fetchFailed
)
// fetchOneMedia calls get_media_info for a single item and, when possible,
// downloads its body.
//
// externalID is the stable logical_key computed by the caller — same value
// across syncs even after an IMA same-name replacement mints a new media_id.
// The raw media_id is still preserved in metadata for observability.
func fetchOneMedia(
ctx context.Context, cli *client,
kbID string, externalID string, f walkedFile, folderPath map[string]string,
) (types.FetchedItem, fetchOutcome) {
info, err := cli.GetMediaInfo(ctx, f.MediaID)
if err != nil {
logger.Warnf(ctx, "[IMA] get_media_info(%s) failed, will retry next sync: %v", f.MediaID, err)
return types.FetchedItem{}, fetchFailed
}
if info.MediaType == mediaTypeNote {
return fetchNote(ctx, cli, kbID, externalID, f, folderPath, info)
}
if isSkippableMediaType(info.MediaType) {
logger.Infof(ctx, "[IMA] skip media %s (title=%q media_type=%d): unsupported by the IMA OpenAPI",
f.MediaID, f.Title, info.MediaType)
return types.FetchedItem{}, fetchSkipped
}
if info.URLInfo.URL == "" {
logger.Warnf(ctx, "[IMA] media %s (title=%q) has no url_info.url, skipping", f.MediaID, f.Title)
return types.FetchedItem{}, fetchSkipped
}
ext := extensionForMediaType(info.MediaType)
// A media type with no fixed extension (web page, WeChat article, ...) is
// normally handed to the ingest layer as a bare URL so WeKnora fetches it
// itself. That only works for publicly reachable links: when IMA attaches
// auth headers the URL points at IMA-hosted storage and WeKnora's own
// fetch — which cannot carry those headers — would be rejected. In that
// case download here, where the headers are available, and infer the
// extension from the response instead.
if ext == "" {
if len(info.URLInfo.Headers) == 0 {
return types.FetchedItem{
ExternalID: externalID,
Title: f.Title,
URL: info.URLInfo.URL,
SourceResourceID: kbID,
UpdatedAt: time.Now(),
Metadata: baseMetadata(externalID, f, folderPath, info, kbID),
}, fetchOK
}
ext = "html"
}
body, ct, err := cli.DownloadURL(ctx, info.URLInfo)
if err != nil {
logger.Warnf(ctx, "[IMA] download media %s (title=%q) failed, will retry next sync: %v",
f.MediaID, f.Title, err)
return types.FetchedItem{}, fetchFailed
}
// IMA labels every image media_type=9, so trust the response's own
// Content-Type over the media_type default when it names a real format.
if detected := extensionForContentType(ct); detected != "" {
ext = detected
}
if ct == "" || strings.HasPrefix(ct, "application/octet-stream") {
ct = mimeForExtension(ext)
}
fileName := sanitizeFileName(f.Title)
if !strings.HasSuffix(strings.ToLower(fileName), "."+ext) {
fileName = fileName + "." + ext
}
return types.FetchedItem{
ExternalID: externalID,
Title: f.Title,
Content: body,
ContentType: ct,
FileName: fileName,
URL: info.URLInfo.URL,
UpdatedAt: time.Now(),
SourceResourceID: kbID,
Metadata: baseMetadata(externalID, f, folderPath, info, kbID),
}, fetchOK
}
// baseMetadata builds the metadata map preserved on every ingested item.
// externalID (the caller's logical_key) is stored so operators can join the
// stable identity back to the raw media_id — useful when debugging why a
// same-name replacement showed up as an update instead of an add.
func baseMetadata(
externalID string, f walkedFile, folderPath map[string]string,
info *getMediaInfoResp, kbID string,
) map[string]string {
m := map[string]string{
"channel": types.ChannelIMA,
"media_id": f.MediaID,
"ima_logical_key": externalID,
"knowledge_base_id": kbID,
"folder_path": f.FolderPath,
"media_type": fmt.Sprintf("%d", info.MediaType),
}
if f.ParentFolderID != "" {
m["parent_folder_id"] = f.ParentFolderID
}
if fp, ok := folderPath[f.ParentFolderID]; ok && fp != "" {
m["folder_path"] = fp
}
if info.NotebookExtInfo.NotebookID != "" {
m["notebook_id"] = info.NotebookExtInfo.NotebookID
}
return m
}
// sanitizeFileName removes filesystem-hostile characters and truncates to a
// safe UTF-8 boundary.
func sanitizeFileName(name string) string {
if name == "" {
return "untitled"
}
replacer := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_", "*", "_",
"?", "_", "\"", "_", "<", "_", ">", "_", "|", "_",
)
result := replacer.Replace(name)
const maxBytes = 200
if len(result) > maxBytes {
result = result[:maxBytes]
for len(result) > 0 {
r, size := utf8.DecodeLastRuneInString(result)
if r != utf8.RuneError || size != 1 {
break
}
result = result[:len(result)-1]
}
}
return result
}
@@ -0,0 +1,511 @@
package ima
import (
"context"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/types"
)
func TestConnector_Type(t *testing.T) {
if got := NewConnector().Type(); got != types.ConnectorTypeIMA {
t.Errorf("Type() = %q, want %q", got, types.ConnectorTypeIMA)
}
}
// TestFetchIncremental_TransientFailureIsRetried is the regression test for the
// cursor bug: the cursor used to be built from the raw listing before any
// content was fetched, so an item whose download failed was recorded as
// "seen with this media_id" and every later incremental sync skipped it as
// unchanged. The document was then silently missing from the KB forever.
func TestFetchIncremental_TransientFailureIsRetried(t *testing.T) {
f := newFakeIMA(t)
good := fakeFile{MediaID: "m-good", Title: "Good", MediaType: mediaTypeMarkdown, Body: "# ok"}
flaky := fakeFile{
MediaID: "m-flaky", Title: "Flaky", MediaType: mediaTypeMarkdown,
Body: "# later", DownloadFails: true,
}
f.setKB("kb1", []fakeFile{good, flaky})
c := NewConnector()
cfg := f.config("kb1")
goodKey := logicalKey("kb1", "", "Good")
flakyKey := logicalKey("kb1", "", "Flaky")
items, cursor, err := c.FetchIncremental(context.Background(), cfg, nil)
if err != nil {
t.Fatalf("first FetchIncremental: %v", err)
}
if _, ok := findItem(items, flakyKey); ok {
t.Fatalf("failed download must not be emitted, got %s", describeItems(items))
}
mustFindItem(t, items, goodKey)
state := decodeCursor(t, cursor)
if _, recorded := state.KBLogical["kb1"][flakyKey]; recorded {
t.Fatal("a failed item must not be recorded in the cursor, otherwise it is never retried")
}
if state.KBLogical["kb1"][goodKey] != "m-good" {
t.Fatalf("successful item missing from cursor: %#v", state.KBLogical["kb1"])
}
// Second pass: the download now succeeds and the item must be re-attempted.
flaky.DownloadFails = false
f.setKB("kb1", []fakeFile{good, flaky})
items2, cursor2, err := c.FetchIncremental(context.Background(), cfg, cursor)
if err != nil {
t.Fatalf("second FetchIncremental: %v", err)
}
retried := mustFindItem(t, items2, flakyKey)
if string(retried.Content) != "# later" {
t.Errorf("retried content = %q, want %q", retried.Content, "# later")
}
if _, ok := findItem(items2, goodKey); ok {
t.Errorf("unchanged item must be skipped on the second pass, got %s", describeItems(items2))
}
if decodeCursor(t, cursor2).KBLogical["kb1"][flakyKey] != "m-flaky" {
t.Error("the retried item should now be recorded in the cursor")
}
}
// TestFetchIncremental_FailureIsNotADeletion guards the other half of the fix:
// keeping a failed item out of the cursor must not make the next sync mistake
// it for a document that vanished from IMA.
func TestFetchIncremental_FailureIsNotADeletion(t *testing.T) {
f := newFakeIMA(t)
good := fakeFile{MediaID: "m-good", Title: "Good", MediaType: mediaTypeMarkdown, Body: "# ok"}
flaky := fakeFile{MediaID: "m-flaky", Title: "Flaky", MediaType: mediaTypeMarkdown, DownloadFails: true}
f.setKB("kb1", []fakeFile{good, flaky})
c := NewConnector()
cfg := f.config("kb1")
flakyKey := logicalKey("kb1", "", "Flaky")
_, cursor, err := c.FetchIncremental(context.Background(), cfg, nil)
if err != nil {
t.Fatalf("first FetchIncremental: %v", err)
}
// Still failing, still listed by IMA: no tombstone may be emitted.
items, _, err := c.FetchIncremental(context.Background(), cfg, cursor)
if err != nil {
t.Fatalf("second FetchIncremental: %v", err)
}
for _, it := range items {
if it.IsDeleted && it.ExternalID == flakyKey {
t.Fatalf("a still-listed item that merely failed to download must not be tombstoned: %s",
describeItems(items))
}
}
}
func TestFetchIncremental_RemovedItemIsTombstoned(t *testing.T) {
f := newFakeIMA(t)
keep := fakeFile{MediaID: "m-keep", Title: "Keep", MediaType: mediaTypeMarkdown, Body: "# keep"}
drop := fakeFile{MediaID: "m-drop", Title: "Drop", MediaType: mediaTypeMarkdown, Body: "# drop"}
f.setKB("kb1", []fakeFile{keep, drop})
c := NewConnector()
cfg := f.config("kb1")
dropKey := logicalKey("kb1", "", "Drop")
_, cursor, err := c.FetchIncremental(context.Background(), cfg, nil)
if err != nil {
t.Fatalf("first FetchIncremental: %v", err)
}
f.setKB("kb1", []fakeFile{keep})
items, _, err := c.FetchIncremental(context.Background(), cfg, cursor)
if err != nil {
t.Fatalf("second FetchIncremental: %v", err)
}
tombstone, ok := findItem(items, dropKey)
if !ok || !tombstone.IsDeleted {
t.Fatalf("removed item must be tombstoned, got %s", describeItems(items))
}
if tombstone.SourceResourceID != "kb1" {
t.Errorf("tombstone SourceResourceID = %q, want kb1", tombstone.SourceResourceID)
}
}
// TestFetchIncremental_SameNameReplacementKeepsExternalID covers IMA minting a
// new media_id when a file is replaced in place: the item must come back as an
// update under the same external id rather than a delete plus an insert.
func TestFetchIncremental_SameNameReplacementKeepsExternalID(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", []fakeFile{{MediaID: "m-v1", Title: "Doc", MediaType: mediaTypeMarkdown, Body: "v1"}})
c := NewConnector()
cfg := f.config("kb1")
key := logicalKey("kb1", "", "Doc")
_, cursor, err := c.FetchIncremental(context.Background(), cfg, nil)
if err != nil {
t.Fatalf("first FetchIncremental: %v", err)
}
f.setKB("kb1", []fakeFile{{MediaID: "m-v2", Title: "Doc", MediaType: mediaTypeMarkdown, Body: "v2"}})
items, cursor2, err := c.FetchIncremental(context.Background(), cfg, cursor)
if err != nil {
t.Fatalf("second FetchIncremental: %v", err)
}
updated := mustFindItem(t, items, key)
if updated.IsDeleted {
t.Fatal("a same-name replacement must be an update, not a delete")
}
if string(updated.Content) != "v2" {
t.Errorf("content = %q, want v2", updated.Content)
}
if updated.Metadata["media_id"] != "m-v2" {
t.Errorf("metadata media_id = %q, want m-v2", updated.Metadata["media_id"])
}
for _, it := range items {
if it.IsDeleted {
t.Errorf("no tombstone expected, got %s", describeItems(items))
}
}
if decodeCursor(t, cursor2).KBLogical["kb1"][key] != "m-v2" {
t.Error("cursor should track the new media_id")
}
}
// TestFetchIncremental_UnsupportedTypeIsProbedOnce checks that a deterministic
// skip is remembered, so an IMA knowledge base full of notes does not re-pay
// for get_media_info on every scheduled sync.
func TestFetchIncremental_UnsupportedTypeIsProbedOnce(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", []fakeFile{{MediaID: "m-note", Title: "Note", MediaType: mediaTypeNote}})
c := NewConnector()
cfg := f.config("kb1")
items, cursor, err := c.FetchIncremental(context.Background(), cfg, nil)
if err != nil {
t.Fatalf("first FetchIncremental: %v", err)
}
if len(items) != 0 {
t.Fatalf("note must be skipped, got %s", describeItems(items))
}
if f.callCount("get_media_info:m-note") != 1 {
t.Fatalf("get_media_info calls = %d, want 1", f.callCount("get_media_info:m-note"))
}
if _, _, err := c.FetchIncremental(context.Background(), cfg, cursor); err != nil {
t.Fatalf("second FetchIncremental: %v", err)
}
if got := f.callCount("get_media_info:m-note"); got != 1 {
t.Errorf("get_media_info calls after second sync = %d, want 1 (skip should be cached)", got)
}
}
// TestFetchAll_NoteBodyIsReadFromNoteNamespace covers IMA notes. They expose no
// url_info at all: get_media_info only reports a notebook_id, and the body has
// to be read from /openapi/note/v1/get_doc_content.
func TestFetchAll_NoteBodyIsReadFromNoteNamespace(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", []fakeFile{{
MediaID: "note_1",
Title: "Meeting notes",
MediaType: mediaTypeNote,
NotebookID: "987654321",
NoteBody: "# Standup\n- shipped the connector",
}})
items, err := NewConnector().FetchAll(context.Background(), f.config("kb1"), []string{"kb1"})
if err != nil {
t.Fatalf("FetchAll: %v", err)
}
note := mustFindItem(t, items, logicalKey("kb1", "", "Meeting notes"))
if string(note.Content) != "# Standup\n- shipped the connector" {
t.Errorf("content = %q, want the note body", note.Content)
}
if !strings.HasSuffix(note.FileName, ".md") {
t.Errorf("FileName = %q, want an .md suffix", note.FileName)
}
if note.ContentType != "text/markdown" {
t.Errorf("ContentType = %q, want text/markdown", note.ContentType)
}
if note.Metadata["notebook_id"] != "987654321" {
t.Errorf("metadata notebook_id = %q, want 987654321", note.Metadata["notebook_id"])
}
if f.callCount("get_doc_content:987654321") != 1 {
t.Errorf("get_doc_content calls = %d, want 1", f.callCount("get_doc_content:987654321"))
}
}
// TestFetchIncremental_NoteReadFailureIsRetried checks that a note whose body
// could not be read follows the same retry path as a failed download rather
// than being remembered as synced.
func TestFetchIncremental_NoteReadFailureIsRetried(t *testing.T) {
f := newFakeIMA(t)
note := fakeFile{
MediaID: "note_1", Title: "Broken", MediaType: mediaTypeNote,
NotebookID: "111", NoteBody: "recovered", NoteFails: true,
}
f.setKB("kb1", []fakeFile{note})
c := NewConnector()
cfg := f.config("kb1")
key := logicalKey("kb1", "", "Broken")
items, cursor, err := c.FetchIncremental(context.Background(), cfg, nil)
if err != nil {
t.Fatalf("first FetchIncremental: %v", err)
}
if len(items) != 0 {
t.Fatalf("a note that could not be read must not be emitted, got %s", describeItems(items))
}
if _, recorded := decodeCursor(t, cursor).KBLogical["kb1"][key]; recorded {
t.Fatal("a failed note read must not be recorded in the cursor")
}
note.NoteFails = false
f.setKB("kb1", []fakeFile{note})
items2, _, err := c.FetchIncremental(context.Background(), cfg, cursor)
if err != nil {
t.Fatalf("second FetchIncremental: %v", err)
}
if got := mustFindItem(t, items2, key); string(got.Content) != "recovered" {
t.Errorf("content = %q, want recovered", got.Content)
}
}
func TestFetchAll_EmptyNoteIsSkipped(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", []fakeFile{{
MediaID: "note_1", Title: "Blank", MediaType: mediaTypeNote,
NotebookID: "222", NoteBody: " \n ",
}})
items, err := NewConnector().FetchAll(context.Background(), f.config("kb1"), []string{"kb1"})
if err != nil {
t.Fatalf("FetchAll: %v", err)
}
if len(items) != 0 {
t.Fatalf("an empty note must be skipped, got %s", describeItems(items))
}
}
// TestFetchAll_AISessionIsStillSkipped pins the types IMA genuinely cannot
// export, so widening note support does not quietly widen these too.
func TestFetchAll_AISessionIsStillSkipped(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", []fakeFile{
{MediaID: "m-ai", Title: "Chat", MediaType: mediaTypeAISession},
{MediaID: "m-video", Title: "Clip", MediaType: mediaTypeVideo},
})
items, err := NewConnector().FetchAll(context.Background(), f.config("kb1"), []string{"kb1"})
if err != nil {
t.Fatalf("FetchAll: %v", err)
}
if len(items) != 0 {
t.Fatalf("AI sessions and video parses must be skipped, got %s", describeItems(items))
}
}
// TestFetchAll_AuthenticatedURLIsDownloaded covers media types with no fixed
// extension. When IMA attaches auth headers the URL is IMA-hosted and WeKnora's
// own fetch could not authenticate, so the connector must download it here and
// forward the headers.
func TestFetchAll_AuthenticatedURLIsDownloaded(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", []fakeFile{{
MediaID: "m-web",
Title: "Article",
MediaType: mediaTypeWeb,
Body: "<html>body</html>",
ContentType: "text/html",
URLHeaders: map[string]string{"X-Ima-Token": "secret"},
}})
items, err := NewConnector().FetchAll(context.Background(), f.config("kb1"), []string{"kb1"})
if err != nil {
t.Fatalf("FetchAll: %v", err)
}
item := mustFindItem(t, items, logicalKey("kb1", "", "Article"))
if string(item.Content) != "<html>body</html>" {
t.Errorf("content = %q, want the downloaded body", item.Content)
}
if !strings.HasSuffix(item.FileName, ".html") {
t.Errorf("FileName = %q, want an .html suffix", item.FileName)
}
if got := f.downloadHeaders["m-web"].Get("X-Ima-Token"); got != "secret" {
t.Errorf("auth header forwarded = %q, want %q", got, "secret")
}
}
// TestFetchAll_PublicURLStaysURLOnly is the counterpart: without auth headers
// the link is publicly reachable, so it is handed to the ingest layer as a URL
// and WeKnora renders the live page instead of a snapshot.
func TestFetchAll_PublicURLStaysURLOnly(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", []fakeFile{{MediaID: "m-web", Title: "Article", MediaType: mediaTypeWeb, Body: "ignored"}})
items, err := NewConnector().FetchAll(context.Background(), f.config("kb1"), []string{"kb1"})
if err != nil {
t.Fatalf("FetchAll: %v", err)
}
item := mustFindItem(t, items, logicalKey("kb1", "", "Article"))
if len(item.Content) != 0 {
t.Errorf("public URL must not be downloaded, got %d bytes", len(item.Content))
}
if item.URL == "" {
t.Error("URL-only item must carry a URL")
}
if f.callCount("download:m-web") != 0 {
t.Error("no download should have been attempted")
}
}
// TestFetchAll_ImageExtensionFollowsContentType guards against naming a JPEG
// ".png": IMA reports every image as media_type=9, so only the download's
// Content-Type reveals the real format, and the extension drives the ingest
// pipeline's file-type checks.
func TestFetchAll_ImageExtensionFollowsContentType(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", []fakeFile{{
MediaID: "m-img",
Title: "Photo",
MediaType: mediaTypeImage,
Body: "\xff\xd8\xff\xe0jpeg",
ContentType: "image/jpeg",
}})
items, err := NewConnector().FetchAll(context.Background(), f.config("kb1"), []string{"kb1"})
if err != nil {
t.Fatalf("FetchAll: %v", err)
}
item := mustFindItem(t, items, logicalKey("kb1", "", "Photo"))
if !strings.HasSuffix(item.FileName, ".jpg") {
t.Errorf("FileName = %q, want a .jpg suffix for an image/jpeg body", item.FileName)
}
}
func TestFetchAll_SkipsItemWithoutURL(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", []fakeFile{{MediaID: "m-empty", Title: "Empty", MediaType: mediaTypePDF, NoURL: true}})
items, err := NewConnector().FetchAll(context.Background(), f.config("kb1"), []string{"kb1"})
if err != nil {
t.Fatalf("FetchAll: %v", err)
}
if len(items) != 0 {
t.Fatalf("item without a URL must be skipped, got %s", describeItems(items))
}
}
// TestFetchAll_ResolvesFolderPath checks the mixed file/folder listing walk.
func TestFetchAll_ResolvesFolderPath(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1",
[]fakeFile{
{MediaID: "m-root", Title: "Root", MediaType: mediaTypeMarkdown, Body: "root"},
{MediaID: "m-deep", Title: "Deep", ParentFolderID: "f2", MediaType: mediaTypeMarkdown, Body: "deep"},
},
fakeFolder{FolderID: "f1", Name: "Outer"},
fakeFolder{FolderID: "f2", Name: "Inner", ParentFolderID: "f1"},
)
items, err := NewConnector().FetchAll(context.Background(), f.config("kb1"), []string{"kb1"})
if err != nil {
t.Fatalf("FetchAll: %v", err)
}
deep := mustFindItem(t, items, logicalKey("kb1", "f2", "Deep"))
if deep.Metadata["folder_path"] != "Outer/Inner" {
t.Errorf("folder_path = %q, want Outer/Inner", deep.Metadata["folder_path"])
}
root := mustFindItem(t, items, logicalKey("kb1", "", "Root"))
if root.Metadata["folder_path"] != "" {
t.Errorf("root folder_path = %q, want empty", root.Metadata["folder_path"])
}
}
func TestListResources_ReturnsAddableBases(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb-b", nil)
f.setKB("kb-a", nil)
resources, err := NewConnector().ListResources(context.Background(), f.config(), "")
if err != nil {
t.Fatalf("ListResources: %v", err)
}
if len(resources) != 2 {
t.Fatalf("got %d resources, want 2", len(resources))
}
if resources[0].ExternalID != "kb-a" || resources[1].ExternalID != "kb-b" {
t.Errorf("resources are not sorted by id: %+v", resources)
}
if resources[0].Description != "desc kb-a" {
t.Errorf("description not enriched from get_knowledge_base: %q", resources[0].Description)
}
}
// TestListResources_FallsBackToSearch covers tenants whose credential exposes
// read-only scopes, where get_addable_knowledge_base_list comes back empty.
func TestListResources_FallsBackToSearch(t *testing.T) {
f := newFakeIMA(t)
f.searchBases = []searchedKnowledgeBaseInfo{{ID: "kb-x", Name: "X", CoverURL: "https://cover"}}
resources, err := NewConnector().ListResources(context.Background(), f.config(), "")
if err != nil {
t.Fatalf("ListResources: %v", err)
}
if len(resources) != 1 || resources[0].ExternalID != "kb-x" {
t.Fatalf("expected the search fallback to surface kb-x, got %+v", resources)
}
if resources[0].Metadata["cover_url"] != "https://cover" {
t.Errorf("cover_url = %v, want https://cover", resources[0].Metadata["cover_url"])
}
}
func TestListResources_ChildrenAreEmpty(t *testing.T) {
f := newFakeIMA(t)
f.setKB("kb1", nil)
resources, err := NewConnector().ListResources(context.Background(), f.config(), "kb1")
if err != nil {
t.Fatalf("ListResources: %v", err)
}
if len(resources) != 0 {
t.Errorf("knowledge bases have no children, got %+v", resources)
}
}
func TestValidate_InvalidCredentials(t *testing.T) {
f := newFakeIMA(t)
cfg := f.config()
cfg.Credentials["client_id"] = ""
err := NewConnector().Validate(context.Background(), cfg)
if err == nil {
t.Fatal("expected an error for a missing client_id")
}
if !strings.Contains(err.Error(), "client_id") {
t.Errorf("error = %v, want it to name the missing field", err)
}
}
func TestFetchIncremental_RequiresResourceIDs(t *testing.T) {
f := newFakeIMA(t)
if _, _, err := NewConnector().FetchIncremental(context.Background(), f.config(), nil); err == nil {
t.Fatal("expected an error when no knowledge base is selected")
}
}
func TestConnectorIsRegisteredInMetadata(t *testing.T) {
meta, ok := datasource.ConnectorMetadataRegistry[types.ConnectorTypeIMA]
if !ok {
t.Fatal("IMA connector missing from the metadata registry")
}
if meta.Type != types.ConnectorTypeIMA {
t.Errorf("metadata type = %q, want %q", meta.Type, types.ConnectorTypeIMA)
}
}
@@ -0,0 +1,359 @@
package ima
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// TestMain whitelists loopback for SSRF so the httptest servers (127.0.0.1)
// are reachable. Production keeps the default strict SSRF policy.
func TestMain(m *testing.M) {
_ = os.Setenv("SSRF_WHITELIST", "127.0.0.1,localhost")
secutils.ResetSSRFWhitelistForTest()
os.Exit(m.Run())
}
// fakeFile is one entry in a fake knowledge base listing.
type fakeFile struct {
MediaID string
Title string
ParentFolderID string
// MediaType is what get_media_info reports for this file.
MediaType int32
// Body is what the download URL serves.
Body string
// ContentType is the download response's Content-Type ("" omits the header).
ContentType string
// URLHeaders are the auth headers IMA attaches to url_info.
URLHeaders map[string]string
// NoURL makes get_media_info return an empty url_info.url.
NoURL bool
// InfoFails makes get_media_info return a 500 for this media_id.
InfoFails bool
// DownloadFails makes the download URL return a 500.
DownloadFails bool
// NotebookID is reported under notebook_ext_info for notes.
NotebookID string
// NoteBody is what get_doc_content serves for NotebookID.
NoteBody string
// NoteFails makes get_doc_content return a business error.
NoteFails bool
}
// fakeFolder is a folder entry in a fake knowledge base listing.
type fakeFolder struct {
FolderID string
Name string
ParentFolderID string
}
// fakeIMA is an in-process stand-in for the IMA OpenAPI. Only the endpoints
// the connector calls are implemented.
type fakeIMA struct {
server *httptest.Server
mu sync.Mutex
// files and folders are keyed by knowledge base id, then by parent folder
// id ("" for the KB root).
files map[string]map[string][]fakeFile
folders map[string]map[string][]fakeFolder
// bases is what get_addable_knowledge_base_list returns.
bases []addableKnowledgeBaseInfo
// searchBases is what search_knowledge_base returns.
searchBases []searchedKnowledgeBaseInfo
// calls counts requests per action, plus "download:<media_id>".
calls map[string]int
// downloadHeaders records the headers seen by the last download of a media.
downloadHeaders map[string]http.Header
}
func newFakeIMA(t *testing.T) *fakeIMA {
t.Helper()
f := &fakeIMA{
files: map[string]map[string][]fakeFile{},
folders: map[string]map[string][]fakeFolder{},
calls: map[string]int{},
downloadHeaders: map[string]http.Header{},
}
mux := http.NewServeMux()
mux.HandleFunc(apiBasePath+"/", f.handleAPI)
mux.HandleFunc(noteBasePath+"/", f.handleNoteAPI)
mux.HandleFunc("/dl/", f.handleDownload)
f.server = httptest.NewServer(mux)
t.Cleanup(f.server.Close)
return f
}
// setKB replaces the listing of a knowledge base and registers it as addable.
func (f *fakeIMA) setKB(kbID string, files []fakeFile, folders ...fakeFolder) {
f.mu.Lock()
defer f.mu.Unlock()
byParent := map[string][]fakeFile{}
for _, file := range files {
byParent[file.ParentFolderID] = append(byParent[file.ParentFolderID], file)
}
f.files[kbID] = byParent
foldersByParent := map[string][]fakeFolder{}
for _, folder := range folders {
foldersByParent[folder.ParentFolderID] = append(foldersByParent[folder.ParentFolderID], folder)
}
f.folders[kbID] = foldersByParent
for _, b := range f.bases {
if b.ID == kbID {
return
}
}
f.bases = append(f.bases, addableKnowledgeBaseInfo{ID: kbID, Name: "KB " + kbID})
}
func (f *fakeIMA) callCount(key string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls[key]
}
func (f *fakeIMA) record(key string) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls[key]++
}
// findFile locates a file by media_id across every knowledge base.
func (f *fakeIMA) findFile(mediaID string) (fakeFile, bool) {
f.mu.Lock()
defer f.mu.Unlock()
for _, byParent := range f.files {
for _, files := range byParent {
for _, file := range files {
if file.MediaID == mediaID {
return file, true
}
}
}
}
return fakeFile{}, false
}
func (f *fakeIMA) handleAPI(w http.ResponseWriter, r *http.Request) {
action := strings.TrimPrefix(r.URL.Path, apiBasePath+"/")
f.record(action)
var req map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&req)
str := func(k string) string {
if v, ok := req[k].(string); ok {
return v
}
return ""
}
switch action {
case "get_addable_knowledge_base_list":
f.mu.Lock()
resp := getAddableKnowledgeBaseListResp{AddableKnowledgeBaseList: f.bases, IsEnd: true}
f.mu.Unlock()
writeEnvelope(w, 0, "", resp)
case "search_knowledge_base":
f.mu.Lock()
resp := searchKnowledgeBaseResp{InfoList: f.searchBases, IsEnd: true}
f.mu.Unlock()
writeEnvelope(w, 0, "", resp)
case "get_knowledge_base":
infos := map[string]knowledgeBaseInfo{}
if ids, ok := req["ids"].([]interface{}); ok {
for _, raw := range ids {
id, _ := raw.(string)
infos[id] = knowledgeBaseInfo{ID: id, Name: "KB " + id, Description: "desc " + id}
}
}
writeEnvelope(w, 0, "", getKnowledgeBaseResp{Infos: infos})
case "get_knowledge_list":
kbID, folderID := str("knowledge_base_id"), str("folder_id")
f.mu.Lock()
files := f.files[kbID][folderID]
folders := f.folders[kbID][folderID]
f.mu.Unlock()
var list []json.RawMessage
for _, folder := range folders {
b, _ := json.Marshal(folderInfo{
FolderID: folder.FolderID,
Name: folder.Name,
ParentFolderID: folder.ParentFolderID,
})
list = append(list, b)
}
for _, file := range files {
// IMA omits media_type in list responses.
b, _ := json.Marshal(map[string]interface{}{
"media_id": file.MediaID,
"title": file.Title,
"parent_folder_id": file.ParentFolderID,
})
list = append(list, b)
}
writeEnvelope(w, 0, "", getKnowledgeListResp{KnowledgeList: list, IsEnd: true})
case "get_media_info":
mediaID := str("media_id")
f.record("get_media_info:" + mediaID)
file, ok := f.findFile(mediaID)
if !ok {
writeEnvelope(w, 110001, "unknown media", nil)
return
}
if file.InfoFails {
w.WriteHeader(http.StatusInternalServerError)
return
}
resp := getMediaInfoResp{MediaType: file.MediaType}
resp.NotebookExtInfo = notebookExtInfo{NotebookID: file.NotebookID}
// Notes carry no url_info; the body lives in the note namespace.
if !file.NoURL && file.MediaType != mediaTypeNote {
resp.URLInfo = urlInfo{
URL: f.server.URL + "/dl/" + mediaID,
Headers: file.URLHeaders,
}
}
writeEnvelope(w, 0, "", resp)
default:
writeEnvelope(w, 110001, "unsupported action "+action, nil)
}
}
// handleNoteAPI serves the /openapi/note/v1 namespace.
func (f *fakeIMA) handleNoteAPI(w http.ResponseWriter, r *http.Request) {
action := strings.TrimPrefix(r.URL.Path, noteBasePath+"/")
f.record("note/" + action)
var req map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&req)
if action != "get_doc_content" {
writeEnvelope(w, 110012, "unsupported note action "+action, nil)
return
}
noteID, _ := req["note_id"].(string)
f.record("get_doc_content:" + noteID)
f.mu.Lock()
defer f.mu.Unlock()
for _, byParent := range f.files {
for _, files := range byParent {
for _, file := range files {
if file.NotebookID != noteID || noteID == "" {
continue
}
if file.NoteFails {
writeEnvelope(w, 110011, "note read failed", nil)
return
}
writeEnvelope(w, 0, "", getDocContentResp{Content: file.NoteBody})
return
}
}
}
writeEnvelope(w, 110001, "unknown note", nil)
}
func (f *fakeIMA) handleDownload(w http.ResponseWriter, r *http.Request) {
mediaID := strings.TrimPrefix(r.URL.Path, "/dl/")
f.record("download:" + mediaID)
f.mu.Lock()
f.downloadHeaders[mediaID] = r.Header.Clone()
f.mu.Unlock()
file, ok := f.findFile(mediaID)
if !ok || file.DownloadFails {
w.WriteHeader(http.StatusInternalServerError)
return
}
if file.ContentType != "" {
w.Header().Set("Content-Type", file.ContentType)
}
_, _ = w.Write([]byte(file.Body))
}
func writeEnvelope(w http.ResponseWriter, code int, msg string, data interface{}) {
raw, _ := json.Marshal(data)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(apiEnvelope{Code: code, Msg: msg, Data: raw})
}
func (f *fakeIMA) config(resourceIDs ...string) *types.DataSourceConfig {
return &types.DataSourceConfig{
Type: types.ConnectorTypeIMA,
Credentials: map[string]interface{}{
"client_id": "cid",
"api_key": "key",
"base_url": f.server.URL,
},
ResourceIDs: resourceIDs,
}
}
// decodeCursor converts the connector cursor back into its typed form.
func decodeCursor(t *testing.T, cur *types.SyncCursor) *imaCursor {
t.Helper()
if cur == nil {
t.Fatal("cursor is nil")
}
b, err := json.Marshal(cur.ConnectorCursor)
if err != nil {
t.Fatalf("marshal cursor: %v", err)
}
var out imaCursor
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal cursor: %v", err)
}
return &out
}
// findItem returns the fetched item with the given external id.
func findItem(items []types.FetchedItem, externalID string) (types.FetchedItem, bool) {
for _, it := range items {
if it.ExternalID == externalID {
return it, true
}
}
return types.FetchedItem{}, false
}
func mustFindItem(t *testing.T, items []types.FetchedItem, externalID string) types.FetchedItem {
t.Helper()
it, ok := findItem(items, externalID)
if !ok {
t.Fatalf("item %s not found in %s", externalID, describeItems(items))
}
return it
}
func describeItems(items []types.FetchedItem) string {
var parts []string
for _, it := range items {
parts = append(parts, fmt.Sprintf("{id=%s title=%q deleted=%t}", it.ExternalID, it.Title, it.IsDeleted))
}
return "[" + strings.Join(parts, " ") + "]"
}
+433
View File
@@ -0,0 +1,433 @@
package ima
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/types"
)
// DefaultBaseURL is the IMA OpenAPI base URL. The Path prefix is baked into
// the client (see client.go); users only configure the host.
const DefaultBaseURL = "https://ima.qq.com"
// Base path for knowledge-base OpenAPI requests.
const apiBasePath = "/openapi/wiki/v1"
// Base path for the note OpenAPI. Notes live in their own namespace: the wiki
// endpoints only ever hand back a notebook_id for them, and the body has to be
// read from here.
const noteBasePath = "/openapi/note/v1"
// Config holds IMA-specific configuration decoded from
// DataSourceConfig.Credentials. Both credentials are opaque strings and
// stored encrypted at rest by DataSourceConfig.ToJSON (see internal/types/datasource.go).
type Config struct {
// ClientID: value of the `ima-openapi-clientid` request header,
// obtained from https://ima.qq.com/agent-interface.
ClientID string `json:"client_id"`
// APIKey: value of the `ima-openapi-apikey` request header.
APIKey string `json:"api_key"`
// BaseURL lets users point at an on-prem / test IMA endpoint.
// Defaults to https://ima.qq.com when empty.
BaseURL string `json:"base_url,omitempty"`
}
// GetBaseURL returns the normalized base URL (empty → default, no trailing slash).
func (c *Config) GetBaseURL() string {
url := strings.TrimSpace(c.BaseURL)
if url == "" {
return DefaultBaseURL
}
if !strings.Contains(url, "://") {
url = "https://" + url
}
return strings.TrimRight(url, "/")
}
// parseIMAConfig extracts and validates IMA-specific configuration.
// Uses JSON marshal/unmarshal roundtrip so extra fields are ignored gracefully.
func parseIMAConfig(config *types.DataSourceConfig) (*Config, error) {
if config == nil {
return nil, fmt.Errorf("%w: config is nil", datasource.ErrInvalidConfig)
}
credBytes, err := json.Marshal(config.Credentials)
if err != nil {
return nil, fmt.Errorf("marshal credentials: %w", err)
}
var cfg Config
if err := json.Unmarshal(credBytes, &cfg); err != nil {
return nil, fmt.Errorf("parse ima credentials: %w", err)
}
if strings.TrimSpace(cfg.ClientID) == "" {
return nil, fmt.Errorf("%w: client_id is required", datasource.ErrInvalidCredentials)
}
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, fmt.Errorf("%w: api_key is required", datasource.ErrInvalidCredentials)
}
if err := datasource.ValidateConnectorBaseURL(cfg.GetBaseURL()); err != nil {
return nil, err
}
return &cfg, nil
}
// apiEnvelope is the uniform IMA response wrapper: `{ code, msg, data }`.
// A non-zero code is a business-level error and MUST be surfaced to the user.
//
// Some published IMA references spell the same envelope `{ retcode, errmsg }`.
// Accepting both costs nothing and avoids the failure mode where a mismatch
// leaves Code at its zero value and every API error is silently read as
// success.
type apiEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
RetErr *int `json:"retcode"`
ErrMsg string `json:"errmsg"`
Data json.RawMessage `json:"data"`
}
// statusCode returns the business status code under either spelling.
func (e apiEnvelope) statusCode() int {
if e.Code != 0 {
return e.Code
}
if e.RetErr != nil {
return *e.RetErr
}
return 0
}
// message returns the human-readable error under either spelling.
func (e apiEnvelope) message() string {
if e.Msg != "" {
return e.Msg
}
return e.ErrMsg
}
// KnowledgeBaseInfo mirrors IMA's KnowledgeBaseInfo (get_knowledge_base).
type knowledgeBaseInfo struct {
ID string `json:"id"`
Name string `json:"name"`
CoverURL string `json:"cover_url"`
Description string `json:"description"`
RecommendedQuestions []string `json:"recommended_questions"`
}
// searchedKnowledgeBaseInfo — search_knowledge_base returns fewer fields.
type searchedKnowledgeBaseInfo struct {
ID string `json:"id"`
Name string `json:"name"`
CoverURL string `json:"cover_url"`
}
// searchKnowledgeBaseResp — search_knowledge_base response.
type searchKnowledgeBaseResp struct {
InfoList []searchedKnowledgeBaseInfo `json:"info_list"`
IsEnd bool `json:"is_end"`
NextCursor string `json:"next_cursor"`
}
// addableKnowledgeBaseInfo — get_addable_knowledge_base_list item.
// Contains only id + name (no cover_url), see api.md §7.
type addableKnowledgeBaseInfo struct {
ID string `json:"id"`
Name string `json:"name"`
}
// getAddableKnowledgeBaseListResp — get_addable_knowledge_base_list response.
// NOTE: the JSON field is `addable_knowledge_base_list`, not `info_list`.
type getAddableKnowledgeBaseListResp struct {
AddableKnowledgeBaseList []addableKnowledgeBaseInfo `json:"addable_knowledge_base_list"`
IsEnd bool `json:"is_end"`
NextCursor string `json:"next_cursor"`
}
// getKnowledgeBaseResp — get_knowledge_base response (map keyed by id).
type getKnowledgeBaseResp struct {
Infos map[string]knowledgeBaseInfo `json:"infos"`
}
// knowledgeInfo mirrors IMA's KnowledgeInfo (files, notes, etc.).
// IMPORTANT: IMA does not expose media_type in the list response, we must call
// get_media_info to discover it. However, folders are distinguished by presence
// of a non-empty folder_id — see folderInfo below.
type knowledgeInfo struct {
MediaID string `json:"media_id"`
Title string `json:"title"`
ParentFolderID string `json:"parent_folder_id"`
MediaType int32 `json:"media_type,omitempty"`
}
// folderInfo mirrors IMA's FolderInfo.
type folderInfo struct {
FolderID string `json:"folder_id"`
Name string `json:"name"`
FileNumber json.Number `json:"file_number"`
FolderNumber json.Number `json:"folder_number"`
ParentFolderID string `json:"parent_folder_id"`
IsTop bool `json:"is_top"`
}
// FileCount safely converts FileNumber to int64 (0 on empty/invalid).
func (f folderInfo) FileCount() int64 {
if f.FileNumber == "" {
return 0
}
n, err := f.FileNumber.Int64()
if err != nil {
return 0
}
return n
}
// FolderCount safely converts FolderNumber to int64 (0 on empty/invalid).
func (f folderInfo) FolderCount() int64 {
if f.FolderNumber == "" {
return 0
}
n, err := f.FolderNumber.Int64()
if err != nil {
return 0
}
return n
}
// getKnowledgeListResp — get_knowledge_list response.
// According to `references/api.md`, the response returns knowledge_list mixed
// with folders; we decode a loose JSON view so entries without a folder_id are
// treated as knowledge and those with folder_id are treated as folders.
type getKnowledgeListResp struct {
// KnowledgeList is a loose slice of raw JSON objects because IMA returns
// files and folders in the same array. We iterate and inspect fields.
KnowledgeList []json.RawMessage `json:"knowledge_list"`
IsEnd bool `json:"is_end"`
NextCursor string `json:"next_cursor"`
CurrentPath []folderInfo `json:"current_path"`
}
// urlInfo mirrors IMA's URLInfo (present on get_media_info for URL-backed media).
type urlInfo struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
}
// notebookExtInfo — set on notes (media_type=11).
type notebookExtInfo struct {
NotebookID string `json:"notebook_id"`
}
// getMediaInfoResp — get_media_info response.
type getMediaInfoResp struct {
MediaType int32 `json:"media_type"`
URLInfo urlInfo `json:"url_info"`
NotebookExtInfo notebookExtInfo `json:"notebook_ext_info"`
}
// getDocContentResp — note/v1 get_doc_content response. Content is the note
// body; with target_content_format=0 it is plain text.
type getDocContentResp struct {
Content string `json:"content"`
}
// imaCursor tracks per-knowledge-base state seen during the last sync.
//
// KBLogical: { logical_key: media_id } — the authoritative source for both
// delete detection AND "replacement" detection. IMA reassigns media_id
// whenever a same-named file is replaced in place, so a logical key (see
// logicalKey) is what we treat as a document's stable identity.
// A logical_key present last sync but absent this sync ⇒ delete.
// A logical_key whose media_id changed ⇒ replacement (re-fetch content; the
// emitted FetchedItem carries the same ExternalID so the ingest layer's
// "existing external_id → delete-and-recreate" path (see
// datasource_service.go applyFetchedItem) treats it as an update.
//
// Only items that were successfully synced (or deterministically skipped)
// this cycle are recorded, so a transient download failure leaves the key
// absent and the next run retries it instead of treating it as unchanged.
//
// KBMedia is a legacy field retained only so cursors persisted by earlier
// builds still deserialize; it is never written or consulted.
type imaCursor struct {
LastSyncTime time.Time `json:"last_sync_time"`
KBLogical map[string]map[string]string `json:"kb_logical,omitempty"`
KBMedia map[string]map[string]string `json:"kb_media,omitempty"`
}
// logicalKey derives a stable identity for an IMA item that survives
// same-name replacement (which mints a new media_id server-side).
//
// The key is a short SHA-256 hex over (kb_id, parent_folder_id, title).
// Rationale:
//
// - kb_id keeps identities isolated across knowledge bases (same file
// copied into two KBs is two distinct docs from WeKnora's perspective).
// - parent_folder_id + title anchor the doc to its folder location and
// display name — the tuple IMA's own check_repeated_names uses to gate
// uploads (see api.md), so IMA's own uniqueness constraint enforces
// this key's uniqueness inside a KB.
//
// media_type is deliberately NOT part of the key: get_knowledge_list omits it
// (it only arrives later from get_media_info), so folding it in would mean
// every external_id shifts the day IMA starts populating the field in list
// responses — which reads as "every document deleted and re-added".
//
// Emitted as "ima_" + first 32 hex chars (128 bits) — small enough to fit
// well within Postgres varchar(64) external_id columns, still >> collision
// resistant for any realistic KB size.
func logicalKey(kbID, parentFolderID, title string) string {
h := sha256.New()
// Delimiter can't appear in any component because it's neither a valid
// IMA id character nor allowed in titles once we've folded to bytes.
_, _ = fmt.Fprintf(h, "%s\x1f%s\x1f%s", kbID, parentFolderID, title)
return "ima_" + hex.EncodeToString(h.Sum(nil))[:32]
}
// IMA MediaType enum (see api.md §MediaType).
const (
mediaTypePDF int32 = 1
mediaTypeWeb int32 = 2
mediaTypeWord int32 = 3
mediaTypePPT int32 = 4
mediaTypeExcel int32 = 5
mediaTypeMPArticle int32 = 6
mediaTypeMarkdown int32 = 7
mediaTypeImage int32 = 9
mediaTypeNote int32 = 11
mediaTypeAISession int32 = 12
mediaTypeTXT int32 = 13
mediaTypeXmind int32 = 14
mediaTypeAudio int32 = 15
mediaTypeVideo int32 = 16
mediaTypeHTML int32 = 20
mediaTypeEPUB int32 = 21
)
// extensionForMediaType maps IMA media types to a file extension.
// Returns "" when the media type has no downloadable body (web page, note, AI
// session, video-parse) — callers should skip those or treat them as URL-only.
func extensionForMediaType(t int32) string {
switch t {
case mediaTypePDF:
return "pdf"
case mediaTypeWord:
return "docx"
case mediaTypePPT:
return "pptx"
case mediaTypeExcel:
return "xlsx"
case mediaTypeMarkdown:
return "md"
case mediaTypeImage:
return "png"
case mediaTypeTXT:
return "txt"
case mediaTypeXmind:
return "xmind"
case mediaTypeAudio:
return "mp3"
case mediaTypeHTML:
return "html"
case mediaTypeEPUB:
return "epub"
default:
return ""
}
}
// extensionForContentType maps a response Content-Type back to a file
// extension, ignoring any "; charset=..." suffix. Returns "" for types we
// have no better guess for than the media_type-derived default.
//
// IMA reports every image as media_type=9 regardless of the real encoding, so
// the download's Content-Type is the only signal that distinguishes a JPEG
// from a PNG. Naming a JPEG ".png" breaks the extension-based file-type checks
// downstream in the ingest pipeline.
func extensionForContentType(contentType string) string {
mediaType := strings.ToLower(strings.TrimSpace(contentType))
if i := strings.Index(mediaType, ";"); i >= 0 {
mediaType = strings.TrimSpace(mediaType[:i])
}
switch mediaType {
case "image/png":
return "png"
case "image/jpeg", "image/jpg":
return "jpg"
case "image/gif":
return "gif"
case "image/webp":
return "webp"
case "image/bmp":
return "bmp"
default:
return ""
}
}
// mimeForExtension maps a file extension to a canonical MIME type.
// Used when the downloaded URL response omits (or misreports) Content-Type.
func mimeForExtension(ext string) string {
switch strings.ToLower(strings.TrimPrefix(ext, ".")) {
case "pdf":
return "application/pdf"
case "doc":
return "application/msword"
case "docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case "ppt":
return "application/vnd.ms-powerpoint"
case "pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
case "xls":
return "application/vnd.ms-excel"
case "xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case "md":
return "text/markdown"
case "txt":
return "text/plain"
case "html", "htm":
return "text/html"
case "png":
return "image/png"
case "jpg", "jpeg":
return "image/jpeg"
case "webp":
return "image/webp"
case "gif":
return "image/gif"
case "bmp":
return "image/bmp"
case "epub":
return "application/epub+zip"
case "xmind":
return "application/x-xmind"
case "mp3":
return "audio/mpeg"
case "m4a":
return "audio/x-m4a"
case "wav":
return "audio/wav"
case "aac":
return "audio/aac"
default:
return "application/octet-stream"
}
}
// isSkippableMediaType reports whether IMA offers no way to read this type's
// content. AI sessions are only ever referenced by session_id with no read
// endpoint, and video parses cannot even be added outside the IMA desktop app.
// Notes are NOT listed here: they are read through the note namespace, see
// fetchNote.
func isSkippableMediaType(t int32) bool {
return t == mediaTypeAISession || t == mediaTypeVideo
}
@@ -0,0 +1,196 @@
package ima
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/types"
)
func TestLogicalKey_IsStableAndScoped(t *testing.T) {
base := logicalKey("kb1", "f1", "Doc")
if base != logicalKey("kb1", "f1", "Doc") {
t.Error("logicalKey must be deterministic")
}
if !strings.HasPrefix(base, "ima_") || len(base) != 36 {
t.Errorf("key = %q, want an ima_ prefix and 36 chars so it fits varchar(64)", base)
}
for _, other := range []string{
logicalKey("kb2", "f1", "Doc"),
logicalKey("kb1", "f2", "Doc"),
logicalKey("kb1", "f1", "Other"),
} {
if other == base {
t.Errorf("keys must differ across kb/folder/title, both were %q", base)
}
}
}
// TestLogicalKey_DelimiterIsUnambiguous guards against a key collision from
// naively concatenating the components.
func TestLogicalKey_DelimiterIsUnambiguous(t *testing.T) {
if logicalKey("kb", "a", "bc") == logicalKey("kb", "ab", "c") {
t.Error("component boundaries must not be ambiguous")
}
}
func TestParseIMAConfig(t *testing.T) {
tests := []struct {
name string
credentials map[string]interface{}
wantErr error
wantErrText string
}{
{
name: "valid",
credentials: map[string]interface{}{"client_id": "cid", "api_key": "key"},
},
{
name: "missing client_id",
credentials: map[string]interface{}{"api_key": "key"},
wantErr: datasource.ErrInvalidCredentials,
},
{
name: "blank api_key",
credentials: map[string]interface{}{"client_id": "cid", "api_key": " "},
wantErr: datasource.ErrInvalidCredentials,
},
{
name: "loopback base_url rejected by SSRF policy",
credentials: map[string]interface{}{"client_id": "cid", "api_key": "key", "base_url": "http://169.254.169.254"},
wantErrText: "SSRF",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseIMAConfig(&types.DataSourceConfig{Credentials: tt.credentials})
switch {
case tt.wantErr != nil:
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
case tt.wantErrText != "":
if err == nil || !strings.Contains(err.Error(), tt.wantErrText) {
t.Fatalf("err = %v, want it to mention %q", err, tt.wantErrText)
}
default:
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
})
}
}
func TestParseIMAConfig_NilConfig(t *testing.T) {
if _, err := parseIMAConfig(nil); !errors.Is(err, datasource.ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
func TestConfig_GetBaseURL(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", DefaultBaseURL},
{" ", DefaultBaseURL},
{"https://ima.example.com/", "https://ima.example.com"},
{"ima.example.com", "https://ima.example.com"},
{"http://ima.example.com//", "http://ima.example.com"},
}
for _, tt := range tests {
if got := (&Config{BaseURL: tt.in}).GetBaseURL(); got != tt.want {
t.Errorf("GetBaseURL(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestExtensionForContentType(t *testing.T) {
tests := []struct {
in string
want string
}{
{"image/jpeg", "jpg"},
{"image/JPEG", "jpg"},
{"image/png; charset=binary", "png"},
{"image/webp", "webp"},
{"application/pdf", ""},
{"", ""},
}
for _, tt := range tests {
if got := extensionForContentType(tt.in); got != tt.want {
t.Errorf("extensionForContentType(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestSanitizeFileName(t *testing.T) {
if got := sanitizeFileName(""); got != "untitled" {
t.Errorf("empty name = %q, want untitled", got)
}
if got := sanitizeFileName(`a/b\c:d*e?f"g<h>i|j`); strings.ContainsAny(got, `/\:*?"<>|`) {
t.Errorf("filesystem-hostile characters survived: %q", got)
}
// A long CJK title must be truncated on a rune boundary, not mid-sequence.
long := strings.Repeat("知识", 200)
got := sanitizeFileName(long)
if len(got) > 200 {
t.Errorf("len = %d bytes, want <= 200", len(got))
}
if !strings.HasPrefix(long, got) {
t.Errorf("truncation corrupted the prefix: %q", got)
}
}
func TestIsSkippableMediaType(t *testing.T) {
for _, mt := range []int32{mediaTypeAISession, mediaTypeVideo} {
if !isSkippableMediaType(mt) {
t.Errorf("media type %d should be skippable", mt)
}
}
// Notes are read through the note namespace rather than skipped.
for _, mt := range []int32{mediaTypePDF, mediaTypeMarkdown, mediaTypeWeb, mediaTypeNote} {
if isSkippableMediaType(mt) {
t.Errorf("media type %d should not be skippable", mt)
}
}
}
// TestAPIEnvelope_AcceptsBothSpellings covers the retcode/errmsg variant some
// IMA references describe: if only `code` were read, a non-zero retcode would
// decode as 0 and every API error would be silently treated as success.
func TestAPIEnvelope_AcceptsBothSpellings(t *testing.T) {
tests := []struct {
name string
body string
wantCode int
wantMsg string
}{
{"code/msg", `{"code":110030,"msg":"无权限"}`, 110030, "无权限"},
{"retcode/errmsg", `{"retcode":110030,"errmsg":"无权限"}`, 110030, "无权限"},
{"success", `{"code":0,"msg":"ok"}`, 0, "ok"},
{"retcode success", `{"retcode":0,"errmsg":"成功"}`, 0, "成功"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var env apiEnvelope
if err := json.Unmarshal([]byte(tt.body), &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := env.statusCode(); got != tt.wantCode {
t.Errorf("statusCode() = %d, want %d", got, tt.wantCode)
}
if got := env.message(); got != tt.wantMsg {
t.Errorf("message() = %q, want %q", got, tt.wantMsg)
}
})
}
}