Send agent-generated files as native WeChat attachments via the iLink upload flow, and fix the wire protocol for file uploads: dedicated wire ObjectMapper (bypasses the global Long-to-String serializer), md5/len fields and encrypt_type on media items, channel_version 1.0.2, and explicit business-error handling on ret != 0. The weixin adapter now routes generated-file URLs through GeneratedFileScrubber, matching WeCom/Feishu behavior.
Fixes#307
- router.onError fallback: a failed route-chunk load hard-navigates to the
clicked route once (guarded against reload loops) instead of hanging
silently until a manual refresh
- warm all lazy route chunks during idle time after login, so sidebar
navigation no longer depends on live chunk fetches under load
- SSE executor switches to a virtual-thread-per-task executor, matching
the app-wide virtual-thread model
Wire MCP standard notifications/progress into the existing SSE stream so long-running MCP tool calls surface live progress instead of a bare spinner. A per-call progressToken maps back to (conversationId, toolCallId); ProgressAwareMcpToolCallback injects it into tools/call _meta and calls McpSyncClient directly (falling back to the delegate on error, and applying identity forwarding first). Progress events skip the ring buffer and are replayed from a latest-value snapshot on SSE reconnect. Frontend renders a gradient progress bar in ToolCallSegment when a running tool reports progress.
After the workspace-aware chat-uploads change, the upload root became
absolute (the resolver normalizes via toAbsolutePath/normalize, and the
autoconfiguration rewrites baseDir to an absolute path). ChatController.upload
then set ChatUploadResponse.path to that absolute path — despite the inline
comment promising a relative path "to avoid exposing the server's absolute
path". The field is rendered into the LLM prompt ("附件: foo (path)") and
returned to the client, so this leaked the server filesystem layout into both
the prompt and the response, and broke portability if the deploy dir moves.
Extract toRelativeUploadPath(uploadRoot, convId, storedName) which makes the
path relative to the upload root's parent (preserving the trailing sub-dir
name, e.g. chat-uploads/{convId}/{storedName}) and normalizes separators to
'/'. Retrieval is unaffected: it goes through the basename-based
ChatUploadResolver and the /api/v1/chat/files/... URL, not this field.
Adds ChatControllerUploadPathTest (default root, absolute workspace-scoped
root, custom base-dir name) asserting the result is relative and leak-free.
Addresses the blocker item in #452.
The identity forwarded to opt-in MCP servers was a one-dimensional string
(ChatOrigin.requesterId): a MateClaw username for web logins, but a webchat
visitorId for visitors and an IM sender id for IM — indistinguishable to the
REST backend. The signed-token mode (d204b702) made this worse: an RS256
signature over an unauthenticated visitorId reads as "MateClaw authenticated
this user" to any backend that trusts the signature.
Introduce an identity-typing dimension at McpIdentityForwardService:
- classify() branches on ChatOrigin: authenticated (web login, sub=immutable
userId), anonymous (webchat visitor, trust=anonymous), external (IM sender,
trust=external), or none (cron/system → nothing injected, fail-closed).
- mint() adds `trust` and `channel_type` claims; plaintext value is prefixed
`trust:subject` so backends can tell the kinds apart without a JWT.
The immutable userId reaches resolve() without coupling it to the user store:
JwtAuthFilter stamps user.id into auth.setDetails() (both JWT and PAT paths),
and ChatController.memoryOrigin carries it on a new ChatOrigin.requesterUserId
field (only-add, per the record's evolution rule).
Resolves the webchat semantic mismatch raised in #459 and the "sub should be
an immutable user id" follow-up. 82 tests green (4 identity classes covered
with claim assertions + full ChatOrigin/MCP regression).
(cherry picked from commit b5d2cfbf98b39848d7139c743a0b81fea71e8ffe)
Follow-up to #447. The generated-file link extraction accepted any
non-')' text before the path, so a paren-free javascript:/data: URL
embedding /api/v1/files/generated/<id> could be captured and bound to an
<a href>, enabling XSS on click. Adopt the scheme-restricted pattern
already used by SegmentSupersedeDetector and the channel adapters, on
both backend (ChatController) and frontend (useChat). Also replace the
inline fully-qualified Pattern/Matcher with imports and drop an unused
run-overview i18n key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract generated-file download links from tool results — on the backend (persisted to message metadata for history) and on the frontend (live during SSE) — de-duplicate by URL, and render them as a Generated Files section with file-type icons and a rail badge.
* feat(webchat): add approval resolve + replay for API-Key channel (ISSUE #413 P1)
Before this, a WebChat (API-Key) channel that hit a ToolGuard-protected
tool parked the turn in a pending approval the visitor could never
clear — it hung for 30 min until the GC timeout and the turn was
wasted. This PR closes the loop, mirroring the web ChatController.
A1 — no code change. tool_approval_requested already reaches the SDK
via ToolExecutionGuardHelper's streamTracker.broadcastObject (direct
SSE push, bypassing the StreamDelta path). Adding it to
forwardVisitorEvent would double-deliver; the default-drop is correct.
A2 — new /sessions/approve and /sessions/deny REST endpoints. Auth is
the existing visitorToken + conversationId ownership guard; the actor
is webchatUsername(visitorId), which resolves the 'no MateClaw
username' blocker noted in the old stopSession javadoc. Both broadcast
tool_approval_resolved so the SDK clears its banner in real time.
A3 — approve returns an SSE stream: resolveAndConsume (atomic DB +
metadata + memory), restoreChatOrigin (recovers the webchat origin
captured at createPending), then chatWithReplayStream replays the
tool call and continues the turn. Replay may re-trigger approvals,
which the existing tool_approval_requested direct push handles.
A4 — stopSession now sweeps pending approvals (denyAllByConversation)
and broadcasts each resolution, so stopping a stream no longer leaves
approvals lingering for the GC.
Tests: WebChatApprovalInteractionTest (7) — deny resolves + broadcasts,
deny auth/ownership guards, idempotent unknown-pending, stop sweep
clears pending, stop no-op when nothing pending.
Regression: WebChatStopStreamTest (5), WebChatArchivePinTest (6),
WebChatSchemaFieldsTest (5), WebChatWikiPageListTest (8),
ApprovalWorkflowServiceResolveTest (13), GcTest (7), RecoveryTest (7).
* fix(webchat): IDOR guard + SSE hang fix (PR #415 review)
Addresses all review feedback from mateaix:
P0 IDOR (security): /sessions/approve and /sessions/deny accepted a
client-supplied pendingId without cross-checking it belonged to the
caller's conversation. A visitor could resolve / replay another
visitor's guarded tool call. Fix: getPending(pendingId) then assert
conversationId matches before resolving. Added getPending delegate on
ApprovalWorkflowService so the webchat controller (which holds the
workflow facade) can do the precise lookup.
SSE hang: approveSession's already-resolved / error branches broadcast
'done' before streamTracker.register/attach, so the event had no
subscriber and the SSE hung to the 10-min timeout. Fix: register+attach
first, then resolveAndConsume. Removed the now-duplicate register/attach
in the replay branch.
Tests: +2 IDOR cases (cross-visitor pendingId rejected 404; mismatched
pendingId rejected 404). denyResolvesPending now asserts via getPending
(findPendingByConversation returns the earliest pending, polluted by
cross-test map state). denyUnknownPendingIsSafe updated to expect 404
(no longer leaks pendingId existence). Isolated IDOR victim/attacker
visitor IDs to avoid cross-test conversationId collisions.
Regression: WebChatStopStreamTest (5), WebChatArchivePinTest (6),
ApprovalWorkflowServiceResolveTest (13), GcTest (7), RecoveryTest (7).
* style(webchat): use simple ChatOrigin name in approveSession (PR #415 review)
Reviewer flagged fully-qualified inline types (ResolveOutcome was fixed
in the prior commit; ChatOrigin was missed). Add the import and switch
the 3 FQN references in approveSession to the simple name, matching the
ResolveOutcome cleanup. chatStream's pre-existing FQN usages are out of
this PR's scope and left untouched.
Before this, a workflow await_approval step whose approverChannels
pointed at feishu/wecom was effectively dead for IM interaction. Even
after PR #414 (B1) pushed the notice to the IM group, clicking the
card's Approve/Deny buttons did nothing useful:
- Identity check (requester==clicker) failed-closed: wf- approvals
have userId=null (system-initiated), so every click was rejected.
- Even if it passed, the synthetic /approve injection was a dead end:
the router routes by conversationId, but wf- ids use a synthetic
workflow:run:{runId} key that no IM conversation matches, so
findPendingByConversation returned null and the /approve was fed
to the LLM as plain text.
B3 fix: both ToolGuardCardHandlers now detect the wf- prefix and
resolve inline (approvalService.resolve), bypassing the synthetic
injection entirely. The WorkflowApprovalResolvedEvent published
inside resolve is picked up by ApprovalResumeBridge (activated in
PR #414 B2), which resumes the paused run. This mirrors the Web /
WebChat resolve path (PR #415).
Identity policy: any audience member may resolve a wf- approval.
The card only reaches channels declared in await_approval's
approverChannels, so whoever sees it is a designated approver.
Regular tool approvals keep the strict requester==clicker guard.
Tests:
- wecom ToolGuardCardHandlerTest: +2 wf- cases (inline resolve, no
synthetic injection; already-resolved renders expired). Existing 6
cases updated for the new 3-arg constructor.
- feishu FeishuCardDispatcherTest: updated for the new factory
constructor signature.
Regression: ApprovalWorkflowServiceResolveTest (13), GcTest (7),
RecoveryTest (7), feishu dispatcher (4), button value (7),
renderer (3+3) — all green.
Add GET /api/v1/channels/webchat/wiki/pages mirroring /skills, so
downstream integrators can build a [[slug]] picker UI that points the
LLM at specific wiki pages. The picker token format is the universal
Obsidian/Wikipedia wikilink convention; the LLM consumes [[slug]] via
the existing wiki_read_page(slug=...) tool, so no agent-runtime changes
are needed.
- AgentBindingResolver.getBoundKbIds(agentId): three-state mirror of
getBoundSkillIds. null = no rows (fall through to workspace-wide KBs),
Set.of() = explicitly scoped to zero KBs, non-empty = explicit scope.
- WebChatController.listWikiPages: API Key + visitorToken auth chain,
agentId workspace anti-escalation, visibility excludes pageType=
synthesis (LLM intermediate artifacts), 100-page cap forces keyword
filter, response carries only display-level metadata.
- WebChatWikiPageView DTO: kbId/kbName/slug/title/summary/pageType;
content/embedding/sourceRawIds deliberately stay admin-console-only.
- WikiTool.wiki_read_page @Tool description: document the [[slug]]
convention so the LLM treats each token as a wiki-page reference.
- WebChatWikiPageListTest: 8 cases covering happy path, keyword filter,
synthesis exclusion, anti-escalation, auth failures, cap behavior,
and the no-binding → workspace-wide fallback.
Closes#381.
GET /api/v1/channels/webchat/skills?agentId=<optional>&visitorId=<required>
Headers: X-MC-Key + X-MC-Visitor-Token
Downstream systems integrating via the webchat SSE endpoint have no way
today to enumerate the skills a visitor can invoke — the existing
GET /api/v1/skills is JWT + workspace-role gated, unreachable from the
API-Key-authenticated webchat channel. Without a list, integrators
can't render a slash picker UI; visitors have to know skill slugs by
heart.
The new endpoint mirrors the /stream auth chain (resolveChannel +
verifyVisitorToken) and reuses AgentBindingResolver.getBoundSkillIds
to scope visibility. Only enabled skills explicitly bound to the agent
surface; agents with no explicit bindings return an empty list rather
than inheriting the global pool (the agent config stays the source of
truth for what surfaces in visitor UI). The agentId anti-escalation
guard from /stream is reused verbatim — an explicit agentId must
belong to the channel's workspace.
Returns WebChatSkillView (id / name / nameZh / nameEn / description /
icon). Deliberately omits SKILL.md content, configJson and
securityScanResult: those never leave the admin console.
Issue: #373
Previously WebChatController.chatStream silently dropped every agent
lifecycle event except _usage_final (and content_delta / thinking_delta
derived from delta.payload). Visitors sat with nothing between the
meta event and the first content chunk — typically 3–10s when the
agent plans / recalls memory / runs tools, longer when the agent
chained multiple tool calls. The JWT chat path (ChatController) had
this wiring; webchat did not.
Curated 4-event subset (per design review):
- phase — high-level phase transition (planning / generating /
summarizing / ...). SDK shows a "AI is thinking..."
typing indicator before the first token.
- tool_start — agent invoked a tool. SDK shows a localized badge
("Searching...", "Reading file.pdf", ...).
- tool_end — tool completed. SDK clears the badge.
- plan — Plan-Execute agents expose their step list. SDK can
render a checklist.
Deliberately NOT forwarded (internal noise / leak risk):
- _usage_final, _routing_decision — consumed internally
- finish_reason — implicit in `done`
- feedback_event — visitor can't retry/regenerate anyway
- perf_summary, iteration_* — internal metrics
- plan_step_started/completed — too granular; the plan event covers
the visitor's needs
Critical safety constraint: tool_start / tool_end carry ONLY the tool
name. Tool arguments and results are dropped — agent tool calls can
contain PII (file paths, user queries, credentials), and relaying
those to a 3rd-party website frontend is a data leak. The SDK maps
tool name → localized label via its own lookup.
Backward compat: existing clients ignore unknown event types per the
SSE spec, so adding these is non-breaking.
Tests: 5 new cases in WebChatStreamE2ETest covering each event type
+ a regression case asserting internal events are silently dropped.
85/85 webchat tests green.
Docs: docs/zh/webchat.md gains a "实时进度事件" subsection.
Stack: feat/webchat-attachment-e2e → feat/webchat-stream-phase-events
Follow-up to epic #355.
AuditEventService gains recordAs(actor, workspaceId, ...) — an overload
that takes an explicit actor string instead of deriving one from
SecurityContext. Used for non-MateClaw principals (currently just
webchat visitors), where there is no Spring Security auth and the
default record() path would write "system".
WebChatController injects AuditEventService and adds an audit() helper
that constructs the canonical actor string "webchat:<channelId>:<visitorId>"
so audit-event searches can filter by channel or visitor. Eight write
endpoints now log an audit row on success:
webchat.create-session, webchat.rename-session, webchat.pin-session,
webchat.archive-session, webchat.delete-session, webchat.stop-session,
webchat.regenerate-session, webchat.upload-file
/stream is intentionally NOT audited — message-volume noise, and
conversationService.saveMessage already leaves a durable trail.
Each row carries:
- username = webchat:<channelId>:<visitorId>
- action = webchat.<verb>
- resource = CONVERSATION / <conversationId>
- detailJson = {sessionId, ...action-specific fields}
WebChatAuditTrailTest (@SpringBootTest, 2 cases):
- createSession lands a row with the exact actor string + action
- rename + pin + archive + stop each leave a row (4 distinct actions)
Audit insert is async; tests poll up to 3s for the row to appear.
Regression: 9 webchat test classes (64 tests) green.
Part of epic #355.
Two cleanups promised in the plan, kept narrow to avoid cascading churn:
1. New WebChatErrors enum — single source of truth for the visitor-facing
HTTP error codes + messages. All future R.fail() calls can reference
WebChatErrors.INVALID_API_KEY etc. instead of bare literals. This PR
doesn't migrate every existing call site (that's a noisy sweep better
done in a follow-up); the enum just needs to exist so audit/OpenAPI
work in PR 7/8 can quote canonical messages.
2. pageSessions now delegates auth to listSessions instead of duplicating
the resolveChannel + verifyVisitorToken block. Same external behavior;
-15 lines of duplication. The pagination/keyword logic stays where it
is (it's specific to the /page variant and doesn't belong in
listSessions).
Visitor-token `required=true` migration from plan §6 was dropped: changing
it would flip missing-token responses from 401 to 400, which violates the
current error-code contract that visitors and tests rely on. The
`required=false` + explicit-verify pattern stays.
Regression: 7 webchat test classes, 42/42 green.
Part of epic #355.
New endpoint POST /api/v1/channels/webchat/sessions/regenerate. Behavior:
1. Auth (API Key + visitorToken + ownsConversation — same chain as the
other session mutations).
2. streamTracker.requestStop() — kill any in-flight stream first so its
doOnComplete doesn't race the delete/save below.
3. Find last role=user message (seed) and last role=assistant message
(target).
4. Delete the last assistant message if present.
5. Reuse chatStream by handing it a synthetic WebChatRequest whose
message is the seed user content. chatStream saves a fresh user
message (new id, same content) and starts the agent turn.
Trade-off: chatStream saves a new user message rather than replaying the
existing one in place, so the user-side message count grows by 1 per
regenerate. Acceptable — the alternative (refactoring chatStream into
reusable chunks) is a 4-hour distraction from the actual feature, and the
extra row is harmless (history still reads naturally: user, asst, user,
asst, user, asst instead of user, asst, asst).
ConversationService gains findLastMessageByRole() and deleteMessageById()
helpers; both are scoped exactly to what regenerate needs.
WebChatRegenerateTest (@SpringBootTest, 5 cases):
- empty thread (no user message) → error event, no DB change
- deletes the last assistant reply (count strictly decreases)
- bad token → no DB change (auth fails before mutation)
- unknown sessionId → returns emitter without throwing
- seeds from the LAST user message when multiple exist
Tests don't assert on the actual LLM stream content — that's left for
PR 5's WebChatStreamE2ETest, which mocks the chat model.
Part of epic #355.
Two new session-state mutations, both following the rename endpoint's
shape (PUT + {flag: true|false} body + visitorId/sessionId query):
- PUT /api/v1/channels/webchat/sessions/pinned — flips mate_conversation.pinned
- PUT /api/v1/channels/webchat/sessions/archive — flips mate_conversation.archived
Archive complements delete as a "soft-close" — the thread stays on disk
(history preserved, addressable, downloadable) but is hidden from the
default /sessions listing. Pin makes a thread sort first in the visitor's
listing, mirroring the admin-console behavior.
Archive dominates pin: an archived+pinned thread is still hidden by
default. Callers opt back in via includeArchived=true (added in PR 1).
ConversationService gains setArchived(), mirroring the existing
setPinned() pattern.
WebChatArchivePinTest (@SpringBootTest, 6 cases):
- pin flips column + view reflects pinned=1
- archive hides from default listing, includeArchived=true shows it,
un-archive restores
- archived+pinned still hidden (archive dominates)
- malformed body / wrong type → 400
- unknown sessionId → 404
- bad token → 401
Part of epic #355.
Closes the "no way to ban a single visitor without burning the global
JWT secret" gap from the epic. Two changes:
1. Token format: HMAC payload now includes exp, format is
`<base64sig>.<expEpochSec>`. Default TTL 7 days (VISITOR_TOKEN_TTL_SECONDS).
Expiry participates in the HMAC, so bumping it client-side invalidates
the signature. /stream still mints fresh tokens on first contact — a
revoked visitor can start a new /stream (gets a new token), they just
can't use the old one on management endpoints.
2. WebChatTokenRevocationService — DB-backed registry (webchat_revoked_visitor
table from V148) with a 5-minute Caffeine cache in front. revoke() /
unrevoke() / isRevoked(). The cache accepts up to 10min eventual
consistency across instances — webchat is low-volume, and a fresh node
sees revocations immediately on cold cache. DB remains source of truth.
WebChatController.verifyVisitorToken becomes an instance method that chains
verifyVisitorTokenSignature (static, HMAC + exp) with isRevoked (instance,
DB + cache). All 9 management endpoints now check revocation transitively.
Admin endpoint: POST /api/v1/admin/webchat/revoked-visitor (and DELETE to
un-revoke). Mounted under /api/v1/admin/** so it requires a MateClaw JWT
— visitors can't reach it. Records an audit row (action=webchat.revoke-
visitor, resourceType=CHANNEL) via AuditEventService.
Tests:
- WebChatTokenRevocationTest (@SpringBootTest, 7 cases): revoke blocks
/sessions with 401, un-revoke restores, double-revoke idempotent,
expired token rejected even without revocation, /stream unaffected
(signature still verifies), admin endpoint inserts row + audit.
- WebChatVisitorTokenTest extended to 16 cases — added expired-token,
tampered-exp, and "differs when exp differs" coverage; existing
verify_* cases moved to verifyVisitorTokenSignature (the static half).
Regression: WebChatSchemaFieldsTest (5/5), WebChatCreateSessionTest (9/9),
WebChatSessionManagementTest (5/5), WebChatStopStreamTest (5/5).
Part of epic #355.
Schema foundations for the rest of the epic. Three additions:
1. mate_conversation.archived — INT default 0. Lets a visitor "soft-close"
a thread: stays on disk (history preserved, addressable, downloadable)
but excluded from default /sessions listing. Pinned/archived are
orthogonal; archive dominates (archived+pinned still hidden by default).
2. webchat_revoked_visitor — registry table consumed in PR 2 by the
WebChatTokenRevocationService. Unique on (channel_id, visitor_id, deleted)
so re-revoke is idempotent and deleted=1 un-revokes. Migration written
for all three DBs (h2 IF NOT EXISTS, MySQL INFORMATION_SCHEMA guard,
KingbaseES native IF NOT EXISTS) following the V147 pattern.
3. WebChatSessionView gains pinned/archived/streamStatus so the visitor-
side listing surfaces the same state the admin console sees. Closes
the "field exposure" gap from the epic.
loadVisitorSessions gains an includeArchived flag (default false) — the
default hides archived threads and excludes them from the empty-session
quota, since the visitor already declared they're done with them.
GET /sessions and GET /sessions/page thread an `includeArchived=true`
query param through.
Tests (WebChatSchemaFieldsTest, @SpringBootTest + H2 + V148):
- revoked-visitor table is queryable
- archived column is read/write
- view exposes pinned/archived/streamStatus
- archived hidden by default, visible with includeArchived=true
- archived empty threads don't saturate the 5-thread quota
Regression: WebChatCreateSessionTest (9/9), WebChatSessionManagementTest
(5/5) — both updated for the new includeArchived param.
Part of epic #355.
Until now webchat had no way to actually interrupt a running stream —
ChatController's /api/v1/chat/{id}/stop was technically permitAll'd but
silently no-op'd on webchat streams because WebChatController.chatStream
dropped the subscribe() return value, so ChatStreamTracker.requestStop
had no Disposable to dispose. Visitors could only "stop" client-side by
closing the SSE connection; the server-side LLM call kept running,
burning tokens and firing any side-effecting tools to completion.
Two changes (issue #353):
1. WebChatController.chatStream: keep the Disposable and register it
with streamTracker.setDisposable, mirroring ChatController#chatStream
line 495. Now requestStop actually disposes the Flux.
2. New endpoint POST /api/v1/channels/webchat/sessions/stop:
- Auth mirrors the other session-management endpoints: X-MC-Key +
X-MC-Visitor-Token + ownsConversation (404 on unknown sessionId,
so callers can't probe the namespace).
- Returns {stopped: true|false}; false means no active stream
(idempotent, not an error).
- No approval sweep — webchat has no MateClaw username and exposes
no approval UI today; defer until that surfaces.
WebChatStopStreamTest (@SpringBootTest, H2, V147) — 5 cases:
- stopActiveStream registers a real Flux.never() Disposable on the
tracker and asserts both stopped=true AND disposable.isDisposed(),
proving the chatStream wiring change is what makes the endpoint work.
- noActiveStreamReturnsFalse — idempotent path.
- bad token / bad API key → 401.
- unknown sessionId → 404.
Complements the implicit getOrCreate in /stream: lets a caller pre-create
an empty thread (message_count = 0) and receive sessionId / conversationId /
visitorToken up front, then decide when to send the first message via
/stream. Mirrors how downstream CRM/ticketing systems model "create the
conversation object first, message later".
Auth is the visitor's first touch — only X-MC-Key is required (no
X-MC-Visitor-Token, which the visitor can't have yet); the server signs
and returns a fresh visitorToken the caller must echo back on subsequent
GET/PUT/DELETE.
Behavior (issue #351):
- Idempotent on sessionId collision → returns the existing thread 200,
does NOT clobber title.
- Empty-session quota ≤ 5 per (channel, visitor); 409 with a clear
message when exceeded. Existing rows are exempt (re-create is idempotent).
- Caller-supplied title (1-100 chars) is persisted; absent title leaves
the default "新对话" so the first /stream user message still derives
it. getOrCreateWebchatConversation now accepts an optional title and
only writes it on insert (existing rows untouched).
- agentId override mirrors /stream's workspace check.
ConversationService.getOrCreateWebchatConversation gains a title-aware
overload; the original 5-arg signature delegates with title = null.
End-to-end coverage in WebChatCreateSessionTest (@SpringBootTest, H2
with V147 migration): happy path, caller-title survives first user
message, default-title still derived, idempotent collision, quota 409,
bad API key 401, illegal sessionId/title 400, listed after creation.
Two webchat hardening fixes:
- Session listing no longer pulls every system-owned conversation into
memory. listSessions/pageSessions went through listConversations(owner)
whose `username IN (owner, system)` loaded all IM/cron rows just to show
one visitor's handful of threads. New listWebchatConversations(username)
queries only the visitor's own rows; the channel prefix is matched
in-memory with a literal startsWith (so a '_'/'%' in the api key's first
8 chars can't act as a LIKE wildcard).
- Upload now enforces a per-conversation quota (file count + total bytes,
both configurable) so a visitor can't fill the disk with many
individually-under-cap files. Pairs with the existing staging TTL sweep.
When webchat:<key8>:<visitorId>:<sessionId> exceeds 64 chars the
conversationId folds visitorId+sessionId into an unrecoverable hash, so
the thread fell outside listSessions' conversationId-prefix filter and its
sessionId could not be recovered — the thread was invisible and
unaddressable (common with a UUID visitorId + a >10-char sessionId).
Persist the sessionId on creation (new nullable webchat_session_id column)
and enumerate by username + channel prefix (webchat:<key8>:), which also
matches the hashed form. sessionId is read from the column, falling back to
parsing the conversationId only for legacy rows.
Adds a @SpringBootTest covering listing (incl. the hashed thread), message
pagination, session paging/search, rename, and token rejection end-to-end.
Refs matevip/mateclaw#346
Bring the webchat visitor session API closer to the admin console's:
- GET /sessions/messages gains beforeId + limit. With a limit it returns
{messages, hasMore} (latest N, then pull-up for older) using the
external path-stripped view; without it, the full list as before.
- GET /sessions/page paginates + keyword-searches a visitor's threads
(in-memory: a visitor's thread set is bounded to its own namespace).
- PUT /sessions/title renames a thread (1-100 chars).
All keep the webchat auth model (API key + visitor token, server-derived
conversationId, ownership guard). Message views go through the shared
toExternalMessageViews helper so the paginated path is sanitized too.
Refs matevip/mateclaw#346
WebChat had no file support: the /stream body carried only text, and
agent-produced files had no visitor-reachable download path (the JWT
/chat/files endpoint is unreachable for API-key visitors).
Add webchat-authenticated file transfer, reusing MessageContentPart +
the existing upload dir + agent multimodal injection:
- WebChatFileService: validate (size cap, extension whitelist, filename
sanitize), store under the conversation's upload dir, stage by opaque
fileId, traversal-safe resolve. Untrusted-uploader hardening lives here.
- POST /upload (multipart) and GET /files, both authed by API key +
visitor token with a server-derived conversationId (never client paths).
Downloads send non-images as attachment + X-Content-Type-Options:nosniff.
- /stream gains attachmentIds; the server resolves each id from the
staging registry (client metadata is never trusted), builds parts, and
persists them on the user message so the agent's multimodal/file tools
pick them up from history — same path as the JWT web chat.
- Strip server-side file paths from the visitor-facing message view
(listMessageViewsExternal + includePath flag) so the filesystem layout
is not disclosed.
Refs matevip/mateclaw#342
The disk fallback's catch block logged at debug, so a failed scan
silently dropped recovered files — reproducing the same 'bot can't see
the file' symptom the fallback was added to fix. Promote to warn with
the full stack trace, matching cacheRecentFile's logging.
loadRecentFilesFromDisk now filters out files older than RECENT_FILE_TTL_MINUTES
(60 min) so the disk fallback matches the Caffeine cache TTL and does not inject
stale attachments into future conversations.
Testability refactoring:
- recentFileCache: private → package-private (tests can seed the cache directly)
- chatUploadsRoot: new package-private Path field (tests redirect to @TempDir)
- loadRecentFilesFromDisk: add (Path dir, long cutoffMs) package-private overload;
private (String) wrapper delegates to it
- injectRecentFiles: private → package-private
New test class FeishuRecentFileCacheTest (14 cases):
- loadRecentFilesFromDisk: non-existent dir, empty dir, fresh files sorted
newest-first, stale files excluded by TTL, mixed fresh+stale, >5 files capped,
timestamp-prefix stripping, MIME guessing from extension
- injectRecentFiles: Caffeine cache hit, cache-miss disk fallback, empty disk,
duplicate-path dedup, image vs file part typing, null textContent guard
Relates to #325
The per-chat recent file cache (Caffeine, 60 min TTL) is purely
in-memory. After a process restart, GC eviction, or TTL expiry the
cache is empty, but the staged copies under data/chat-uploads/ survive
on disk. A follow-up text message that should have seen the cached file
instead found nothing — the bot replied as if no file was ever sent.
Changes:
- injectRecentFiles(): fall back to scanning data/chat-uploads/{id}/
when the Caffeine cache misses, sorted by last-modified time, capped
at RECENT_FILE_MAX_PER_CHAT (5).
- cacheRecentFile(): promote catch log from debug → warn with full
stack trace so silent download failures are visible in production
logs. Add entry-level info log for correlation.
- New helper loadRecentFilesFromDisk() + guessContentType().
Closes#325
Relates to #201
Add KingbaseES support as an opt-in profile: dedicated migration tree, bilingual seed data, runtime DbType detection (KINGBASE_ES / POSTGRE_SQL), and JDBC URL handling in the datasource manager.
The webchat conversationId (webchat:<key8>:<visitorId>[:<sessionId>]) and the
derived username (webchat:<visitorId>) are written to VARCHAR(64) columns, but
visitorId had no validation and sessionId allows 64 chars — so a long visitorId,
or a legitimate 64-char sessionId, overflows the column and the getOrCreateConversation
INSERT throws (500 on /stream). Validate visitorId (charset + blank->UUID) and
fold the variable part into a stable hash when the derived id/username would
exceed 64 chars, keeping short ids byte-identical (backward compatible). Also
make listSessions filter on exact owner username, not just the conversationId
prefix, so system-owned rows can never leak via a crafted visitorId. Adds
boundary regression tests.
Add per-visitor session management for the WebChat Web/API access mode:
list a visitor's conversation threads, fetch a thread's messages, and
delete a thread.
Authorization: visitorId is a client-asserted request param, so it cannot
be trusted on its own — deriving conversationId from it and then checking
ownership against it is tautological (any caller passes). Instead, /stream
issues a per-visitor token = HMAC-SHA256(jwtSecret, channelId:visitorId),
returned in the meta event; the management endpoints require it back via
the X-MC-Visitor-Token header and verify it in constant time. The signing
secret is server-only (unlike the public channel API key) and the channelId
in the payload makes tokens non-portable across channels.
Includes regression tests for token issuance/verification semantics
(forged visitorId rejected, cross-visitor and cross-channel tokens rejected,
tampered tokens rejected).
The WebChat stream endpoint resolves agentId only when the
(visitorId + sessionId) conversation is first created; later requests
with a different agentId reuse the existing conversation's agent and
silently ignore the new value. Document this on WebChatRequest.agentId
so integrators know to use a new sessionId to reach another agent.
The mention alias-learning fed every identifier of every mention in a
delivery into the per-chat alias cache. A single delivery of "@bot @alice"
matched the bot by its global id and then learned alice's openId as a bot
alias, so every later "@alice" message was misdetected as @bot and the agent
replied to messages never addressed to it.
Only single-mention deliveries are unambiguous bot identities, so restrict
alias learning to them — a multi-mention delivery mixes the bot with
co-mentioned humans, and Feishu's dual-delivery alias form is itself a single
mention, so this is safe and keeps the learning feature working. Also cap the
per-chat alias set size. Adds a [bot, human] co-mention regression test.
Rename the webchat channel from "embed widget" to "Web / API access" (key
unchanged, docs/i18n only) and extend the backend SSE endpoint for pure
backend integration:
- WebChatRequest gains optional agentId (route one Key to multiple agents;
rejected unless the agent shares the channel's workspace) and sessionId
(one visitor, multiple isolated threads).
- sessionId is validated ([A-Za-z0-9_-]{1,64}) and only composed into the
server-derived conversationId — raw conversationIds are never accepted, so
the key+visitor namespace still bounds every thread.
- A `meta` SSE event echoes the effective sessionId/conversationId at stream
start so callers can persist and re-address a thread.
- Memory stays attributed per visitor (api:<visitorId>), shared across that
visitor's sessions.
All new fields are optional; omitting them reproduces the prior behaviour
byte-for-byte. Refs matevip/mateclaw#295.
ChannelMessageEventBridge.onChannelMessage() ran synchronously on the Lark
SDK WebSocket dispatch thread; when the DB pool was saturated its ingest
query blocked that thread and subsequent messages were silently dropped.
- @Async moves trigger ingest onto the (vthread + SecurityContext-propagating)
async executor, freeing the WS dispatch thread.
- Add a 10s timeout to the refreshTenantAccessToken() HTTP request, which
previously had none.
Scope narrowed per review to only fix#208: the @mention alias learning and
session-id changes are dropped (to be raised as separate PRs), and dev's
existing message dedup is left untouched.
Closes#208
Same-message attachment reads failed on Feishu: the content part carried the sandbox-external ~/.mateclaw/media/ path, so read_file/extract_document_text were rejected by WorkspacePathGuard and ChatUploadResolver's basename fallback missed (media names {messageId}_{key} vs chat-uploads {millis}_{fileName}). cacheRecentFile already copies the attachment into the per-conversation data/chat-uploads/ dir; return that absolute path and stamp it onto the current message's image/file/audio/media parts so the path surfaced to the LLM is resolver-reachable. Reuses the existing copy — no extra I/O, no new state. Rich-text post multi-image and other channels sharing the media dir are noted as follow-ups.
Closes#278