import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime" ;
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime" ;
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime" ;
import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime" ;
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime" ;
import { resolveSignalAccount } from "./accounts.js" ;
import { signalRpcRequest } from "./client.js" ;
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js" ;
import { resolveSignalRpcContext } from "./rpc-context.js" ;
export type SignalSendOpts = {
cfg: OpenClawConfig;
baseUrl?: string;
account?: string;
accountId?: string;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
maxBytes?: number;
timeoutMs?: number;
textMode?: "markdown" | "plain" ;
textStyles?: SignalTextStyleRange[];
};
export type SignalSendResult = {
messageId: string;
timestamp?: number;
};
export type SignalRpcOpts = Pick<
SignalSendOpts,
"cfg" | "baseUrl" | "account" | "accountId" | "timeoutMs"
>;
export type SignalReceiptType = "read" | "viewed" ;
type SignalTarget =
| { type: "recipient" ; recipient: string }
| { type: "group" ; groupId: string }
| { type: "username" ; username: string };
async function resolveSignalRpcAccountInfo(opts: SignalRpcOpts) {
if (opts.baseUrl?.trim() && opts.account?.trim()) {
return undefined;
}
if (!opts.cfg) {
throw new Error(
"Signal RPC account resolution requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path." ,
);
}
const cfg = requireRuntimeConfig(opts.cfg, "Signal RPC account resolution" );
return resolveSignalAccount({
cfg,
accountId: opts.accountId,
});
}
function parseTarget(raw: string): SignalTarget {
let value = raw.trim();
if (!value) {
throw new Error("Signal recipient is required" );
}
const lower = normalizeLowercaseStringOrEmpty(value);
if (lower.startsWith("signal:" )) {
value = value.slice("signal:" .length).trim();
}
const normalized = normalizeLowercaseStringOrEmpty(value);
if (normalized.startsWith("group:" )) {
return { type: "group" , groupId: value.slice("group:" .length).trim() };
}
if (normalized.startsWith("username:" )) {
return {
type: "username" ,
username: value.slice("username:" .length).trim(),
};
}
if (normalized.startsWith("u:" )) {
return { type: "username" , username: value.trim() };
}
return { type: "recipient" , recipient: value };
}
type SignalTargetParams = {
recipient?: string[];
groupId?: string;
username?: string[];
};
type SignalTargetAllowlist = {
recipient?: boolean ;
group?: boolean ;
username?: boolean ;
};
function buildTargetParams(
target: SignalTarget,
allow: SignalTargetAllowlist,
): SignalTargetParams | null {
if (target.type === "recipient" ) {
if (!allow.recipient) {
return null ;
}
return { recipient: [target.recipient] };
}
if (target.type === "group" ) {
if (!allow.group) {
return null ;
}
return { groupId: target.groupId };
}
if (target.type === "username" ) {
if (!allow.username) {
return null ;
}
return { username: [target.username] };
}
return null ;
}
export async function sendMessageSignal(
to: string,
text: string,
opts: SignalSendOpts,
): Promise<SignalSendResult> {
const cfg = requireRuntimeConfig(opts.cfg, "Signal send" );
const accountInfo = resolveSignalAccount({
cfg,
accountId: opts.accountId,
});
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const target = parseTarget(to);
let message = text ?? "" ;
let messageFromPlaceholder = false ;
let textStyles: SignalTextStyleRange[] = [];
const textMode = opts.textMode ?? "markdown" ;
const maxBytes = (() => {
if (typeof opts.maxBytes === "number" ) {
return opts.maxBytes;
}
if (typeof accountInfo.config.mediaMaxMb === "number" ) {
return accountInfo.config.mediaMaxMb * 1024 * 1024 ;
}
if (typeof cfg.agents?.defaults?.mediaMaxMb === "number" ) {
return cfg.agents.defaults.mediaMaxMb * 1024 * 1024 ;
}
return 8 * 1024 * 1024 ;
})();
let attachments: string[] | undefined;
if (opts.mediaUrl?.trim()) {
const resolved = await resolveOutboundAttachmentFromUrl(opts.mediaUrl.trim(), maxBytes, {
mediaAccess: opts.mediaAccess,
localRoots: opts.mediaLocalRoots,
readFile: opts.mediaReadFile,
});
attachments = [resolved.path];
const kind = kindFromMime(resolved.contentType ?? undefined);
if (!message && kind) {
// Avoid sending an empty body when only attachments exist.
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;
messageFromPlaceholder = true ;
}
}
if (message.trim() && !messageFromPlaceholder) {
if (textMode === "plain" ) {
textStyles = opts.textStyles ?? [];
} else {
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "signal" ,
accountId: accountInfo.accountId,
});
const formatted = markdownToSignalText(message, { tableMode });
message = formatted.text;
textStyles = formatted.styles;
}
}
if (!message.trim() && (!attachments || attachments.length === 0 )) {
throw new Error("Signal send requires text or media" );
}
const params: Record<string, unknown> = { message };
if (textStyles.length > 0 ) {
params["text-style" ] = textStyles.map(
(style) => `${style.start}:${style.length}:${style.style}`,
);
}
if (account) {
params.account = account;
}
if (attachments && attachments.length > 0 ) {
params.attachments = attachments;
}
const targetParams = buildTargetParams(target, {
recipient: true ,
group: true ,
username: true ,
});
if (!targetParams) {
throw new Error("Signal recipient is required" );
}
Object.assign(params, targetParams);
const result = await signalRpcRequest<{ timestamp?: number }>("send" , params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
const timestamp = result?.timestamp;
return {
messageId: timestamp ? String(timestamp) : "unknown" ,
timestamp,
};
}
export async function sendTypingSignal(
to: string,
opts: SignalRpcOpts & { stop?: boolean },
): Promise<boolean > {
const accountInfo = await resolveSignalRpcAccountInfo(opts);
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const targetParams = buildTargetParams(parseTarget(to), {
recipient: true ,
group: true ,
});
if (!targetParams) {
return false ;
}
const params: Record<string, unknown> = { ...targetParams };
if (account) {
params.account = account;
}
if (opts.stop) {
params.stop = true ;
}
await signalRpcRequest("sendTyping" , params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return true ;
}
export async function sendReadReceiptSignal(
to: string,
targetTimestamp: number,
opts: SignalRpcOpts & { type?: SignalReceiptType },
): Promise<boolean > {
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0 ) {
return false ;
}
const accountInfo = await resolveSignalRpcAccountInfo(opts);
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const targetParams = buildTargetParams(parseTarget(to), {
recipient: true ,
});
if (!targetParams) {
return false ;
}
const params: Record<string, unknown> = {
...targetParams,
targetTimestamp,
type: opts.type ?? "read" ,
};
if (account) {
params.account = account;
}
await signalRpcRequest("sendReceipt" , params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return true ;
}
Messung V0.5 in Prozent C=100 H=98 G=98
¤ Dauer der Verarbeitung: 0.4 Sekunden
¤
*© Formatika GbR, Deutschland