Files
viettranx 1bdd291fac feat(feishu): auto-fetch Lark docx URLs into agent context
When a user pastes a Lark or Feishu docx URL in chat, the channel
now detects the URL and fetches the document's raw text via the
Lark Docs API, injecting the content into the agent input inline
so the model can reason over the linked doc without a tool call.

- New Channel.resolveLarkDocs pipeline step runs before reply
  context fetch in handleMessageEvent (step 7a)
- LarkClient.GetDocRawContent calls /open-apis/docx/v1/documents
  /{id}/raw_content with the existing tenant access token;
  permission and not-found errors map to ErrDocAccessDenied
- Per-channel LRU cache (128 entries, 5 min TTL) dedupes repeat
  URL references within the window; soft failures are NOT cached
  so permission grants become visible immediately
- Rune-safe content truncation at 8000 runes handles CJK docs
  without splitting mid-rune
- Bounded concurrency (max 3 parallel fetches) and a per-message
  cap of 10 doc URLs act as spam guards
- Tight URL regex anchors the hostname class so a lazy match
  cannot bypass via query-string embedding
- 18 new unit tests cover URL extraction edge cases, cache LRU
  and TTL semantics, Lark API error code mapping, resolver
  end-to-end with nil cache, access denied soft failure,
  per-message cap, and UTF-8 truncation

Required Lark app permission: docx:document:readonly, plus
per-document access grant from the doc owner.

Partial fix for #818 (Phase 2 of 3 — thread reply + writer
commands are tracked separately).
2026-04-11 21:26:12 +07:00

141 lines
4.9 KiB
Go

package feishu
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// TestGetDocRawContent_Success verifies happy-path fetch of a Lark doc.
func TestGetDocRawContent_Success(t *testing.T) {
const docID = "DOCABC123"
var gotPath, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`))
return
}
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"msg":"","data":{"content":"Hello from Lark doc\n\nSecond paragraph."}}`))
}))
defer srv.Close()
client := NewLarkClient("app", "secret", srv.URL)
content, err := client.GetDocRawContent(context.Background(), docID)
if err != nil {
t.Fatalf("GetDocRawContent error: %v", err)
}
wantPath := "/open-apis/docx/v1/documents/" + docID + "/raw_content"
if gotPath != wantPath {
t.Errorf("path: got %q, want %q", gotPath, wantPath)
}
if gotAuth == "" {
t.Errorf("missing Authorization header")
}
if content != "Hello from Lark doc\n\nSecond paragraph." {
t.Errorf("content: got %q, want full doc text", content)
}
}
// TestGetDocRawContent_AccessDenied asserts that permission failures surface as
// ErrDocAccessDenied so the caller can render a soft failure marker rather
// than crashing the pipeline. Uses Lark docx v1 error code 1770032 (HTTP 403).
func TestGetDocRawContent_AccessDenied(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`))
return
}
_, _ = w.Write([]byte(`{"code":1770032,"msg":"forbidden","data":{}}`))
}))
defer srv.Close()
client := NewLarkClient("app", "secret", srv.URL)
_, err := client.GetDocRawContent(context.Background(), "DOC_NO_PERM")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrDocAccessDenied) {
t.Errorf("expected ErrDocAccessDenied, got %v", err)
}
}
// TestGetDocRawContent_NotFound maps the docx 404 code (1770002) to
// ErrDocAccessDenied for uniform soft-failure handling (either way the bot
// has no content to inject).
func TestGetDocRawContent_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`))
return
}
_, _ = w.Write([]byte(`{"code":1770002,"msg":"document not found","data":{}}`))
}))
defer srv.Close()
client := NewLarkClient("app", "secret", srv.URL)
_, err := client.GetDocRawContent(context.Background(), "DOC_MISSING")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrDocAccessDenied) {
t.Errorf("expected ErrDocAccessDenied, got %v", err)
}
}
// TestGetDocRawContent_LegacyPermCode preserves coverage for the legacy
// generic permission denied code (99991672) still kept in the whitelist as
// a belt-and-suspenders fallback for tenants that surface it.
func TestGetDocRawContent_LegacyPermCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`))
return
}
_, _ = w.Write([]byte(`{"code":99991672,"msg":"no permission","data":{}}`))
}))
defer srv.Close()
client := NewLarkClient("app", "secret", srv.URL)
_, err := client.GetDocRawContent(context.Background(), "DOC_LEGACY")
if !errors.Is(err, ErrDocAccessDenied) {
t.Errorf("expected ErrDocAccessDenied, got %v", err)
}
}
// TestGetDocRawContent_GenericError verifies that unknown error codes surface
// as a normal error (not the sentinel) so callers log them differently.
func TestGetDocRawContent_GenericError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`))
return
}
_, _ = w.Write([]byte(`{"code":500001,"msg":"internal error","data":{}}`))
}))
defer srv.Close()
client := NewLarkClient("app", "secret", srv.URL)
_, err := client.GetDocRawContent(context.Background(), "DOC")
if err == nil {
t.Fatal("expected error, got nil")
}
if errors.Is(err, ErrDocAccessDenied) {
t.Errorf("unknown codes should NOT map to ErrDocAccessDenied, got %v", err)
}
}
// TestGetDocRawContent_EmptyDocID guards against accidental empty-ID requests.
func TestGetDocRawContent_EmptyDocID(t *testing.T) {
client := NewLarkClient("a", "s", "http://unused")
if _, err := client.GetDocRawContent(context.Background(), ""); err == nil {
t.Fatal("expected error on empty doc ID")
}
}