mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
b080dd1e76
* refactor: consolidate coercion contracts Centralize exact string, record, numeric, date, Boolean, argument, and structured-error coercions while preserving call-site semantics. Migrate canonical-name collisions and deprecated internal SDK bypasses, deleting 55 net production/tooling lines. Expand declaration ownership enforcement to 101 allowed helpers and add a narrow export-completeness audit. * fix: preserve standalone script coercions Keep copied Control UI tooling self-contained and retain the trusted release harness module-relative source seam when the harness runs against an old target cwd.
453 lines
13 KiB
TypeScript
453 lines
13 KiB
TypeScript
import { resolveAllowlistMatchByCandidates } from "openclaw/plugin-sdk/allow-from";
|
|
import {
|
|
formatAgentEnvelope,
|
|
implicitMentionKindWhen,
|
|
resolveEnvelopeFormatOptions,
|
|
resolveInboundMentionDecision,
|
|
} from "openclaw/plugin-sdk/channel-inbound";
|
|
import {
|
|
resolveChannelImplicitMentions,
|
|
resolveStableChannelMessageIngress,
|
|
type StableChannelIngressIdentityParams,
|
|
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
// Tlon helper module supports utils behavior.
|
|
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
|
|
import { asNullableRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { normalizeShip } from "../targets.js";
|
|
|
|
export interface ParsedCite {
|
|
type: "chan" | "group" | "desk" | "bait";
|
|
nest?: string;
|
|
author?: string;
|
|
postId?: string;
|
|
group?: string;
|
|
flag?: string;
|
|
where?: string;
|
|
}
|
|
|
|
export function extractCites(content: unknown): ParsedCite[] {
|
|
if (!content || !Array.isArray(content)) {
|
|
return [];
|
|
}
|
|
|
|
const cites: ParsedCite[] = [];
|
|
|
|
for (const verse of content) {
|
|
if (verse?.block?.cite && typeof verse.block.cite === "object") {
|
|
const cite = verse.block.cite;
|
|
|
|
if (cite.chan && typeof cite.chan === "object") {
|
|
const { nest, where } = cite.chan;
|
|
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
|
|
cites.push({
|
|
type: "chan",
|
|
nest,
|
|
where,
|
|
author: whereMatch?.[1],
|
|
postId: whereMatch?.[2],
|
|
});
|
|
} else if (cite.group && typeof cite.group === "string") {
|
|
cites.push({ type: "group", group: cite.group });
|
|
} else if (cite.desk && typeof cite.desk === "object") {
|
|
cites.push({ type: "desk", flag: cite.desk.flag, where: cite.desk.where });
|
|
} else if (cite.bait && typeof cite.bait === "object") {
|
|
cites.push({
|
|
type: "bait",
|
|
group: cite.bait.group,
|
|
nest: cite.bait.graph,
|
|
where: cite.bait.where,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return cites;
|
|
}
|
|
|
|
export function formatModelName(modelString?: string | null): string {
|
|
if (!modelString) {
|
|
return "AI";
|
|
}
|
|
const modelName = modelString.includes("/")
|
|
? expectDefined(modelString.split("/").at(1), "provider/model second segment")
|
|
: modelString;
|
|
const modelMappings: Record<string, string> = {
|
|
"claude-opus-4-5": "Claude Opus 4.5",
|
|
"claude-sonnet-4-5": "Claude Sonnet 4.5",
|
|
"claude-sonnet-3-5": "Claude Sonnet 3.5",
|
|
"gpt-4o": "GPT-4o",
|
|
"gpt-4-turbo": "GPT-4 Turbo",
|
|
"gpt-4": "GPT-4",
|
|
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
|
"gemini-pro": "Gemini Pro",
|
|
};
|
|
|
|
const mappedName = modelMappings[modelName];
|
|
if (mappedName !== undefined) {
|
|
return mappedName;
|
|
}
|
|
return modelName
|
|
.replace(/-/g, " ")
|
|
.split(" ")
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
export function isBotMentioned(
|
|
messageText: string,
|
|
botShipName: string,
|
|
nickname?: string,
|
|
): boolean {
|
|
if (!messageText || !botShipName) {
|
|
return false;
|
|
}
|
|
|
|
if (/@all\b/i.test(messageText)) {
|
|
return true;
|
|
}
|
|
|
|
const normalizedBotShip = normalizeShip(botShipName);
|
|
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
|
|
if (mentionPattern.test(messageText)) {
|
|
return true;
|
|
}
|
|
|
|
if (nickname) {
|
|
const escapedNickname = nickname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const nicknamePattern = new RegExp(`(^|\\s)${escapedNickname}(?=\\s|$|[,!?.])`, "i");
|
|
if (nicknamePattern.test(messageText)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function stripBotMention(messageText: string, botShipName: string): string {
|
|
if (!messageText || !botShipName) {
|
|
return messageText;
|
|
}
|
|
return messageText.replace(normalizeShip(botShipName), "").trim();
|
|
}
|
|
|
|
const tlonIngressIdentity = {
|
|
key: "sender-ship",
|
|
normalize: normalizeShip,
|
|
sensitivity: "pii",
|
|
isWildcardEntry: () => false,
|
|
entryIdPrefix: "tlon-entry",
|
|
} satisfies StableChannelIngressIdentityParams;
|
|
|
|
export async function isDmAllowedWithIngress(
|
|
senderShip: string,
|
|
allowlist: string[] | undefined,
|
|
): Promise<boolean> {
|
|
const access = await resolveStableChannelMessageIngress({
|
|
channelId: "tlon",
|
|
accountId: "default",
|
|
identity: tlonIngressIdentity,
|
|
subject: { stableId: senderShip },
|
|
conversation: {
|
|
kind: "direct",
|
|
id: "direct",
|
|
},
|
|
dmPolicy: "allowlist",
|
|
allowFrom: allowlist ?? [],
|
|
});
|
|
return access.senderAccess.allowed;
|
|
}
|
|
|
|
export async function resolveTlonCommandAuthorizationWithIngress(params: {
|
|
senderShip: string;
|
|
ownerShip: string | null | undefined;
|
|
useAccessGroups: boolean;
|
|
}) {
|
|
const normalizedOwner = params.ownerShip ? normalizeShip(params.ownerShip) : null;
|
|
return await resolveStableChannelMessageIngress({
|
|
channelId: "tlon",
|
|
accountId: "default",
|
|
identity: tlonIngressIdentity,
|
|
useAccessGroups: params.useAccessGroups,
|
|
subject: { stableId: params.senderShip },
|
|
conversation: {
|
|
kind: "direct",
|
|
id: "command",
|
|
},
|
|
event: {
|
|
authMode: "none",
|
|
mayPair: false,
|
|
},
|
|
dmPolicy: "allowlist",
|
|
groupPolicy: "open",
|
|
allowFrom: normalizedOwner ? [normalizedOwner] : [],
|
|
command: {},
|
|
});
|
|
}
|
|
|
|
export function resolveTlonGroupMentionDecision(params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
wasMentioned: boolean;
|
|
botParticipatedInThread: boolean;
|
|
}) {
|
|
const implicitMentions = resolveChannelImplicitMentions({
|
|
cfg: params.cfg,
|
|
channel: "tlon",
|
|
accountId: params.accountId,
|
|
});
|
|
return resolveInboundMentionDecision({
|
|
facts: {
|
|
canDetectMention: true,
|
|
wasMentioned: params.wasMentioned,
|
|
implicitMentionKinds: implicitMentionKindWhen(
|
|
"bot_thread_participant",
|
|
params.botParticipatedInThread,
|
|
),
|
|
},
|
|
policy: {
|
|
isGroup: true,
|
|
requireMention: true,
|
|
implicitMentions,
|
|
allowTextCommands: false,
|
|
hasControlCommand: false,
|
|
commandAuthorized: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function isGroupInviteAllowed(
|
|
inviterShip: string,
|
|
allowlist: string[] | undefined,
|
|
): boolean {
|
|
const normalizedInviter = normalizeShip(inviterShip);
|
|
return resolveAllowlistMatchByCandidates({
|
|
allowList: (allowlist ?? []).map((ship) => normalizeShip(ship)),
|
|
candidates: [{ value: normalizedInviter, source: "ship" }],
|
|
}).allowed;
|
|
}
|
|
|
|
export async function resolveAuthorizedMessageText(params: {
|
|
rawText: string;
|
|
content: unknown;
|
|
authorizedForCites: boolean;
|
|
resolveAllCites: (content: unknown) => Promise<string>;
|
|
}): Promise<string> {
|
|
const { rawText, content, authorizedForCites, resolveAllCites } = params;
|
|
if (!authorizedForCites) {
|
|
return rawText;
|
|
}
|
|
const citedContent = await resolveAllCites(content);
|
|
return citedContent + rawText;
|
|
}
|
|
|
|
// Helper to recursively extract text from inline content
|
|
function renderInlineItem(
|
|
item: unknown,
|
|
options?: {
|
|
linkMode?: "content-or-href" | "href";
|
|
allowBreak?: boolean;
|
|
allowBlockquote?: boolean;
|
|
},
|
|
): string {
|
|
if (typeof item === "string") {
|
|
return item;
|
|
}
|
|
const record = asNullableRecord(item);
|
|
if (!record) {
|
|
return "";
|
|
}
|
|
const ship = readStringField(record, "ship");
|
|
if (ship) {
|
|
return ship;
|
|
}
|
|
if ("sect" in record) {
|
|
const sect = record.sect;
|
|
if (typeof sect === "string") {
|
|
return `@${sect || "all"}`;
|
|
}
|
|
if (sect === null) {
|
|
return "@all";
|
|
}
|
|
}
|
|
if (options?.allowBreak && "break" in record) {
|
|
return "\n";
|
|
}
|
|
const inlineCode = readStringField(record, "inline-code");
|
|
if (inlineCode) {
|
|
return `\`${inlineCode}\``;
|
|
}
|
|
const code = readStringField(record, "code");
|
|
if (code) {
|
|
return `\`${code}\``;
|
|
}
|
|
const link = asNullableRecord(record.link);
|
|
const linkHref = link ? readStringField(link, "href") : undefined;
|
|
if (link && linkHref) {
|
|
const linkContent = readStringField(link, "content");
|
|
return options?.linkMode === "href" ? linkHref : linkContent || linkHref;
|
|
}
|
|
if (Array.isArray(record.bold)) {
|
|
return `**${extractInlineText(record.bold)}**`;
|
|
}
|
|
if (Array.isArray(record.italics)) {
|
|
return `*${extractInlineText(record.italics)}*`;
|
|
}
|
|
if (Array.isArray(record.strike)) {
|
|
return `~~${extractInlineText(record.strike)}~~`;
|
|
}
|
|
if (options?.allowBlockquote && Array.isArray(record.blockquote)) {
|
|
return `> ${extractInlineText(record.blockquote)}`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function extractInlineText(items: readonly unknown[]): string {
|
|
return items.map((item) => renderInlineItem(item)).join("");
|
|
}
|
|
|
|
export function extractMessageText(content: unknown): string {
|
|
if (!content || !Array.isArray(content)) {
|
|
return "";
|
|
}
|
|
|
|
return content
|
|
.map((verse) => {
|
|
const verseRecord = asNullableRecord(verse);
|
|
if (!verseRecord) {
|
|
return "";
|
|
}
|
|
|
|
// Handle inline content (text, ships, links, etc.)
|
|
if (Array.isArray(verseRecord.inline)) {
|
|
return verseRecord.inline
|
|
.map((item) =>
|
|
renderInlineItem(item, {
|
|
linkMode: "href",
|
|
allowBreak: true,
|
|
allowBlockquote: true,
|
|
}),
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Handle block content (images, code blocks, etc.)
|
|
const block = asNullableRecord(verseRecord.block);
|
|
if (block) {
|
|
const image = asNullableRecord(block.image);
|
|
|
|
// Image blocks
|
|
if (image) {
|
|
const imageSrc = readStringField(image, "src");
|
|
if (imageSrc) {
|
|
const altText = readStringField(image, "alt");
|
|
const alt = altText ? ` (${altText})` : "";
|
|
return `\n${imageSrc}${alt}\n`;
|
|
}
|
|
}
|
|
|
|
// Code blocks
|
|
const codeBlock = asNullableRecord(block.code);
|
|
if (codeBlock) {
|
|
const lang = readStringField(codeBlock, "lang") ?? "";
|
|
const code = readStringField(codeBlock, "code") ?? "";
|
|
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
|
|
}
|
|
|
|
// Header blocks
|
|
const header = asNullableRecord(block.header);
|
|
if (header) {
|
|
const headerContent = Array.isArray(header.content) ? header.content : [];
|
|
const text =
|
|
headerContent.map((item) => (typeof item === "string" ? item : "")).join("") || "";
|
|
return `\n## ${text}\n`;
|
|
}
|
|
|
|
// Cite/quote blocks - parse the reference structure
|
|
const cite = asNullableRecord(block.cite);
|
|
if (cite) {
|
|
const chanCite = asNullableRecord(cite.chan);
|
|
|
|
// ChanCite - reference to a channel message
|
|
if (chanCite) {
|
|
const nest = readStringField(chanCite, "nest");
|
|
const where = readStringField(chanCite, "where");
|
|
// where is typically /msg/~author/timestamp
|
|
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
|
|
if (whereMatch) {
|
|
const [, author, _postId] = whereMatch;
|
|
return `\n> [quoted: ${author} in ${nest}]\n`;
|
|
}
|
|
return `\n> [quoted from ${nest}]\n`;
|
|
}
|
|
|
|
// GroupCite - reference to a group
|
|
const group = readStringField(cite, "group");
|
|
if (group) {
|
|
return `\n> [ref: group ${group}]\n`;
|
|
}
|
|
|
|
// DeskCite - reference to an app/desk
|
|
const desk = asNullableRecord(cite.desk);
|
|
if (desk) {
|
|
const flag = readStringField(desk, "flag");
|
|
if (flag) {
|
|
return `\n> [ref: ${flag}]\n`;
|
|
}
|
|
}
|
|
|
|
// BaitCite - reference with group+graph context
|
|
const bait = asNullableRecord(cite.bait);
|
|
if (bait) {
|
|
const graph = readStringField(bait, "graph");
|
|
const groupName = readStringField(bait, "group");
|
|
if (graph && groupName) {
|
|
return `\n> [ref: ${graph} in ${groupName}]\n`;
|
|
}
|
|
}
|
|
|
|
return `\n> [quoted message]\n`;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
})
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
export function isSummarizationRequest(messageText: string): boolean {
|
|
const patterns = [
|
|
/summarize\s+(this\s+)?(channel|chat|conversation)/i,
|
|
/what\s+did\s+i\s+miss/i,
|
|
/catch\s+me\s+up/i,
|
|
/channel\s+summary/i,
|
|
/tldr/i,
|
|
];
|
|
return patterns.some((pattern) => pattern.test(messageText));
|
|
}
|
|
|
|
/**
|
|
* Formats channel history for a summarization request. Each entry is rendered
|
|
* through the shared inbound envelope so timestamps honor the configured user
|
|
* timezone instead of the host process zone (matches Mattermost/Feishu).
|
|
*/
|
|
export function formatSummarizationHistoryText(
|
|
history: ReadonlyArray<{ author: string; content: string; timestamp: number }>,
|
|
cfg?: OpenClawConfig,
|
|
): string {
|
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
|
return history
|
|
.map((msg) =>
|
|
formatAgentEnvelope({
|
|
channel: "Tlon",
|
|
from: msg.author,
|
|
timestamp: msg.timestamp,
|
|
body: msg.content,
|
|
envelope: envelopeOptions,
|
|
}),
|
|
)
|
|
.join("\n");
|
|
}
|