mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
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).
98 lines
2.5 KiB
Go
98 lines
2.5 KiB
Go
package feishu
|
|
|
|
import (
|
|
"container/list"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// docCacheEntry holds one cached document body plus its insertion time for
|
|
// TTL-based expiry. We store a *list.Element alongside so that Get can promote
|
|
// the entry to the front in constant time.
|
|
type docCacheEntry struct {
|
|
key string
|
|
content string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// docCache is a small LRU+TTL cache keyed by Lark document ID. Stores the
|
|
// already-truncated doc content as plain strings so retrieval is a map lookup
|
|
// with no allocation on the hot path. Safe for concurrent use.
|
|
//
|
|
// Scope is intentionally per-channel instance (not shared across tenants) so
|
|
// one tenant cannot spy on another tenant's doc cache hits via timing.
|
|
type docCache struct {
|
|
mu sync.Mutex
|
|
items map[string]*list.Element
|
|
order *list.List // front = most recently used
|
|
capacity int
|
|
ttl time.Duration
|
|
}
|
|
|
|
// newDocCache returns an LRU cache with the given max entry count and TTL.
|
|
// Size is enforced on Set; TTL is enforced lazily on Get.
|
|
func newDocCache(capacity int, ttl time.Duration) *docCache {
|
|
if capacity <= 0 {
|
|
capacity = 1
|
|
}
|
|
return &docCache{
|
|
items: make(map[string]*list.Element, capacity),
|
|
order: list.New(),
|
|
capacity: capacity,
|
|
ttl: ttl,
|
|
}
|
|
}
|
|
|
|
// Get returns the cached content and true on hit, or ("", false) on miss or
|
|
// TTL expiry. A successful hit promotes the entry to the most-recently-used
|
|
// position.
|
|
func (c *docCache) Get(key string) (string, bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
elem, ok := c.items[key]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
entry := elem.Value.(*docCacheEntry)
|
|
if time.Now().After(entry.expiresAt) {
|
|
c.order.Remove(elem)
|
|
delete(c.items, key)
|
|
return "", false
|
|
}
|
|
c.order.MoveToFront(elem)
|
|
return entry.content, true
|
|
}
|
|
|
|
// Set inserts or updates the cache entry for key. Updating an existing key
|
|
// refreshes both its value and its position. Eviction of the LRU entry runs
|
|
// when capacity is exceeded.
|
|
func (c *docCache) Set(key, content string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if elem, ok := c.items[key]; ok {
|
|
entry := elem.Value.(*docCacheEntry)
|
|
entry.content = content
|
|
entry.expiresAt = time.Now().Add(c.ttl)
|
|
c.order.MoveToFront(elem)
|
|
return
|
|
}
|
|
entry := &docCacheEntry{
|
|
key: key,
|
|
content: content,
|
|
expiresAt: time.Now().Add(c.ttl),
|
|
}
|
|
elem := c.order.PushFront(entry)
|
|
c.items[key] = elem
|
|
|
|
for c.order.Len() > c.capacity {
|
|
lru := c.order.Back()
|
|
if lru == nil {
|
|
return
|
|
}
|
|
c.order.Remove(lru)
|
|
delete(c.items, lru.Value.(*docCacheEntry).key)
|
|
}
|
|
}
|