ndr: resolve npub to chat_id for outbound messages
When sending messages to an npub, the plugin now: - Converts npub bech32 to hex pubkey - Looks up the chat list to find matching their_pubkey - Returns the chat_id for the actual send Includes helpful error message listing available chats if the pubkey isn't found. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
37a724e041
commit
fb2d16218b
@ -83,31 +83,36 @@ export const ndrPlugin: ChannelPlugin<ResolvedNdrAccount> = {
|
|||||||
if (!bus) {
|
if (!bus) {
|
||||||
throw new Error(`NDR bus not running for account ${aid}`);
|
throw new Error(`NDR bus not running for account ${aid}`);
|
||||||
}
|
}
|
||||||
|
// Resolve npub to chat_id if needed
|
||||||
|
const chatId = await resolveNpubToChatId(bus, to);
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
cfg: core.config.loadConfig(),
|
cfg: core.config.loadConfig(),
|
||||||
channel: "ndr",
|
channel: "ndr",
|
||||||
accountId: aid,
|
accountId: aid,
|
||||||
});
|
});
|
||||||
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||||
await bus.sendMessage(to, message);
|
await bus.sendMessage(chatId, message);
|
||||||
return { channel: "ndr", to };
|
return { channel: "ndr", to: chatId };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, media, accountId }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
||||||
const core = getNdrRuntime();
|
const core = getNdrRuntime();
|
||||||
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const bus = activeBuses.get(aid);
|
const bus = activeBuses.get(aid);
|
||||||
if (!bus) {
|
if (!bus) {
|
||||||
throw new Error(`NDR bus not running for account ${aid}`);
|
throw new Error(`NDR bus not running for account ${aid}`);
|
||||||
}
|
}
|
||||||
const caption = media.caption ? `${media.caption}\n` : "";
|
// Resolve npub to chat_id if needed
|
||||||
|
const chatId = await resolveNpubToChatId(bus, to);
|
||||||
|
const caption = text ? `${text}\n` : "";
|
||||||
|
|
||||||
// Try to upload via htree if we have a local file path
|
// mediaUrl could be a local file path or a remote URL
|
||||||
let mediaLink = media.url ?? "[media attachment]";
|
let mediaLink = mediaUrl ?? "[media attachment]";
|
||||||
if (media.path) {
|
if (mediaUrl && !mediaUrl.startsWith("http")) {
|
||||||
|
// Local file path - upload via htree
|
||||||
try {
|
try {
|
||||||
const { execSync } = await import("child_process");
|
const { execSync } = await import("child_process");
|
||||||
// Properly escape the file path for shell
|
// Properly escape the file path for shell
|
||||||
const escapedPath = media.path.replace(/'/g, "'\\''");
|
const escapedPath = mediaUrl.replace(/'/g, "'\\''");
|
||||||
const output = execSync(`htree add '${escapedPath}'`, {
|
const output = execSync(`htree add '${escapedPath}'`, {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
@ -118,14 +123,14 @@ export const ndrPlugin: ChannelPlugin<ResolvedNdrAccount> = {
|
|||||||
mediaLink = urlMatch[1];
|
mediaLink = urlMatch[1];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// htree not available or failed - fall back to URL or placeholder
|
// htree not available or failed - fall back to original URL
|
||||||
mediaLink = media.url ?? "[media: upload failed]";
|
mediaLink = mediaUrl ?? "[media: upload failed]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = `${caption}${mediaLink}`;
|
const message = `${caption}${mediaLink}`;
|
||||||
await bus.sendMessage(to, message);
|
await bus.sendMessage(chatId, message);
|
||||||
return { channel: "ndr", to };
|
return { channel: "ndr", to: chatId };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -362,3 +367,82 @@ export const ndrPlugin: ChannelPlugin<ResolvedNdrAccount> = {
|
|||||||
export function getActiveNdrBuses(): Map<string, NdrBusHandle> {
|
export function getActiveNdrBuses(): Map<string, NdrBusHandle> {
|
||||||
return new Map(activeBuses);
|
return new Map(activeBuses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve npub to chat_id by looking up the chat list.
|
||||||
|
* If the target is already a chat_id (8-char hex), returns it unchanged.
|
||||||
|
* If it's an npub, finds the chat with matching their_pubkey.
|
||||||
|
*/
|
||||||
|
async function resolveNpubToChatId(bus: NdrBusHandle, target: string): Promise<string> {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
|
||||||
|
// If it's already a chat_id (8-char hex), return as-is
|
||||||
|
if (/^[0-9a-fA-F]{8}$/.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an npub, resolve to chat_id
|
||||||
|
if (trimmed.startsWith("npub1")) {
|
||||||
|
// Convert npub to hex pubkey
|
||||||
|
const hexPubkey = npubToHex(trimmed);
|
||||||
|
if (!hexPubkey) {
|
||||||
|
throw new Error(`Invalid npub: ${trimmed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the chat with this pubkey
|
||||||
|
const chats = await bus.listChats();
|
||||||
|
const chat = chats.find((c) => c.their_pubkey === hexPubkey);
|
||||||
|
if (!chat) {
|
||||||
|
const availableChats = chats.length > 0
|
||||||
|
? ` Available chats: ${chats.map((c) => c.id).join(", ")}`
|
||||||
|
: " No active chats found.";
|
||||||
|
throw new Error(`No chat found with pubkey ${trimmed.slice(0, 20)}...${availableChats}`);
|
||||||
|
}
|
||||||
|
return chat.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown format, try as-is (let ndr CLI handle it)
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert bech32 npub to hex pubkey
|
||||||
|
*/
|
||||||
|
function npubToHex(npub: string): string | null {
|
||||||
|
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||||
|
const CHARSET_REV: Record<string, number> = {};
|
||||||
|
for (let i = 0; i < CHARSET.length; i++) {
|
||||||
|
CHARSET_REV[CHARSET[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bech = npub.toLowerCase();
|
||||||
|
const pos = bech.lastIndexOf("1");
|
||||||
|
if (pos < 1 || pos + 7 > bech.length) return null;
|
||||||
|
|
||||||
|
const hrp = bech.slice(0, pos);
|
||||||
|
if (hrp !== "npub") return null;
|
||||||
|
|
||||||
|
const data: number[] = [];
|
||||||
|
for (const c of bech.slice(pos + 1)) {
|
||||||
|
if (!(c in CHARSET_REV)) return null;
|
||||||
|
data.push(CHARSET_REV[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove checksum (last 6 chars)
|
||||||
|
const dataWithoutChecksum = data.slice(0, -6);
|
||||||
|
|
||||||
|
// Convert from 5-bit to 8-bit
|
||||||
|
let acc = 0;
|
||||||
|
let bits = 0;
|
||||||
|
const result: number[] = [];
|
||||||
|
for (const value of dataWithoutChecksum) {
|
||||||
|
acc = (acc << 5) | value;
|
||||||
|
bits += 5;
|
||||||
|
while (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
result.push((acc >> bits) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user