a2acalling 0.6.1 → 0.6.3
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/README.md +33 -9
- package/SKILL.md +69 -5
- package/bin/cli.js +539 -162
- package/docs/protocol.md +24 -14
- package/package.json +1 -1
- package/scripts/install-openclaw.js +64 -64
- package/src/dashboard/public/app.js +765 -28
- package/src/dashboard/public/index.html +57 -13
- package/src/dashboard/public/style.css +16 -0
- package/src/lib/callbook.js +358 -0
- package/src/lib/client.js +1 -2
- package/src/lib/config.js +214 -16
- package/src/lib/conversations.js +74 -0
- package/src/lib/disclosure.js +3 -43
- package/src/lib/external-ip.js +18 -7
- package/src/lib/invite-host.js +24 -21
- package/src/lib/logger.js +26 -14
- package/src/lib/tokens.js +314 -113
- package/src/routes/a2a.js +11 -2
- package/src/routes/callbook.js +142 -0
- package/src/routes/dashboard.js +605 -37
- package/src/server.js +6 -0
package/src/lib/tokens.js
CHANGED
|
@@ -15,6 +15,47 @@ const DEFAULT_CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
|
|
|
15
15
|
const DB_FILENAME = 'a2a.json';
|
|
16
16
|
const logger = createLogger({ component: 'a2a.tokens' });
|
|
17
17
|
|
|
18
|
+
function sanitizeCustomFields(fields, options = {}) {
|
|
19
|
+
const maxFields = Number.isFinite(options.maxFields) ? options.maxFields : 200;
|
|
20
|
+
const keyMaxLength = Number.isFinite(options.keyMaxLength) ? options.keyMaxLength : 80;
|
|
21
|
+
const valMaxLength = Number.isFinite(options.valMaxLength) ? options.valMaxLength : 800;
|
|
22
|
+
|
|
23
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cleaned = {};
|
|
28
|
+
const keys = Object.keys(fields);
|
|
29
|
+
let count = 0;
|
|
30
|
+
|
|
31
|
+
for (const rawKey of keys) {
|
|
32
|
+
if (count >= maxFields) break;
|
|
33
|
+
const key = String(rawKey || '').replace(/\s+/g, ' ').trim().slice(0, keyMaxLength);
|
|
34
|
+
if (!key) continue;
|
|
35
|
+
|
|
36
|
+
const rawVal = fields[rawKey];
|
|
37
|
+
let value = '';
|
|
38
|
+
if (rawVal === null || rawVal === undefined) {
|
|
39
|
+
value = '';
|
|
40
|
+
} else if (typeof rawVal === 'string') {
|
|
41
|
+
value = rawVal;
|
|
42
|
+
} else if (typeof rawVal === 'number' || typeof rawVal === 'boolean') {
|
|
43
|
+
value = String(rawVal);
|
|
44
|
+
} else {
|
|
45
|
+
try {
|
|
46
|
+
value = JSON.stringify(rawVal);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
value = String(rawVal);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
cleaned[key] = String(value).replace(/\s+/g, ' ').trim().slice(0, valMaxLength);
|
|
53
|
+
count++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return cleaned;
|
|
57
|
+
}
|
|
58
|
+
|
|
18
59
|
class TokenStore {
|
|
19
60
|
constructor(configDir = DEFAULT_CONFIG_DIR) {
|
|
20
61
|
this.configDir = configDir;
|
|
@@ -31,7 +72,42 @@ class TokenStore {
|
|
|
31
72
|
_load() {
|
|
32
73
|
if (fs.existsSync(this.dbPath)) {
|
|
33
74
|
try {
|
|
34
|
-
|
|
75
|
+
const parsed = JSON.parse(fs.readFileSync(this.dbPath, 'utf8'));
|
|
76
|
+
const db = parsed && typeof parsed === 'object' ? parsed : {};
|
|
77
|
+
db.tokens = Array.isArray(db.tokens) ? db.tokens : [];
|
|
78
|
+
db.calls = Array.isArray(db.calls) ? db.calls : [];
|
|
79
|
+
db.contacts = Array.isArray(db.contacts) ? db.contacts : [];
|
|
80
|
+
|
|
81
|
+
// Backward compat: legacy "remotes" is now "contacts".
|
|
82
|
+
const legacyRemotes = Array.isArray(db.remotes) ? db.remotes : [];
|
|
83
|
+
if (legacyRemotes.length) {
|
|
84
|
+
const keyFor = (row) => {
|
|
85
|
+
if (!row || typeof row !== 'object') return null;
|
|
86
|
+
if (row.id) return `id:${row.id}`;
|
|
87
|
+
const host = row.host ? String(row.host) : '';
|
|
88
|
+
const hash = row.token_hash ? String(row.token_hash) : '';
|
|
89
|
+
if (host && hash) return `hosthash:${host}#${hash}`;
|
|
90
|
+
if (host) return `host:${host}`;
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const merged = db.contacts.slice();
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const row of merged) {
|
|
97
|
+
const key = keyFor(row);
|
|
98
|
+
if (key) seen.add(key);
|
|
99
|
+
}
|
|
100
|
+
for (const row of legacyRemotes) {
|
|
101
|
+
const key = keyFor(row);
|
|
102
|
+
if (key && seen.has(key)) continue;
|
|
103
|
+
if (key) seen.add(key);
|
|
104
|
+
merged.push(row);
|
|
105
|
+
}
|
|
106
|
+
db.contacts = merged;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Persisted schema is intentionally minimal during prototyping.
|
|
110
|
+
return { tokens: db.tokens, contacts: db.contacts, calls: db.calls };
|
|
35
111
|
} catch (e) {
|
|
36
112
|
// Corrupted file - backup and start fresh
|
|
37
113
|
const backupPath = `${this.dbPath}.corrupt.${Date.now()}`;
|
|
@@ -46,16 +122,22 @@ class TokenStore {
|
|
|
46
122
|
backup_path: backupPath
|
|
47
123
|
}
|
|
48
124
|
});
|
|
49
|
-
return { tokens: [],
|
|
125
|
+
return { tokens: [], contacts: [], calls: [] };
|
|
50
126
|
}
|
|
51
127
|
}
|
|
52
|
-
return { tokens: [],
|
|
128
|
+
return { tokens: [], contacts: [], calls: [] };
|
|
53
129
|
}
|
|
54
130
|
|
|
55
131
|
_save(db) {
|
|
132
|
+
const persisted = {
|
|
133
|
+
tokens: Array.isArray(db.tokens) ? db.tokens : [],
|
|
134
|
+
contacts: Array.isArray(db.contacts) ? db.contacts : [],
|
|
135
|
+
calls: Array.isArray(db.calls) ? db.calls : []
|
|
136
|
+
};
|
|
137
|
+
|
|
56
138
|
// Atomic write: write to temp file, then rename
|
|
57
139
|
const tmpPath = `${this.dbPath}.tmp.${process.pid}`;
|
|
58
|
-
fs.writeFileSync(tmpPath, JSON.stringify(
|
|
140
|
+
fs.writeFileSync(tmpPath, JSON.stringify(persisted, null, 2), { mode: 0o600 });
|
|
59
141
|
fs.renameSync(tmpPath, this.dbPath);
|
|
60
142
|
try {
|
|
61
143
|
fs.chmodSync(this.dbPath, 0o600);
|
|
@@ -117,8 +199,10 @@ class TokenStore {
|
|
|
117
199
|
tierSettings = null // Object with tier-specific settings
|
|
118
200
|
} = options;
|
|
119
201
|
|
|
120
|
-
|
|
121
|
-
|
|
202
|
+
const tier = String(permissions || 'public').trim() || 'public';
|
|
203
|
+
if (!TokenStore.VALID_TIERS.includes(tier)) {
|
|
204
|
+
throw new Error(`Invalid permissions tier: ${tier}. Expected: ${TokenStore.VALID_TIERS.join('|')}`);
|
|
205
|
+
}
|
|
122
206
|
|
|
123
207
|
const token = TokenStore.generateToken();
|
|
124
208
|
const tokenHash = TokenStore.hashToken(token);
|
|
@@ -144,14 +228,16 @@ class TokenStore {
|
|
|
144
228
|
const defaultTopics = {
|
|
145
229
|
'public': configTiers.public?.topics || ['chat'],
|
|
146
230
|
'friends': configTiers.friends?.topics || ['chat', 'calendar.read', 'email.read', 'search'],
|
|
147
|
-
'family': configTiers.family?.topics || ['chat', 'calendar', 'email', 'search', 'tools']
|
|
231
|
+
'family': configTiers.family?.topics || ['chat', 'calendar', 'email', 'search', 'tools'],
|
|
232
|
+
'custom': configTiers.custom?.topics || ['chat']
|
|
148
233
|
};
|
|
149
234
|
|
|
150
235
|
// Default goals based on tier label (snapshot at creation)
|
|
151
236
|
const defaultGoals = {
|
|
152
237
|
'public': configTiers.public?.goals || [],
|
|
153
238
|
'friends': configTiers.friends?.goals || [],
|
|
154
|
-
'family': configTiers.family?.goals || []
|
|
239
|
+
'family': configTiers.family?.goals || [],
|
|
240
|
+
'custom': configTiers.custom?.goals || []
|
|
155
241
|
};
|
|
156
242
|
|
|
157
243
|
// Resolve capabilities: explicit > config > defaults
|
|
@@ -231,8 +317,10 @@ class TokenStore {
|
|
|
231
317
|
record.last_used = new Date().toISOString();
|
|
232
318
|
this._save(db);
|
|
233
319
|
|
|
234
|
-
|
|
235
|
-
|
|
320
|
+
const tier = record.tier || 'public';
|
|
321
|
+
if (!TokenStore.VALID_TIERS.includes(tier)) {
|
|
322
|
+
return { valid: false, error: 'invalid_token_tier' };
|
|
323
|
+
}
|
|
236
324
|
|
|
237
325
|
// Resolve capabilities: stored > defaults
|
|
238
326
|
const capabilities = record.capabilities
|
|
@@ -280,100 +368,155 @@ class TokenStore {
|
|
|
280
368
|
* @param {object} options - Contact metadata
|
|
281
369
|
* @param {string} options.name - Agent name
|
|
282
370
|
* @param {string} options.owner - Human owner name
|
|
371
|
+
* @param {string} options.server_name - Server label (owner-local / optional)
|
|
283
372
|
* @param {string} options.notes - Freeform notes
|
|
284
373
|
* @param {string[]} options.tags - Grouping tags
|
|
374
|
+
* @param {object} options.fields - Flexible CRM-like fields (key/value)
|
|
285
375
|
* @param {string} options.trust - Trust level (trusted, verified, unknown)
|
|
286
376
|
*/
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (typeof options === 'string') {
|
|
290
|
-
options = { name: options };
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const match = inviteUrl.match(/^(?:a2a|oclaw):\/\/([^/]+)\/(.+)$/);
|
|
377
|
+
addContact(inviteUrl, options = {}) {
|
|
378
|
+
const match = String(inviteUrl || '').match(/^a2a:\/\/([^/]+)\/(.+)$/);
|
|
294
379
|
if (!match) {
|
|
295
380
|
throw new Error(`Invalid invite URL: ${inviteUrl}. Expected format: a2a://host/token`);
|
|
296
381
|
}
|
|
297
382
|
|
|
298
|
-
|
|
299
|
-
|
|
383
|
+
const [, host, token] = match;
|
|
384
|
+
const agentName = options.name || host;
|
|
385
|
+
const rawMine = options.is_mine !== undefined ? options.is_mine : options.isMine;
|
|
386
|
+
const isMine = (() => {
|
|
387
|
+
if (rawMine === null || rawMine === undefined) return false;
|
|
388
|
+
if (typeof rawMine === 'boolean') return rawMine;
|
|
389
|
+
if (typeof rawMine === 'string') {
|
|
390
|
+
const s = rawMine.trim().toLowerCase();
|
|
391
|
+
if (['true', '1', 'yes', 'y', 'on'].includes(s)) return true;
|
|
392
|
+
if (['false', '0', 'no', 'n', 'off', ''].includes(s)) return false;
|
|
393
|
+
}
|
|
394
|
+
return Boolean(rawMine);
|
|
395
|
+
})();
|
|
300
396
|
|
|
301
397
|
const db = this._load();
|
|
302
|
-
|
|
398
|
+
|
|
303
399
|
// Check for duplicate by host + token hash
|
|
304
400
|
const tokenHash = TokenStore.hashToken(token);
|
|
305
|
-
const existing = db.
|
|
401
|
+
const existing = (db.contacts || []).find(r => r.host === host && r.token_hash === tokenHash);
|
|
306
402
|
if (existing) {
|
|
307
403
|
return { success: false, error: 'duplicate', existing };
|
|
308
404
|
}
|
|
309
405
|
|
|
310
|
-
// Encrypt token for storage (simple XOR with derived key - not production-grade but better than plaintext)
|
|
311
|
-
|
|
406
|
+
// Encrypt token for storage (simple XOR with derived key - not production-grade but better than plaintext).
|
|
407
|
+
// Keep using the legacy key suffix so older "remotes" records remain decryptable.
|
|
408
|
+
const encryptionKey = crypto.createHash('sha256').update(this.dbPath + TokenStore.LEGACY_REMOTE_KEY_SUFFIX).digest();
|
|
312
409
|
const tokenBuffer = Buffer.from(token, 'utf8');
|
|
313
410
|
const encrypted = Buffer.alloc(tokenBuffer.length);
|
|
314
411
|
for (let i = 0; i < tokenBuffer.length; i++) {
|
|
315
412
|
encrypted[i] = tokenBuffer[i] ^ encryptionKey[i % encryptionKey.length];
|
|
316
413
|
}
|
|
317
414
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
415
|
+
const contact = {
|
|
416
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
417
|
+
name: agentName,
|
|
418
|
+
owner: options.owner || null,
|
|
419
|
+
is_mine: isMine,
|
|
420
|
+
host,
|
|
421
|
+
token_hash: tokenHash,
|
|
422
|
+
token_enc: encrypted.toString('base64'),
|
|
423
|
+
server_name: options.server_name || options.serverName || null,
|
|
424
|
+
notes: options.notes || null,
|
|
425
|
+
tags: Array.isArray(options.tags) ? options.tags : [],
|
|
426
|
+
fields: sanitizeCustomFields(options.fields || options.custom_fields || options.customFields),
|
|
427
|
+
linked_token_id: options.linkedTokenId || options.linked_token_id || null, // Token you gave them
|
|
328
428
|
status: 'unknown',
|
|
329
429
|
last_seen: null,
|
|
330
|
-
added_at: new Date().toISOString()
|
|
430
|
+
added_at: new Date().toISOString(),
|
|
431
|
+
updated_at: null
|
|
331
432
|
};
|
|
332
433
|
|
|
333
|
-
db.
|
|
434
|
+
db.contacts = db.contacts || [];
|
|
435
|
+
db.contacts.push(contact);
|
|
334
436
|
this._save(db);
|
|
335
437
|
|
|
336
|
-
return { success: true,
|
|
438
|
+
return { success: true, contact: { ...contact, token: undefined, token_enc: undefined } };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Legacy wrapper.
|
|
442
|
+
addRemote(inviteUrl, options = {}) {
|
|
443
|
+
const result = this.addContact(inviteUrl, options);
|
|
444
|
+
if (!result.success) return result;
|
|
445
|
+
return { success: true, remote: result.contact };
|
|
337
446
|
}
|
|
338
447
|
|
|
339
448
|
/**
|
|
340
|
-
* Decrypt a
|
|
449
|
+
* Decrypt a contact token
|
|
341
450
|
*/
|
|
342
|
-
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
451
|
+
_decryptContactToken(contact) {
|
|
452
|
+
if (!contact.token_enc) return null;
|
|
453
|
+
|
|
454
|
+
let encrypted;
|
|
455
|
+
try {
|
|
456
|
+
encrypted = Buffer.from(contact.token_enc, 'base64');
|
|
457
|
+
} catch (err) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Try legacy key first, then the newer "contact-key" suffix (in case we ever wrote it).
|
|
462
|
+
const suffixes = [
|
|
463
|
+
TokenStore.LEGACY_REMOTE_KEY_SUFFIX,
|
|
464
|
+
TokenStore.CONTACT_KEY_SUFFIX
|
|
465
|
+
].filter(Boolean);
|
|
466
|
+
|
|
467
|
+
for (const suffix of [...new Set(suffixes)]) {
|
|
468
|
+
const encryptionKey = crypto.createHash('sha256').update(this.dbPath + suffix).digest();
|
|
469
|
+
const decrypted = Buffer.alloc(encrypted.length);
|
|
470
|
+
for (let i = 0; i < encrypted.length; i++) {
|
|
471
|
+
decrypted[i] = encrypted[i] ^ encryptionKey[i % encryptionKey.length];
|
|
472
|
+
}
|
|
473
|
+
const token = decrypted.toString('utf8');
|
|
474
|
+
if (/^fed_[A-Za-z0-9_-]{10,}$/.test(token)) {
|
|
475
|
+
return token;
|
|
476
|
+
}
|
|
351
477
|
}
|
|
352
|
-
|
|
478
|
+
|
|
479
|
+
return null;
|
|
353
480
|
}
|
|
354
481
|
|
|
355
482
|
/**
|
|
356
|
-
* List
|
|
483
|
+
* List contacts (optionally with linked token info / secrets)
|
|
357
484
|
*/
|
|
358
|
-
|
|
485
|
+
listContacts(options = {}) {
|
|
486
|
+
const includeLinkedToken = options.includeLinkedToken !== false;
|
|
487
|
+
const includeSecrets = options.includeSecrets === true;
|
|
359
488
|
const db = this._load();
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
489
|
+
|
|
490
|
+
const contacts = Array.isArray(db.contacts) ? db.contacts : [];
|
|
491
|
+
return contacts.map(row => {
|
|
492
|
+
const base = { ...row };
|
|
493
|
+
if (!includeSecrets) {
|
|
494
|
+
base.token_hash = undefined;
|
|
495
|
+
base.token_enc = undefined;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (includeLinkedToken && base.linked_token_id) {
|
|
499
|
+
const token = (db.tokens || []).find(t => t.id === base.linked_token_id);
|
|
363
500
|
if (token) {
|
|
364
|
-
|
|
501
|
+
base.linked_token = token;
|
|
365
502
|
}
|
|
366
503
|
}
|
|
367
|
-
|
|
504
|
+
|
|
505
|
+
return base;
|
|
368
506
|
});
|
|
369
507
|
}
|
|
370
508
|
|
|
509
|
+
// Legacy wrapper.
|
|
510
|
+
listRemotes(options = {}) {
|
|
511
|
+
return this.listContacts(options);
|
|
512
|
+
}
|
|
513
|
+
|
|
371
514
|
/**
|
|
372
515
|
* Link a token to a contact
|
|
373
516
|
*/
|
|
374
517
|
linkTokenToContact(contactNameOrId, tokenId) {
|
|
375
518
|
const db = this._load();
|
|
376
|
-
const remote = db.
|
|
519
|
+
const remote = (db.contacts || []).find(r =>
|
|
377
520
|
r.name === contactNameOrId || r.id === contactNameOrId
|
|
378
521
|
);
|
|
379
522
|
const token = db.tokens.find(t =>
|
|
@@ -385,15 +528,15 @@ class TokenStore {
|
|
|
385
528
|
|
|
386
529
|
remote.linked_token_id = token.id;
|
|
387
530
|
this._save(db);
|
|
388
|
-
return { success: true, remote, token };
|
|
531
|
+
return { success: true, contact: remote, remote, token };
|
|
389
532
|
}
|
|
390
533
|
|
|
391
534
|
/**
|
|
392
|
-
* Get a
|
|
535
|
+
* Get a contact by name/host/id (with decrypted token)
|
|
393
536
|
*/
|
|
394
|
-
|
|
537
|
+
getContact(nameOrHost) {
|
|
395
538
|
const db = this._load();
|
|
396
|
-
const remote = db.
|
|
539
|
+
const remote = (db.contacts || []).find(r =>
|
|
397
540
|
r.name === nameOrHost ||
|
|
398
541
|
r.host === nameOrHost ||
|
|
399
542
|
r.id === nameOrHost
|
|
@@ -403,45 +546,84 @@ class TokenStore {
|
|
|
403
546
|
// Return with decrypted token
|
|
404
547
|
return {
|
|
405
548
|
...remote,
|
|
406
|
-
token: this.
|
|
549
|
+
token: this._decryptContactToken(remote),
|
|
407
550
|
token_enc: undefined
|
|
408
551
|
};
|
|
409
552
|
}
|
|
410
553
|
|
|
411
554
|
/**
|
|
412
|
-
*
|
|
555
|
+
* Legacy wrapper.
|
|
413
556
|
*/
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
557
|
+
getRemote(nameOrHost) {
|
|
558
|
+
return this.getContact(nameOrHost);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Update a contact's metadata
|
|
563
|
+
*/
|
|
564
|
+
updateContact(nameOrHost, updates) {
|
|
565
|
+
const db = this._load();
|
|
566
|
+
const remote = (db.contacts || []).find(r =>
|
|
567
|
+
r.name === nameOrHost ||
|
|
568
|
+
r.host === nameOrHost ||
|
|
569
|
+
r.id === nameOrHost
|
|
570
|
+
);
|
|
421
571
|
|
|
422
572
|
if (!remote) {
|
|
423
573
|
return { success: false, error: 'not_found' };
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Only allow updating specific fields
|
|
577
|
+
const allowed = ['name', 'owner', 'is_mine', 'notes', 'tags', 'linked_token_id', 'server_name', 'fields'];
|
|
578
|
+
for (const key of allowed) {
|
|
579
|
+
if (updates[key] !== undefined) {
|
|
580
|
+
if (key === 'fields') {
|
|
581
|
+
if (updates.fields === null) {
|
|
582
|
+
remote.fields = {};
|
|
583
|
+
} else {
|
|
584
|
+
remote.fields = {
|
|
585
|
+
...(remote.fields && typeof remote.fields === 'object' ? remote.fields : {}),
|
|
586
|
+
...sanitizeCustomFields(updates.fields)
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
} else if (key === 'is_mine') {
|
|
590
|
+
const raw = updates.is_mine;
|
|
591
|
+
if (raw === null) {
|
|
592
|
+
remote.is_mine = false;
|
|
593
|
+
} else if (typeof raw === 'boolean') {
|
|
594
|
+
remote.is_mine = raw;
|
|
595
|
+
} else if (typeof raw === 'string') {
|
|
596
|
+
const s = raw.trim().toLowerCase();
|
|
597
|
+
if (['true', '1', 'yes', 'y', 'on'].includes(s)) remote.is_mine = true;
|
|
598
|
+
else if (['false', '0', 'no', 'n', 'off', ''].includes(s)) remote.is_mine = false;
|
|
599
|
+
else remote.is_mine = Boolean(raw);
|
|
600
|
+
} else {
|
|
601
|
+
remote.is_mine = Boolean(raw);
|
|
602
|
+
}
|
|
603
|
+
} else {
|
|
604
|
+
remote[key] = updates[key];
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
433
608
|
remote.updated_at = new Date().toISOString();
|
|
434
609
|
|
|
435
610
|
this._save(db);
|
|
436
|
-
return { success: true, remote };
|
|
611
|
+
return { success: true, contact: remote, remote };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Legacy wrapper.
|
|
616
|
+
*/
|
|
617
|
+
updateRemote(nameOrHost, updates) {
|
|
618
|
+
return this.updateContact(nameOrHost, updates);
|
|
437
619
|
}
|
|
438
620
|
|
|
439
621
|
/**
|
|
440
622
|
* Update contact status after ping/call
|
|
441
623
|
*/
|
|
442
|
-
|
|
624
|
+
updateContactStatus(nameOrHost, status, error = null) {
|
|
443
625
|
const db = this._load();
|
|
444
|
-
const remote = db.
|
|
626
|
+
const remote = (db.contacts || []).find(r =>
|
|
445
627
|
r.name === nameOrHost ||
|
|
446
628
|
r.host === nameOrHost ||
|
|
447
629
|
r.id === nameOrHost
|
|
@@ -458,11 +640,18 @@ class TokenStore {
|
|
|
458
640
|
}
|
|
459
641
|
|
|
460
642
|
/**
|
|
461
|
-
*
|
|
643
|
+
* Legacy wrapper.
|
|
462
644
|
*/
|
|
463
|
-
|
|
645
|
+
updateRemoteStatus(nameOrHost, status, error = null) {
|
|
646
|
+
return this.updateContactStatus(nameOrHost, status, error);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Remove a contact
|
|
651
|
+
*/
|
|
652
|
+
removeContact(nameOrHost) {
|
|
464
653
|
const db = this._load();
|
|
465
|
-
const idx = db.
|
|
654
|
+
const idx = (db.contacts || []).findIndex(r =>
|
|
466
655
|
r.name === nameOrHost ||
|
|
467
656
|
r.host === nameOrHost ||
|
|
468
657
|
r.id === nameOrHost
|
|
@@ -472,9 +661,14 @@ class TokenStore {
|
|
|
472
661
|
return { success: false, error: 'not_found' };
|
|
473
662
|
}
|
|
474
663
|
|
|
475
|
-
const [removed] = db.
|
|
664
|
+
const [removed] = db.contacts.splice(idx, 1);
|
|
476
665
|
this._save(db);
|
|
477
|
-
return { success: true, remote: removed };
|
|
666
|
+
return { success: true, contact: removed, remote: removed };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Legacy wrapper.
|
|
670
|
+
removeRemote(nameOrHost) {
|
|
671
|
+
return this.removeContact(nameOrHost);
|
|
478
672
|
}
|
|
479
673
|
|
|
480
674
|
/**
|
|
@@ -489,7 +683,7 @@ class TokenStore {
|
|
|
489
683
|
* This allows mapping call history (SQLite contact_id=tok_...) to a contact
|
|
490
684
|
* row in the dashboard via linked_token_id.
|
|
491
685
|
*/
|
|
492
|
-
|
|
686
|
+
ensureInboundContact(caller, tokenId) {
|
|
493
687
|
if (!caller || !caller.name) {
|
|
494
688
|
return null;
|
|
495
689
|
}
|
|
@@ -498,70 +692,77 @@ class TokenStore {
|
|
|
498
692
|
const owner = caller.owner ? String(caller.owner).trim().slice(0, 120) : null;
|
|
499
693
|
|
|
500
694
|
const db = this._load();
|
|
501
|
-
db.
|
|
695
|
+
db.contacts = db.contacts || [];
|
|
502
696
|
|
|
503
697
|
// Prefer stable linking by the token used for inbound auth.
|
|
504
698
|
let remote = tokenId
|
|
505
|
-
? db.
|
|
699
|
+
? db.contacts.find(r => r.linked_token_id === tokenId)
|
|
506
700
|
: null;
|
|
507
701
|
|
|
508
702
|
// Fallback match by agent name/owner (less reliable, but helpful).
|
|
509
703
|
if (!remote) {
|
|
510
|
-
remote = db.
|
|
704
|
+
remote = db.contacts.find(r => r.name === name || (owner && r.owner === owner));
|
|
511
705
|
}
|
|
512
706
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
707
|
+
if (remote) {
|
|
708
|
+
remote.name = remote.name || name;
|
|
709
|
+
if (owner && !remote.owner) {
|
|
710
|
+
remote.owner = owner;
|
|
711
|
+
}
|
|
712
|
+
if (remote.is_mine === undefined) {
|
|
713
|
+
remote.is_mine = false;
|
|
714
|
+
}
|
|
715
|
+
if (tokenId && !remote.linked_token_id) {
|
|
716
|
+
remote.linked_token_id = tokenId;
|
|
717
|
+
}
|
|
521
718
|
remote.host = remote.host || 'inbound';
|
|
522
719
|
remote.tags = Array.isArray(remote.tags) ? remote.tags : [];
|
|
523
720
|
if (!remote.tags.includes('inbound')) {
|
|
524
721
|
remote.tags.push('inbound');
|
|
525
722
|
}
|
|
723
|
+
remote.fields = remote.fields && typeof remote.fields === 'object' ? remote.fields : {};
|
|
724
|
+
remote.server_name = remote.server_name || null;
|
|
526
725
|
remote.status = remote.status || 'unknown';
|
|
527
726
|
remote.updated_at = new Date().toISOString();
|
|
528
727
|
this._save(db);
|
|
529
728
|
return remote;
|
|
530
729
|
}
|
|
531
730
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
731
|
+
const contact = {
|
|
732
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
733
|
+
name,
|
|
734
|
+
owner,
|
|
735
|
+
is_mine: false,
|
|
736
|
+
host: 'inbound',
|
|
737
|
+
token_hash: null,
|
|
738
|
+
token_enc: null,
|
|
739
|
+
server_name: null,
|
|
539
740
|
notes: tokenId ? `Inbound caller via token ${tokenId}` : 'Inbound caller',
|
|
540
741
|
tags: ['inbound'],
|
|
742
|
+
fields: {},
|
|
541
743
|
linked_token_id: tokenId || null,
|
|
542
744
|
status: 'unknown',
|
|
543
745
|
last_seen: null,
|
|
544
|
-
added_at: new Date().toISOString()
|
|
746
|
+
added_at: new Date().toISOString(),
|
|
747
|
+
updated_at: null
|
|
545
748
|
};
|
|
546
749
|
|
|
547
|
-
db.
|
|
750
|
+
db.contacts.push(contact);
|
|
548
751
|
this._save(db);
|
|
549
752
|
return contact;
|
|
550
753
|
}
|
|
551
754
|
}
|
|
552
755
|
|
|
553
|
-
|
|
554
|
-
TokenStore.
|
|
555
|
-
|
|
556
|
-
'tools-read': 'friends',
|
|
557
|
-
'tools-write': 'family'
|
|
558
|
-
};
|
|
756
|
+
TokenStore.VALID_TIERS = ['public', 'friends', 'family', 'custom'];
|
|
757
|
+
TokenStore.LEGACY_REMOTE_KEY_SUFFIX = 'remote-key';
|
|
758
|
+
TokenStore.CONTACT_KEY_SUFFIX = 'contact-key';
|
|
559
759
|
|
|
560
760
|
// Default capabilities per tier label (used when config has none)
|
|
561
761
|
TokenStore.DEFAULT_CAPABILITIES = {
|
|
562
762
|
'public': ['context-read'],
|
|
563
763
|
'friends': ['context-read', 'calendar.read', 'email.read', 'search'],
|
|
564
|
-
'family': ['context-read', 'calendar', 'email', 'search', 'tools', 'memory']
|
|
764
|
+
'family': ['context-read', 'calendar', 'email', 'search', 'tools', 'memory'],
|
|
765
|
+
'custom': ['context-read']
|
|
565
766
|
};
|
|
566
767
|
|
|
567
768
|
module.exports = { TokenStore };
|
package/src/routes/a2a.js
CHANGED
|
@@ -340,13 +340,22 @@ function createRoutes(options = {}) {
|
|
|
340
340
|
request_id: requestId
|
|
341
341
|
};
|
|
342
342
|
|
|
343
|
+
// Ensure inbound caller exists as a contact (best-effort).
|
|
344
|
+
let ensuredContact = null;
|
|
345
|
+
try {
|
|
346
|
+
ensuredContact = tokenStore.ensureInboundContact(sanitizedCaller, validation.id);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
ensuredContact = null;
|
|
349
|
+
}
|
|
350
|
+
|
|
343
351
|
// Track conversation if store available
|
|
344
352
|
if (convStore) {
|
|
345
353
|
try {
|
|
346
354
|
convStore.startConversation({
|
|
347
355
|
id: a2aContext.conversation_id,
|
|
348
|
-
|
|
349
|
-
|
|
356
|
+
// Standardize: store the local contact.id when available (fallback to token id otherwise).
|
|
357
|
+
contactId: ensuredContact?.id || validation.id,
|
|
358
|
+
contactName: ensuredContact?.name || sanitizedCaller.name || validation.name,
|
|
350
359
|
tokenId: validation.id,
|
|
351
360
|
direction: 'inbound'
|
|
352
361
|
});
|