nostr-bray 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { loadConfig } from './config.js';
3
3
  import { IdentityContext } from './context.js';
4
4
  import { RelayPool } from './relay-pool.js';
5
5
  import { Nip65Manager } from './nip65.js';
6
- import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet } from './social/handlers.js';
6
+ import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsFollow, handleContactsUnfollow } from './social/handlers.js';
7
7
  import { handleDmSend, handleDmRead } from './social/dm.js';
8
8
  import { handleNotifications, handleFeed } from './social/notifications.js';
9
9
  import { handleIdentityList, handleIdentityProve, handleIdentityCreate } from './identity/handlers.js';
@@ -47,6 +47,9 @@ Social:
47
47
  react <event-id> <pubkey> [emoji] React to an event
48
48
  profile <pubkey-hex> Fetch a profile
49
49
  profile-set <json> Set profile (add --confirm to overwrite)
50
+ contacts <pubkey-hex> List who a pubkey follows
51
+ follow <pubkey-hex> [relay] [name] Follow a pubkey
52
+ unfollow <pubkey-hex> Unfollow a pubkey
50
53
  dm <pubkey-hex> "message" Send NIP-17 encrypted DM
51
54
  dm-read Read received DMs
52
55
  feed [--limit N] Fetch text note feed
@@ -248,6 +251,21 @@ async function run(cmdArgs) {
248
251
  out(await handleSocialProfileSet(ctx, pool, { profile, confirm: hasFlag('confirm') }));
249
252
  break;
250
253
  }
254
+ case 'contacts':
255
+ out(await handleContactsGet(pool, ctx.activeNpub, req(1, 'contacts <pubkey-hex>')));
256
+ break;
257
+ case 'follow':
258
+ out(await handleContactsFollow(ctx, pool, {
259
+ pubkeyHex: req(1, 'follow <pubkey-hex> [relay] [petname]'),
260
+ relay: cmdArgs[2],
261
+ petname: cmdArgs[3],
262
+ }));
263
+ break;
264
+ case 'unfollow':
265
+ out(await handleContactsUnfollow(ctx, pool, {
266
+ pubkeyHex: req(1, 'unfollow <pubkey-hex>'),
267
+ }));
268
+ break;
251
269
  case 'dm':
252
270
  out(await handleDmSend(ctx, pool, {
253
271
  recipientPubkeyHex: req(1, 'dm <pubkey-hex> "message"'),
@@ -387,9 +405,31 @@ async function run(cmdArgs) {
387
405
  }
388
406
  }
389
407
  // === Shell mode ===
408
+ const ALL_COMMANDS = [
409
+ 'whoami', 'create', 'list', 'derive', 'persona', 'switch', 'prove', 'proof-publish',
410
+ 'backup', 'restore', 'identity-backup', 'identity-restore', 'migrate',
411
+ 'post', 'reply', 'react', 'profile', 'profile-set', 'contacts', 'follow', 'unfollow', 'dm', 'dm-read', 'feed', 'notifications',
412
+ 'attest', 'trust-read', 'trust-verify', 'trust-revoke', 'trust-request', 'trust-request-list',
413
+ 'ring-prove', 'ring-verify', 'spoken-challenge', 'spoken-verify',
414
+ 'relay-list', 'relay-set', 'relay-add', 'relay-info',
415
+ 'zap-send', 'zap-balance', 'zap-invoice', 'zap-lookup', 'zap-transactions', 'zap-receipts', 'zap-decode',
416
+ 'safety-configure', 'safety-activate',
417
+ 'help', 'exit',
418
+ ];
390
419
  async function shell() {
391
420
  const { createInterface } = await import('node:readline');
392
- const rl = createInterface({ input: process.stdin, output: process.stdout });
421
+ const rl = createInterface({
422
+ input: process.stdin,
423
+ output: process.stdout,
424
+ completer: (line) => {
425
+ const parts = line.split(/\s+/);
426
+ if (parts.length <= 1) {
427
+ const hits = ALL_COMMANDS.filter(c => c.startsWith(line));
428
+ return [hits.length ? hits : ALL_COMMANDS, line];
429
+ }
430
+ return [[], line];
431
+ },
432
+ });
393
433
  console.log(`nostr-bray shell — ${ctx.activeNpub}`);
394
434
  console.log('Type a command, or "help" / "exit".\n');
395
435
  const prompt = () => new Promise(resolve => {
@@ -40,3 +40,20 @@ export declare function handleSocialProfileSet(ctx: IdentityContext, pool: Relay
40
40
  profile: Record<string, unknown>;
41
41
  confirm?: boolean;
42
42
  }): Promise<ProfileSetResult>;
43
+ export interface Contact {
44
+ pubkey: string;
45
+ relay?: string;
46
+ petname?: string;
47
+ }
48
+ /** Fetch the kind 3 contact list for a pubkey */
49
+ export declare function handleContactsGet(pool: RelayPool, npub: string, pubkeyHex: string): Promise<Contact[]>;
50
+ /** Follow a pubkey — fetches current contacts, adds, publishes new kind 3 */
51
+ export declare function handleContactsFollow(ctx: IdentityContext, pool: RelayPool, args: {
52
+ pubkeyHex: string;
53
+ relay?: string;
54
+ petname?: string;
55
+ }): Promise<PostResult>;
56
+ /** Unfollow a pubkey — fetches current contacts, removes, publishes new kind 3 */
57
+ export declare function handleContactsUnfollow(ctx: IdentityContext, pool: RelayPool, args: {
58
+ pubkeyHex: string;
59
+ }): Promise<PostResult>;
@@ -105,3 +105,75 @@ export async function handleSocialProfileSet(ctx, pool, args) {
105
105
  await pool.publish(ctx.activeNpub, event);
106
106
  return { published: true, event };
107
107
  }
108
+ /** Fetch the kind 3 contact list for a pubkey */
109
+ export async function handleContactsGet(pool, npub, pubkeyHex) {
110
+ const events = await pool.query(npub, {
111
+ kinds: [3],
112
+ authors: [pubkeyHex],
113
+ });
114
+ if (events.length === 0)
115
+ return [];
116
+ const best = events.reduce((a, b) => b.created_at > a.created_at ? b : a);
117
+ return best.tags
118
+ .filter(t => t[0] === 'p' && t[1])
119
+ .map(t => ({
120
+ pubkey: t[1],
121
+ relay: t[2] || undefined,
122
+ petname: t[3] || undefined,
123
+ }));
124
+ }
125
+ /** Follow a pubkey — fetches current contacts, adds, publishes new kind 3 */
126
+ export async function handleContactsFollow(ctx, pool, args) {
127
+ // Fetch existing contacts
128
+ const existing = await pool.query(ctx.activeNpub, {
129
+ kinds: [3],
130
+ authors: [ctx.activeNpub],
131
+ });
132
+ let tags = [];
133
+ if (existing.length > 0) {
134
+ const best = existing.reduce((a, b) => b.created_at > a.created_at ? b : a);
135
+ tags = best.tags.filter(t => t[0] === 'p');
136
+ }
137
+ // Don't duplicate
138
+ if (!tags.some(t => t[1] === args.pubkeyHex)) {
139
+ const newTag = ['p', args.pubkeyHex];
140
+ if (args.relay)
141
+ newTag.push(args.relay);
142
+ if (args.petname) {
143
+ if (!args.relay)
144
+ newTag.push('');
145
+ newTag.push(args.petname);
146
+ }
147
+ tags.push(newTag);
148
+ }
149
+ const sign = ctx.getSigningFunction();
150
+ const event = await sign({
151
+ kind: 3,
152
+ created_at: Math.floor(Date.now() / 1000),
153
+ tags,
154
+ content: '',
155
+ });
156
+ const publish = await pool.publish(ctx.activeNpub, event);
157
+ return { event, publish };
158
+ }
159
+ /** Unfollow a pubkey — fetches current contacts, removes, publishes new kind 3 */
160
+ export async function handleContactsUnfollow(ctx, pool, args) {
161
+ const existing = await pool.query(ctx.activeNpub, {
162
+ kinds: [3],
163
+ authors: [ctx.activeNpub],
164
+ });
165
+ let tags = [];
166
+ if (existing.length > 0) {
167
+ const best = existing.reduce((a, b) => b.created_at > a.created_at ? b : a);
168
+ tags = best.tags.filter(t => t[0] === 'p' && t[1] !== args.pubkeyHex);
169
+ }
170
+ const sign = ctx.getSigningFunction();
171
+ const event = await sign({
172
+ kind: 3,
173
+ created_at: Math.floor(Date.now() / 1000),
174
+ tags,
175
+ content: '',
176
+ });
177
+ const publish = await pool.publish(ctx.activeNpub, event);
178
+ return { event, publish };
179
+ }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { hexId } from '../validation.js';
3
- import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet, } from './handlers.js';
3
+ import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsFollow, handleContactsUnfollow, } from './handlers.js';
4
4
  import { handleDmSend, handleDmRead } from './dm.js';
5
5
  import { handleNotifications, handleFeed } from './notifications.js';
6
6
  export function registerSocialTools(server, deps) {
@@ -165,4 +165,51 @@ export function registerSocialTools(server, deps) {
165
165
  content: [{ type: 'text', text: JSON.stringify(feed, null, 2) }],
166
166
  };
167
167
  });
168
+ server.registerTool('contacts_get', {
169
+ description: 'Fetch the contact list (kind 3 follows) for a pubkey. Returns pubkeys, relay hints, and petnames.',
170
+ inputSchema: {
171
+ pubkeyHex: hexId.describe('Hex pubkey to fetch contacts for'),
172
+ npub: z.string().optional().describe('Bech32 npub for relay routing (defaults to active identity)'),
173
+ },
174
+ annotations: { readOnlyHint: true },
175
+ }, async ({ pubkeyHex, npub }) => {
176
+ const contacts = await handleContactsGet(deps.pool, npub ?? deps.ctx.activeNpub, pubkeyHex);
177
+ return {
178
+ content: [{ type: 'text', text: JSON.stringify(contacts, null, 2) }],
179
+ };
180
+ });
181
+ server.registerTool('contacts_follow', {
182
+ description: 'Follow a Nostr pubkey. Fetches current contact list, adds the pubkey, publishes updated kind 3.',
183
+ inputSchema: {
184
+ pubkeyHex: hexId.describe('Hex pubkey to follow'),
185
+ relay: z.string().optional().describe('Relay hint for the contact'),
186
+ petname: z.string().optional().describe('Local petname for the contact'),
187
+ },
188
+ annotations: { readOnlyHint: false },
189
+ }, async ({ pubkeyHex, relay, petname }) => {
190
+ const result = await handleContactsFollow(deps.ctx, deps.pool, { pubkeyHex, relay, petname });
191
+ return {
192
+ content: [{ type: 'text', text: JSON.stringify({
193
+ id: result.event.id,
194
+ followCount: result.event.tags.filter(t => t[0] === 'p').length,
195
+ publish: result.publish,
196
+ }, null, 2) }],
197
+ };
198
+ });
199
+ server.registerTool('contacts_unfollow', {
200
+ description: 'Unfollow a Nostr pubkey. Fetches current contact list, removes the pubkey, publishes updated kind 3.',
201
+ inputSchema: {
202
+ pubkeyHex: hexId.describe('Hex pubkey to unfollow'),
203
+ },
204
+ annotations: { readOnlyHint: false },
205
+ }, async ({ pubkeyHex }) => {
206
+ const result = await handleContactsUnfollow(deps.ctx, deps.pool, { pubkeyHex });
207
+ return {
208
+ content: [{ type: 'text', text: JSON.stringify({
209
+ id: result.event.id,
210
+ followCount: result.event.tags.filter(t => t[0] === 'p').length,
211
+ publish: result.publish,
212
+ }, null, 2) }],
213
+ };
214
+ });
168
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server giving AI agents sovereign Nostr identities — key derivation, personas, attestations, duress detection, zaps",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",