mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 09:02:15 +00:00
502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
|
import {
|
|
createScopedDmSecurityResolver,
|
|
createTopLevelChannelConfigAdapter,
|
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
|
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
|
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
|
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
import {
|
|
buildPassiveChannelStatusSummary,
|
|
buildTrafficStatusSummary,
|
|
} from "openclaw/plugin-sdk/extension-shared";
|
|
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
|
|
import {
|
|
buildChannelConfigSchema,
|
|
collectStatusIssuesFromLastError,
|
|
createPreCryptoDirectDmAuthorizer,
|
|
createDefaultChannelRuntimeState,
|
|
DEFAULT_ACCOUNT_ID,
|
|
dispatchInboundDirectDmWithRuntime,
|
|
formatPairingApproveHint,
|
|
resolveInboundDirectDmAccessWithRuntime,
|
|
type ChannelPlugin,
|
|
} from "../runtime-api.js";
|
|
import type { NostrProfile } from "./config-schema.js";
|
|
import { NostrConfigSchema } from "./config-schema.js";
|
|
import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
|
|
import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
|
|
import type { ProfilePublishResult } from "./nostr-profile.js";
|
|
import { getNostrRuntime } from "./runtime.js";
|
|
import { resolveNostrOutboundSessionRoute } from "./session-route.js";
|
|
import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js";
|
|
import {
|
|
listNostrAccountIds,
|
|
resolveDefaultNostrAccountId,
|
|
resolveNostrAccount,
|
|
type ResolvedNostrAccount,
|
|
} from "./types.js";
|
|
|
|
// Store active bus handles per account
|
|
const activeBuses = new Map<string, NostrBusHandle>();
|
|
|
|
// Store metrics snapshots per account (for status reporting)
|
|
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 === "*") {
|
|
return true;
|
|
}
|
|
if (normalized === normalizedSender) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function resolveNostrDirectAccess(params: {
|
|
cfg: Parameters<typeof resolveInboundDirectDmAccessWithRuntime>[0]["cfg"];
|
|
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",
|
|
});
|
|
}
|
|
|
|
const resolveNostrDmPolicy = createScopedDmSecurityResolver<ResolvedNostrAccount>({
|
|
channelKey: "nostr",
|
|
resolvePolicy: (account) => account.config.dmPolicy,
|
|
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
policyPathSuffix: "dmPolicy",
|
|
defaultPolicy: "pairing",
|
|
approveHint: formatPairingApproveHint("nostr"),
|
|
normalizeEntry: (raw) => {
|
|
try {
|
|
return normalizePubkey(raw.trim().replace(/^nostr:/i, ""));
|
|
} catch {
|
|
return raw.trim();
|
|
}
|
|
},
|
|
});
|
|
|
|
const nostrConfigAdapter = createTopLevelChannelConfigAdapter<ResolvedNostrAccount>({
|
|
sectionKey: "nostr",
|
|
resolveAccount: (cfg) => resolveNostrAccount({ cfg }),
|
|
listAccountIds: listNostrAccountIds,
|
|
defaultAccountId: resolveDefaultNostrAccountId,
|
|
deleteMode: "clear-fields",
|
|
clearBaseFields: [
|
|
"name",
|
|
"defaultAccount",
|
|
"privateKey",
|
|
"relays",
|
|
"dmPolicy",
|
|
"allowFrom",
|
|
"profile",
|
|
],
|
|
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
formatAllowFrom: (allowFrom) =>
|
|
allowFrom
|
|
.map((entry) => String(entry).trim())
|
|
.filter(Boolean)
|
|
.map((entry) => {
|
|
if (entry === "*") {
|
|
return "*";
|
|
}
|
|
try {
|
|
return normalizePubkey(entry);
|
|
} catch {
|
|
return entry;
|
|
}
|
|
})
|
|
.filter(Boolean),
|
|
});
|
|
|
|
export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = createChatChannelPlugin({
|
|
base: {
|
|
id: "nostr",
|
|
meta: {
|
|
id: "nostr",
|
|
label: "Nostr",
|
|
selectionLabel: "Nostr",
|
|
docsPath: "/channels/nostr",
|
|
docsLabel: "nostr",
|
|
blurb: "Decentralized DMs via Nostr relays (NIP-04)",
|
|
order: 100,
|
|
},
|
|
capabilities: {
|
|
chatTypes: ["direct"], // DMs only for MVP
|
|
media: false, // No media for MVP
|
|
},
|
|
reload: { configPrefixes: ["channels.nostr"] },
|
|
configSchema: buildChannelConfigSchema(NostrConfigSchema),
|
|
setup: nostrSetupAdapter,
|
|
setupWizard: nostrSetupWizard,
|
|
config: {
|
|
...nostrConfigAdapter,
|
|
isConfigured: (account) => account.configured,
|
|
describeAccount: (account) =>
|
|
describeAccountSnapshot({
|
|
account,
|
|
configured: account.configured,
|
|
extra: {
|
|
publicKey: account.publicKey,
|
|
},
|
|
}),
|
|
},
|
|
messaging: {
|
|
normalizeTarget: (target) => {
|
|
// Strip nostr: prefix if present
|
|
const cleaned = target.trim().replace(/^nostr:/i, "");
|
|
try {
|
|
return normalizePubkey(cleaned);
|
|
} catch {
|
|
return cleaned;
|
|
}
|
|
},
|
|
targetResolver: {
|
|
looksLikeId: (input) => {
|
|
const trimmed = input.trim();
|
|
return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
|
|
},
|
|
hint: "<npub|hex pubkey|nostr:npub...>",
|
|
},
|
|
resolveOutboundSessionRoute: (params) => resolveNostrOutboundSessionRoute(params),
|
|
},
|
|
status: {
|
|
...createComputedAccountStatusAdapter<ResolvedNostrAccount>({
|
|
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("nostr", accounts),
|
|
buildChannelSummary: ({ snapshot }) =>
|
|
buildPassiveChannelStatusSummary(snapshot, {
|
|
publicKey: snapshot.publicKey ?? null,
|
|
}),
|
|
resolveAccountSnapshot: ({ account, runtime }) => ({
|
|
accountId: account.accountId,
|
|
name: account.name,
|
|
enabled: account.enabled,
|
|
configured: account.configured,
|
|
extra: {
|
|
publicKey: account.publicKey,
|
|
profile: account.profile,
|
|
...buildTrafficStatusSummary(runtime),
|
|
},
|
|
}),
|
|
}),
|
|
},
|
|
gateway: {
|
|
startAccount: 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,
|
|
},
|
|
});
|
|
|
|
// Track bus handle for metrics callback
|
|
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;
|
|
}
|
|
|
|
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
|
|
? String((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) => {
|
|
// Log significant metrics at appropriate levels
|
|
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}`);
|
|
}
|
|
// Update cached metrics snapshot
|
|
if (busHandle) {
|
|
metricsSnapshots.set(account.accountId, busHandle.getMetrics());
|
|
}
|
|
},
|
|
});
|
|
|
|
busHandle = bus;
|
|
|
|
// Store the bus handle
|
|
activeBuses.set(account.accountId, bus);
|
|
|
|
ctx.log?.info(
|
|
`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`,
|
|
);
|
|
|
|
// Return cleanup function
|
|
return {
|
|
stop: () => {
|
|
bus.close();
|
|
activeBuses.delete(account.accountId);
|
|
metricsSnapshots.delete(account.accountId);
|
|
ctx.log?.info(`[${account.accountId}] Nostr provider stopped`);
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
pairing: {
|
|
text: {
|
|
idLabel: "nostrPubkey",
|
|
message: "Your pairing request has been approved!",
|
|
normalizeAllowEntry: (entry) => {
|
|
try {
|
|
return normalizePubkey(entry.trim().replace(/^nostr:/i, ""));
|
|
} catch {
|
|
return entry.trim();
|
|
}
|
|
},
|
|
notify: async ({ cfg, id, message, accountId }) => {
|
|
const bus = activeBuses.get(accountId ?? resolveDefaultNostrAccountId(cfg));
|
|
if (bus) {
|
|
await bus.sendDm(id, message);
|
|
}
|
|
},
|
|
},
|
|
},
|
|
security: {
|
|
resolveDmPolicy: resolveNostrDmPolicy,
|
|
},
|
|
outbound: {
|
|
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()}`,
|
|
});
|
|
},
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Get metrics snapshot for a Nostr account.
|
|
* Returns undefined if account is not running.
|
|
*/
|
|
export function getNostrMetrics(
|
|
accountId: string = DEFAULT_ACCOUNT_ID,
|
|
): MetricsSnapshot | undefined {
|
|
const bus = activeBuses.get(accountId);
|
|
if (bus) {
|
|
return bus.getMetrics();
|
|
}
|
|
return metricsSnapshots.get(accountId);
|
|
}
|
|
|
|
/**
|
|
* Get all active Nostr bus handles.
|
|
* Useful for debugging and status reporting.
|
|
*/
|
|
export function getActiveNostrBuses(): Map<string, NostrBusHandle> {
|
|
return new Map(activeBuses);
|
|
}
|
|
|
|
/**
|
|
* Publish a profile (kind:0) for a Nostr account.
|
|
* @param accountId - Account ID (defaults to "default")
|
|
* @param profile - Profile data to publish
|
|
* @returns Publish results with successes and failures
|
|
* @throws Error if account is not running
|
|
*/
|
|
export async function publishNostrProfile(
|
|
accountId: string = DEFAULT_ACCOUNT_ID,
|
|
profile: NostrProfile,
|
|
): Promise<ProfilePublishResult> {
|
|
const bus = activeBuses.get(accountId);
|
|
if (!bus) {
|
|
throw new Error(`Nostr bus not running for account ${accountId}`);
|
|
}
|
|
return bus.publishProfile(profile);
|
|
}
|
|
|
|
/**
|
|
* Get profile publish state for a Nostr account.
|
|
* @param accountId - Account ID (defaults to "default")
|
|
* @returns Profile publish state or null if account not running
|
|
*/
|
|
export async function getNostrProfileState(accountId: string = DEFAULT_ACCOUNT_ID): Promise<{
|
|
lastPublishedAt: number | null;
|
|
lastPublishedEventId: string | null;
|
|
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
|
} | null> {
|
|
const bus = activeBuses.get(accountId);
|
|
if (!bus) {
|
|
return null;
|
|
}
|
|
return bus.getProfileState();
|
|
}
|