mateclaw/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java
2026-08-18 01:43:15 -04:00

214 lines
10 KiB
Java

package vip.mate.agent.context;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.lang.Nullable;
import java.util.Map;
/**
* Immutable value object that travels alongside an agent invocation describing
* <em>where the request came from</em> — channel, conversation, requester,
* workspace, and optional delivery target.
*
* <p>Replaces ad-hoc ThreadLocal threading (RFC-063 v1) with explicit Spring AI
* {@link ToolContext} carriage (RFC-063r §2.1). The wither-style API enables
* the agent runtime to enrich the origin (agentId, workspace) without mutation.
*
* <h2>Field evolution rule</h2>
* <ul>
* <li>Only add — never delete; deprecate at least 90 days (covers approval TTL)
* before physical removal.</li>
* <li>Never rename — add a new field plus deprecate-old-field, double-write
* during the migration window.</li>
* <li>{@link JsonIgnoreProperties#ignoreUnknown()} guards forward/backward
* compatibility when older approval rows are deserialized after upgrades.</li>
* </ul>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ChatOrigin(
@Nullable Long agentId,
@Nullable String conversationId,
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable Long channelId,
@Nullable ChannelTarget channelTarget,
// True only when the agent invocation was triggered by the scheduled-job
// runner. An explicit discriminator (rather than inferring from
// requesterId/channelId) so the runtime can branch on "is this a cron
// run" without coupling to factory internals.
boolean cronOrigin,
/**
* Display name of the user that sent the inbound IM message. Used by
* the prompt-context injector so the agent's system prompt can
* personalise replies ("You are talking to {{senderName}}"). Null
* for non-IM origins (web, cron). {@code requesterId} carries the
* stable identifier; this one is purely the human-readable surface.
*/
@Nullable String senderName,
/**
* Source channel type ("feishu" / "wecom" / "dingtalk" / ...).
* Lets the agent know which platform it's responding on, e.g. to
* tailor formatting or hint at supported features.
*/
@Nullable String channelType,
/**
* Group / chat identifier for IM channels — distinguishes private
* vs. group conversations. Null for 1:1 chats. Distinct from
* {@link #channelTarget()} (which targets cron / proactive sends).
*/
@Nullable String chatId,
/**
* Public base URL ({@code scheme://host[:port][/contextPath]}) resolved
* from the inbound HTTP request on the request thread. Carried here so
* tools running on async/streaming threads — where no request is bound —
* can still mint absolute download links. Null for IM/cron origins, which
* have no request host; those rely on {@code mateclaw.server.public-base-url}.
*/
@Nullable String baseUrl,
/**
* Immutable numeric id of the MateClaw user behind this request, when the
* requester is an <em>authenticated</em> account (JWT/PAT login via the
* web console). Null for non-account origins — webchat visitors, IM
* senders, cron — which carry no MateClaw user row. On-behalf-of identity
* forwarding uses this to tell "MateClaw authenticated this user" apart
* from "this is an external/anonymous identifier" (RFC: identity typing).
*/
@Nullable Long requesterUserId,
@Nullable Long originMessageId
) {
public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
@Nullable String requesterId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable Long channelId,
@Nullable ChannelTarget channelTarget, boolean cronOrigin,
@Nullable String senderName, @Nullable String channelType,
@Nullable String chatId, @Nullable String baseUrl,
@Nullable Long requesterUserId) {
this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType,
chatId, baseUrl, requesterUserId, null);
}
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
public static final String CTX_KEY = "mateclaw.chatOrigin";
/** Sentinel used by AgentService default overloads where no origin is supplied. */
public static final ChatOrigin EMPTY =
new ChatOrigin(null, null, "", null, null, null, null, false,
null, null, null, null, null, null);
// ---------------- Factories per entry point ----------------
public static ChatOrigin web(@Nullable String conversationId,
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath) {
return web(conversationId, requesterId, workspaceId, workspaceBasePath, null);
}
public static ChatOrigin web(@Nullable String conversationId,
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable String baseUrl) {
return web(conversationId, requesterId, workspaceId, workspaceBasePath, baseUrl, null);
}
/**
* Web-console origin that also carries the authenticated user's immutable
* numeric id. Use this overload from the authenticated web entry point so
* on-behalf-of identity forwarding can assert "MateClaw authenticated this
* user" rather than an external/anonymous identifier.
*/
public static ChatOrigin web(@Nullable String conversationId,
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable String baseUrl,
@Nullable Long requesterUserId) {
return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "",
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
requesterUserId, null);
}
public static ChatOrigin cron(@Nullable String conversationId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable Long channelId,
@Nullable ChannelTarget target) {
return new ChatOrigin(null, conversationId, "system",
workspaceId, workspaceBasePath, channelId, target, true,
null, null, null, null, null, null);
}
// ---------------- Wither-style updates ----------------
public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
}
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
}
public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
}
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId);
}
/**
* Carry the inbound message's sender display name, source channel
* type, and chat (group) id. Called by the channel-side origin
* factory so prompt-context injection can show the agent "who"
* is talking and "where".
*/
public ChatOrigin withSender(@Nullable String newSenderName,
@Nullable String newChannelType,
@Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId);
}
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId);
}
// ---------------- Spring AI ToolContext interop ----------------
/** Wrap this origin into a Spring AI {@link ToolContext} the runtime can pass to tools. */
public ToolContext toToolContext() {
return new ToolContext(Map.of(CTX_KEY, this));
}
/**
* Read a {@link ChatOrigin} stored under {@link #CTX_KEY} in the given
* {@link ToolContext}. Returns {@link #EMPTY} when {@code ctx} is null, has
* no entry, or the value is not a ChatOrigin (defensive — keeps single-tool
* callers safe even if wiring is partial).
*/
public static ChatOrigin from(@Nullable ToolContext ctx) {
if (ctx == null) return EMPTY;
Object v = ctx.getContext().get(CTX_KEY);
return v instanceof ChatOrigin co ? co : EMPTY;
}
}