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

105 lines
4.0 KiB
Go

package feishu
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"unicode/utf8"
)
// resolveLarkDocs expands Lark docx URLs in the inbound text into inline
// context blocks the LLM can read directly. Called once per inbound Feishu
// message, before the content is published to the agent. Transparent to the
// agent — no tool call round-trip, no new LLM turn.
//
// Behavior:
// - Extracts distinct docx URLs via extractLarkDocURLs.
// - Fetches each distinct doc with bounded concurrency (larkDocFetchMaxConc).
// - Reuses the channel's docCache for repeat lookups within the TTL.
// - Truncates content to larkDocMaxContentLen so a single large doc cannot
// blow the LLM context window.
// - On ErrDocAccessDenied, injects a "[Lark Doc ...: access denied]" marker
// instead of failing the pipeline.
// - On any other fetch error, injects a "[Lark Doc ...: fetch failed]"
// marker and logs a warning for operators.
//
// Returns the original text with the expansion blocks appended. When no URLs
// are found, returns the original text unchanged.
func (c *Channel) resolveLarkDocs(ctx context.Context, text string) string {
refs := extractLarkDocURLs(text)
if len(refs) == 0 {
return text
}
// Spam guard: cap the number of doc fetches per inbound message so one
// message dumping 100 links cannot burn Lark API quota or block the
// channel handler. Extra refs get a visible "skipped" marker appended so
// the user knows why their doc wasn't expanded.
var skippedNotice string
if len(refs) > larkDocMaxPerMessage {
skipped := len(refs) - larkDocMaxPerMessage
refs = refs[:larkDocMaxPerMessage]
skippedNotice = fmt.Sprintf("\n\n[... %d more Lark doc URLs in this message were skipped (per-message cap %d)]", skipped, larkDocMaxPerMessage)
}
blocks := make([]string, len(refs))
sem := make(chan struct{}, larkDocFetchMaxConc)
var wg sync.WaitGroup
for i, ref := range refs {
wg.Add(1)
sem <- struct{}{}
go func(i int, ref DocRef) {
defer wg.Done()
defer func() { <-sem }()
body := c.fetchOrCacheLarkDoc(ctx, ref.DocID)
blocks[i] = formatLarkDocBlock(ref, body)
}(i, ref)
}
wg.Wait()
return text + "\n\n" + strings.Join(blocks, "\n\n") + skippedNotice
}
// fetchOrCacheLarkDoc returns the doc body from cache if present, otherwise
// fetches from Lark, applies truncation, caches the result, and returns the
// truncated body. Soft-failure markers are returned directly and NOT cached
// so a transient permission issue doesn't get locked in for the TTL window.
func (c *Channel) fetchOrCacheLarkDoc(ctx context.Context, docID string) string {
if c.docCache != nil {
if cached, ok := c.docCache.Get(docID); ok {
return cached
}
}
content, err := c.client.GetDocRawContent(ctx, docID)
if err != nil {
if errors.Is(err, ErrDocAccessDenied) {
slog.Warn("feishu.doc_fetch_denied", "doc_id", docID, "error", err)
return fmt.Sprintf("[Lark Doc %s: access denied — grant the bot app read permission on this document]", docID)
}
slog.Warn("feishu.doc_fetch_failed", "doc_id", docID, "error", err)
return fmt.Sprintf("[Lark Doc %s: fetch failed]", docID)
}
// Rune-safe truncation so CJK content (common in Lark deployments) never
// gets split mid-rune into invalid UTF-8. The cap is measured and applied
// in runes, and the notice reports the original rune count.
if utf8.RuneCountInString(content) > larkDocMaxContentLen {
runes := []rune(content)
content = string(runes[:larkDocMaxContentLen]) +
fmt.Sprintf("\n\n[... truncated, original size %d runes]", len(runes))
}
if c.docCache != nil {
c.docCache.Set(docID, content)
}
return content
}
// formatLarkDocBlock wraps doc content in clearly-marked delimiters so the
// LLM can distinguish injected document text from the user's own words —
// this is a mitigation against prompt-injection-via-doc-content.
func formatLarkDocBlock(ref DocRef, body string) string {
return fmt.Sprintf("[Lark Doc: %s]\n%s\n[End of Lark Doc]", ref.OriginalURL, body)
}