import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing" ;
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result" ;
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime" ;
import {
createPreCryptoDirectDmAuthorizer,
DEFAULT_ACCOUNT_ID,
type ChannelOutboundAdapter,
resolveInboundDirectDmAccessWithRuntime,
type ChannelPlugin,
} from "./channel-api.js" ;
import type { MetricEvent, MetricsSnapshot } from "./metrics.js" ;
import { startNostrBus, type NostrBusHandle } from "./nostr-bus.js" ;
import { normalizePubkey } from "./nostr-key-utils.js" ;
import { getNostrRuntime } from "./runtime.js" ;
import { resolveDefaultNostrAccountId, type ResolvedNostrAccount } from "./types.js" ;
type NostrGatewayStart = NonNullable<
NonNullable<ChannelPlugin<ResolvedNostrAccount>["gateway" ]>["startAccount" ]
>;
type NostrOutboundAdapter = Pick<
ChannelOutboundAdapter,
"deliveryMode" | "textChunkLimit" | "sendText"
> & {
sendText: NonNullable<ChannelOutboundAdapter["sendText" ]>;
};
const activeBuses = new Map<string, NostrBusHandle>();
const metricsSnapshots = new Map<string, MetricsSnapshot>();
function normalizeNostrAllowEntry(entry: string): string | null {
const trimmed = entry.trim();
if (!trimmed) {
return null ;
}
if (trimmed === "*" ) {
return "*" ;
}
try {
return normalizePubkey(trimmed.replace(/^nostr:/i, "" ));
} catch {
return null ;
}
}
function isNostrSenderAllowed(senderPubkey: string, allowFrom: string[]): boolean {
const normalizedSender = normalizePubkey(senderPubkey);
for (const entry of allowFrom) {
const normalized = normalizeNostrAllowEntry(entry);
if (normalized === "*" || normalized === normalizedSender) {
return true ;
}
}
return false ;
}
async function resolveNostrDirectAccess(params: {
cfg: OpenClawConfig;
accountId: string;
dmPolicy: "pairing" | "allowlist" | "open" | "disabled" ;
allowFrom: Array<string | number> | undefined;
senderPubkey: string;
rawBody: string;
runtime: Parameters<typeof resolveInboundDirectDmAccessWithRuntime>[0 ]["runtime" ];
}) {
return resolveInboundDirectDmAccessWithRuntime({
cfg: params.cfg,
channel: "nostr" ,
accountId: params.accountId,
dmPolicy: params.dmPolicy,
allowFrom: params.allowFrom,
senderId: params.senderPubkey,
rawBody: params.rawBody,
isSenderAllowed: isNostrSenderAllowed,
runtime: params.runtime,
modeWhenAccessGroupsOff: "configured" ,
});
}
export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
publicKey: account.publicKey,
});
ctx.log?.info?.(`[${account.accountId}] starting Nostr provider (pubkey: ${account.publicKey})`);
if (!account.configured) {
throw new Error("Nostr private key not configured" );
}
const runtime = getNostrRuntime();
const pairing = createChannelPairingController({
core: runtime,
channel: "nostr" ,
accountId: account.accountId,
});
const resolveInboundAccess = async (senderPubkey: string, rawBody: string) =>
await resolveNostrDirectAccess({
cfg: ctx.cfg,
accountId: account.accountId,
dmPolicy: account.config.dmPolicy ?? "pairing" ,
allowFrom: account.config.allowFrom,
senderPubkey,
rawBody,
runtime: {
shouldComputeCommandAuthorized: runtime.channel.commands.shouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers:
runtime.channel.commands.resolveCommandAuthorizedFromAuthorizers,
},
});
let busHandle: NostrBusHandle | null = null ;
const authorizeSender = createPreCryptoDirectDmAuthorizer({
resolveAccess: async (senderPubkey) => await resolveInboundAccess(senderPubkey, "" ),
issuePairingChallenge: async ({ senderId, reply }) => {
await pairing.issueChallenge({
senderId,
senderIdLine: `Your Nostr pubkey: ${senderId}`,
sendPairingReply: reply,
onCreated: () => {
ctx.log?.debug?.(`[${account.accountId}] nostr pairing request sender=${senderId}`);
},
onReplyError: (err) => {
ctx.log?.warn?.(
`[${account.accountId}] nostr pairing reply failed for ${senderId}: ${String(err)}`,
);
},
});
},
onBlocked: ({ senderId, reason }) => {
ctx.log?.debug?.(`[${account.accountId}] blocked Nostr sender ${senderId} (${reason})`);
},
});
const bus = await startNostrBus({
accountId: account.accountId,
privateKey: account.privateKey,
relays: account.relays,
authorizeSender: async ({ senderPubkey, reply }) =>
await authorizeSender({ senderId: senderPubkey, reply }),
onMessage: async (senderPubkey, text, reply, meta) => {
const resolvedAccess = await resolveInboundAccess(senderPubkey, text);
if (resolvedAccess.access.decision !== "allow" ) {
ctx.log?.warn?.(
`[${account.accountId}] dropping Nostr DM after preflight drift (${senderPubkey}, ${resolvedAccess.access.reason})`,
);
return ;
}
const { dispatchInboundDirectDmWithRuntime } = await import ("./inbound-direct-dm-runtime.js" );
await dispatchInboundDirectDmWithRuntime({
cfg: ctx.cfg,
runtime,
channel: "nostr" ,
channelLabel: "Nostr" ,
accountId: account.accountId,
peer: {
kind: "direct" ,
id: senderPubkey,
},
senderId: senderPubkey,
senderAddress: `nostr:${senderPubkey}`,
recipientAddress: `nostr:${account.publicKey}`,
conversationLabel: senderPubkey,
rawBody: text,
messageId: meta.eventId,
timestamp: meta.createdAt * 1000 ,
commandAuthorized: resolvedAccess.commandAuthorized,
deliver: async (payload) => {
const outboundText =
payload && typeof payload === "object" && "text" in payload
? ((payload as { text?: string }).text ?? "" )
: "" ;
if (!outboundText.trim()) {
return ;
}
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
cfg: ctx.cfg,
channel: "nostr" ,
accountId: account.accountId,
});
await reply(runtime.channel.text.convertMarkdownTables(outboundText, tableMode));
},
onRecordError: (err) => {
ctx.log?.error?.(
`[${account.accountId}] failed recording Nostr inbound session: ${String(err)}`,
);
},
onDispatchError: (err, info) => {
ctx.log?.error?.(
`[${account.accountId}] Nostr ${info.kind} reply failed: ${String(err)}`,
);
},
});
},
onError: (error, context) => {
ctx.log?.error?.(`[${account.accountId}] Nostr error (${context}): ${error.message}`);
},
onConnect: (relay) => {
ctx.log?.debug?.(`[${account.accountId}] Connected to relay: ${relay}`);
},
onDisconnect: (relay) => {
ctx.log?.debug?.(`[${account.accountId}] Disconnected from relay: ${relay}`);
},
onEose: (relays) => {
ctx.log?.debug?.(`[${account.accountId}] EOSE received from relays: ${relays}`);
},
onMetric: (event: MetricEvent) => {
if (event.name.startsWith("event.rejected." )) {
ctx.log?.debug?.(
`[${account.accountId}] Metric: ${event.name} ${JSON.stringify(event.labels)}`,
);
} else if (event.name === "relay.circuit_breaker.open" ) {
ctx.log?.warn?.(
`[${account.accountId}] Circuit breaker opened for relay: ${event.labels?.relay}`,
);
} else if (event.name === "relay.circuit_breaker.close" ) {
ctx.log?.info?.(
`[${account.accountId}] Circuit breaker closed for relay: ${event.labels?.relay}`,
);
} else if (event.name === "relay.error" ) {
ctx.log?.debug?.(`[${account.accountId}] Relay error: ${event.labels?.relay}`);
}
if (busHandle) {
metricsSnapshots.set(account.accountId, busHandle.getMetrics());
}
},
});
busHandle = bus;
activeBuses.set(account.accountId, bus);
ctx.log?.info?.(
`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`,
);
return {
stop: () => {
bus.close();
activeBuses.delete (account.accountId);
metricsSnapshots.delete (account.accountId);
ctx.log?.info?.(`[${account.accountId}] Nostr provider stopped`);
},
};
};
export const nostrPairingTextAdapter = {
idLabel: "nostrPubkey" ,
message: "Your pairing request has been approved!" ,
normalizeAllowEntry: (entry: string) => {
try {
return normalizePubkey(entry.trim().replace(/^nostr:/i, "" ));
} catch {
return entry.trim();
}
},
notify: async ({
cfg,
id,
message,
accountId,
}: {
cfg: OpenClawConfig;
id: string;
message: string;
accountId?: string;
}) => {
const bus = activeBuses.get(accountId ?? resolveDefaultNostrAccountId(cfg));
if (bus) {
await bus.sendDm(id, message);
}
},
};
export const nostrOutboundAdapter: NostrOutboundAdapter = {
deliveryMode: "direct" ,
textChunkLimit: 4000 ,
sendText: async ({ cfg, to, text, accountId }) => {
const core = getNostrRuntime();
const aid = accountId ?? resolveDefaultNostrAccountId(cfg);
const bus = activeBuses.get(aid);
if (!bus) {
throw new Error(`Nostr bus not running for account ${aid}`);
}
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "nostr" ,
accountId: aid,
});
const message = core.channel.text.convertMarkdownTables(text ?? "" , tableMode);
const normalizedTo = normalizePubkey(to);
await bus.sendDm(normalizedTo, message);
return attachChannelToResult("nostr" , {
to: normalizedTo,
messageId: `nostr-${Date.now()}`,
});
},
};
export function getNostrMetrics(
accountId: string = DEFAULT_ACCOUNT_ID,
): MetricsSnapshot | undefined {
const bus = activeBuses.get(accountId);
if (bus) {
return bus.getMetrics();
}
return metricsSnapshots.get(accountId);
}
export function getActiveNostrBuses(): Map<string, NostrBusHandle> {
return new Map(activeBuses);
}
Messung V0.5 in Prozent C=100 H=91 G=95
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-08)
¤
*© Formatika GbR, Deutschland