import { formatErrorMessage as sharedFormatErrorMessage } from "openclaw/plugin-sdk/error-runtime" ;
import { normalizeShip } from "../targets.js" ;
// Cite types for message references
export interface ChanCite {
chan: { nest: string; where: string };
}
export interface GroupCite {
group: string;
}
export interface DeskCite {
desk: { flag: string; where: string };
}
export interface BaitCite {
bait: { group: string; graph: string; where: string };
}
export type Cite = ChanCite | GroupCite | DeskCite | BaitCite;
export interface ParsedCite {
type: "chan" | "group" | "desk" | "bait" ;
nest?: string;
author?: string;
postId?: string;
group?: string;
flag?: string;
where?: string;
}
// Extract all cites from message content
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("/" ) ? modelString.split("/" )[1 ] : 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" ,
};
if (modelMappings[modelName]) {
return modelMappings[modelName];
}
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 ;
}
// Check for @all mention
if (/@all\b/i.test(messageText)) {
return true ;
}
// Check for ship mention
const normalizedBotShip = normalizeShip(botShipName);
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&" );
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i" );
if (mentionPattern.test(messageText)) {
return true ;
}
// Check for nickname mention (case-insensitive, word boundary)
if (nickname) {
const escapedNickname = nickname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&" );
const nicknamePattern = new RegExp(`(^|\\s)${escapedNickname}(?=\\s|$|[,!?.])`, "i" );
if (nicknamePattern.test(messageText)) {
return true ;
}
}
return false ;
}
/**
* Strip bot ship mention from message text for command detection .
* " ~ bot - ship / status " → " / status "
*/
export function stripBotMention(messageText: string, botShipName: string): string {
if (!messageText || !botShipName) {
return messageText;
}
return messageText.replace(normalizeShip(botShipName), "" ).trim();
}
export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean {
if (!allowlist || allowlist.length === 0 ) {
return false ;
}
const normalizedSender = normalizeShip(senderShip);
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedSender);
}
/**
* Check if a group invite from a ship should be auto - accepted .
*
* SECURITY : Fail - safe to deny . If allowlist is empty or undefined ,
* ALL invites are rejected - even if autoAcceptGroupInvites is enabled .
* This prevents misconfigured bots from accepting malicious invites .
*/
export function isGroupInviteAllowed(
inviterShip: string,
allowlist: string[] | undefined,
): boolean {
// SECURITY: Fail-safe to deny when no allowlist configured
if (!allowlist || allowlist.length === 0 ) {
return false ;
}
const normalizedInviter = normalizeShip(inviterShip);
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedInviter);
}
/**
* Resolve quoted / cited content only after the caller has passed authorization .
* Unauthorized paths must keep raw text and must not trigger cross - channel cite fetches .
*/
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;
}
export const asRecord = asNullableObjectRecord;
export const formatErrorMessage = sharedFormatErrorMessage;
export const readString = readStringField;
function asNullableObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null ;
}
function readStringField(
record: Record<string, unknown> | null | undefined,
field: string,
): string | undefined {
const value = record?.[field];
return typeof value === "string" ? value : undefined;
}
// 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 = asRecord(item);
if (!record) {
return "" ;
}
const ship = readString(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 = readString(record, "inline-code" );
if (inlineCode) {
return `\`${inlineCode}\``;
}
const code = readString(record, "code" );
if (code) {
return `\`${code}\``;
}
const link = asRecord(record.link);
const linkHref = link ? readString(link, "href" ) : undefined;
if (link && linkHref) {
const linkContent = readString(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 = asRecord(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 = asRecord(verseRecord.block);
if (block) {
const image = asRecord(block.image);
// Image blocks
if (image) {
const imageSrc = readString(image, "src" );
if (imageSrc) {
const altText = readString(image, "alt" );
const alt = altText ? ` (${altText})` : "" ;
return `\n${imageSrc}${alt}\n`;
}
}
// Code blocks
const codeBlock = asRecord(block.code);
if (codeBlock) {
const lang = readString(codeBlock, "lang" ) ?? "" ;
const code = readString(codeBlock, "code" ) ?? "" ;
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
}
// Header blocks
const header = asRecord(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 = asRecord(block.cite);
if (cite) {
const chanCite = asRecord(cite.chan);
// ChanCite - reference to a channel message
if (chanCite) {
const nest = readString(chanCite, "nest" );
const where = readString(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 = readString(cite, "group" );
if (group) {
return `\n> [ref: group ${group}]\n`;
}
// DeskCite - reference to an app/desk
const desk = asRecord(cite.desk);
if (desk) {
const flag = readString(desk, "flag" );
if (flag) {
return `\n> [ref: ${flag}]\n`;
}
}
// BaitCite - reference with group+graph context
const bait = asRecord(cite.bait);
if (bait) {
const graph = readString(bait, "graph" );
const groupName = readString(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));
}
export function formatChangesDate(daysAgo = 5 ): string {
const now = new Date();
const targetDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000 );
const year = targetDate.getFullYear();
const month = targetDate.getMonth() + 1 ;
const day = targetDate.getDate();
return `~${year}.${month}.${day}..20 .19 .51 ..9 b9d`;
}
Messung V0.5 in Prozent C=99 H=98 G=98
¤ Dauer der Verarbeitung: 0.6 Sekunden
¤
*© Formatika GbR, Deutschland