@vellumai/assistant 0.4.50 → 0.4.51
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/docs/architecture/integrations.md +2 -2
- package/docs/architecture/keychain-broker.md +6 -6
- package/knip.json +32 -0
- package/package.json +3 -2
- package/src/__tests__/btw-routes.test.ts +61 -5
- package/src/__tests__/config-watcher.test.ts +8 -0
- package/src/__tests__/credential-security-invariants.test.ts +8 -7
- package/src/__tests__/credential-vault-unit.test.ts +19 -18
- package/src/__tests__/credential-vault.test.ts +17 -17
- package/src/__tests__/credentials-cli.test.ts +257 -82
- package/src/__tests__/inbound-invite-redemption.test.ts +36 -7
- package/src/__tests__/integration-status.test.ts +31 -30
- package/src/__tests__/invite-redemption-service.test.ts +121 -32
- package/src/__tests__/invite-routes-http.test.ts +166 -5
- package/src/__tests__/list-messages-attachments.test.ts +193 -0
- package/src/__tests__/oauth-cli.test.ts +286 -60
- package/src/__tests__/oauth-provider-profiles.test.ts +1 -1
- package/src/__tests__/oauth-store.test.ts +243 -11
- package/src/__tests__/relay-server.test.ts +9 -0
- package/src/__tests__/secret-routes-managed-proxy.test.ts +183 -0
- package/src/__tests__/secure-keys.test.ts +71 -16
- package/src/__tests__/server-history-render.test.ts +2 -2
- package/src/__tests__/skills.test.ts +2 -2
- package/src/__tests__/slack-channel-config.test.ts +10 -8
- package/src/__tests__/twilio-config.test.ts +11 -10
- package/src/__tests__/twilio-provider.test.ts +9 -4
- package/src/__tests__/voice-invite-redemption.test.ts +58 -9
- package/src/calls/call-domain.ts +3 -4
- package/src/calls/relay-server.ts +1 -1
- package/src/calls/twilio-config.ts +4 -3
- package/src/calls/twilio-provider.ts +14 -9
- package/src/calls/twilio-rest.ts +10 -7
- package/src/cli/commands/config.ts +14 -9
- package/src/cli/commands/contacts.ts +3 -0
- package/src/cli/commands/credentials.ts +170 -174
- package/src/cli/commands/doctor.ts +7 -5
- package/src/cli/commands/keys.ts +9 -9
- package/src/cli/commands/oauth/apps.ts +40 -11
- package/src/cli/commands/oauth/connections.ts +66 -30
- package/src/cli/commands/oauth/index.ts +3 -3
- package/src/cli/commands/oauth/providers.ts +3 -3
- package/src/cli.ts +16 -12
- package/src/config/__tests__/feature-flag-registry-bundled.test.ts +39 -0
- package/src/config/bundled-skills/contacts/SKILL.md +35 -11
- package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
- package/src/config/bundled-skills/gmail/SKILL.md +1 -1
- package/src/config/bundled-skills/gmail/TOOLS.json +52 -0
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +13 -3
- package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +9 -2
- package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +5 -1
- package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +5 -1
- package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +5 -1
- package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +5 -1
- package/src/config/bundled-skills/gmail/tools/gmail-label.ts +9 -2
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +5 -1
- package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +5 -1
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +5 -1
- package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +5 -1
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +5 -1
- package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +5 -1
- package/src/config/bundled-skills/google-calendar/TOOLS.json +20 -0
- package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +2 -1
- package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +2 -1
- package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +2 -1
- package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +2 -1
- package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +2 -1
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +8 -2
- package/src/config/bundled-skills/messaging/SKILL.md +1 -1
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-read.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-search.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +2 -2
- package/src/config/bundled-skills/messaging/tools/shared.ts +7 -5
- package/src/config/bundled-skills/slack/tools/shared.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +1 -1
- package/src/config/loader.ts +6 -42
- package/src/contacts/contact-store.ts +39 -2
- package/src/contacts/contacts-write.ts +9 -0
- package/src/daemon/config-watcher.ts +8 -13
- package/src/daemon/handlers/config-ingress.ts +2 -2
- package/src/daemon/handlers/config-slack-channel.ts +59 -39
- package/src/daemon/handlers/config-telegram.ts +23 -14
- package/src/daemon/handlers/session-history.ts +1 -358
- package/src/daemon/handlers/shared.ts +3 -17
- package/src/daemon/lifecycle.ts +8 -1
- package/src/daemon/message-types/sessions.ts +0 -42
- package/src/daemon/server.ts +0 -6
- package/src/daemon/session-slash.ts +3 -5
- package/src/email/providers/index.ts +2 -2
- package/src/media/avatar-router.ts +1 -1
- package/src/memory/conversation-queries.ts +3 -80
- package/src/memory/db-init.ts +4 -0
- package/src/memory/invite-store.ts +19 -0
- package/src/memory/migrations/149-oauth-tables.ts +1 -1
- package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +1 -1
- package/src/memory/migrations/157-invite-contact-id.ts +104 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +6 -0
- package/src/memory/schema/contacts.ts +1 -0
- package/src/messaging/provider.ts +1 -1
- package/src/messaging/providers/gmail/adapter.ts +1 -1
- package/src/messaging/providers/telegram-bot/adapter.ts +17 -8
- package/src/messaging/providers/whatsapp/adapter.ts +13 -9
- package/src/messaging/registry.ts +9 -5
- package/src/oauth/byo-connection.test.ts +32 -24
- package/src/oauth/connect-orchestrator.ts +4 -10
- package/src/oauth/connection-resolver.ts +20 -6
- package/src/oauth/manual-token-connection.ts +5 -5
- package/src/oauth/oauth-store.ts +83 -17
- package/src/oauth/platform-connection.test.ts +1 -1
- package/src/oauth/provider-behaviors.ts +503 -4
- package/src/oauth/seed-providers.ts +208 -8
- package/src/oauth/token-persistence.ts +20 -13
- package/src/runtime/channel-readiness-service.ts +48 -40
- package/src/runtime/http-types.ts +2 -0
- package/src/runtime/invite-redemption-service.ts +71 -29
- package/src/runtime/invite-service.ts +40 -22
- package/src/runtime/middleware/twilio-validation.ts +1 -1
- package/src/runtime/routes/btw-routes.ts +10 -5
- package/src/runtime/routes/conversation-routes.ts +47 -10
- package/src/runtime/routes/integrations/slack/channel.ts +2 -2
- package/src/runtime/routes/integrations/telegram.ts +2 -2
- package/src/runtime/routes/integrations/twilio.ts +17 -17
- package/src/runtime/routes/invite-routes.ts +29 -4
- package/src/runtime/routes/secret-routes.ts +17 -0
- package/src/runtime/routes/settings-routes.ts +3 -3
- package/src/runtime/routes/workspace-routes.ts +7 -3
- package/src/runtime/routes/workspace-utils.ts +8 -2
- package/src/schedule/integration-status.ts +26 -19
- package/src/security/oauth2.ts +6 -7
- package/src/security/secure-keys.ts +19 -16
- package/src/security/token-manager.ts +13 -6
- package/src/services/vercel-deploy.ts +0 -24
- package/src/signals/confirm.ts +78 -0
- package/src/signals/mcp-reload.ts +18 -0
- package/src/tools/credentials/vault.ts +22 -5
- package/src/tools/network/script-proxy/session-manager.ts +8 -8
- package/src/tools/schedule/create.ts +2 -2
- package/src/watcher/provider-types.ts +1 -1
- package/src/watcher/providers/github.ts +1 -1
- package/src/watcher/providers/gmail.ts +3 -3
- package/src/watcher/providers/google-calendar.ts +3 -3
- package/src/watcher/providers/linear.ts +1 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ChannelId } from "../channels/types.js";
|
|
11
|
-
import { findContactChannel } from "../contacts/contact-store.js";
|
|
11
|
+
import { findContactChannel, getContact } from "../contacts/contact-store.js";
|
|
12
12
|
import { upsertContactChannel } from "../contacts/contacts-write.js";
|
|
13
13
|
import { getSqlite } from "../memory/db.js";
|
|
14
14
|
import {
|
|
@@ -135,7 +135,17 @@ export function redeemInvite(params: {
|
|
|
135
135
|
const existingChannel = contactResult?.channel ?? null;
|
|
136
136
|
const existingContact = contactResult?.contact ?? null;
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
// If the invite targets a specific contact and the sender's existing channel
|
|
139
|
+
// belongs to a different contact, ignore the existing match — the invite
|
|
140
|
+
// should bind the sender's identity to the target contact, not the existing one.
|
|
141
|
+
const targetMismatch =
|
|
142
|
+
existingContact && existingContact.id !== invite.contactId;
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
existingChannel &&
|
|
146
|
+
existingChannel.status === "active" &&
|
|
147
|
+
!targetMismatch
|
|
148
|
+
) {
|
|
139
149
|
return { ok: true, type: "already_member", memberId: existingChannel.id };
|
|
140
150
|
}
|
|
141
151
|
|
|
@@ -146,17 +156,11 @@ export function redeemInvite(params: {
|
|
|
146
156
|
return { ok: false, reason: "invalid_token" };
|
|
147
157
|
}
|
|
148
158
|
|
|
149
|
-
// Guardian channels must not be reactivated via regular invite redemption —
|
|
150
|
-
// their lifecycle is managed exclusively through the guardian binding flow.
|
|
151
|
-
if (existingContact && existingContact.role === "guardian") {
|
|
152
|
-
return { ok: false, reason: "invalid_token" };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
159
|
// Inactive member reactivation: when the user already has a member record
|
|
156
160
|
// in a non-active state (revoked/pending), reactivate it via upsertContactChannel
|
|
157
161
|
// and consume an invite use atomically. The fresh-member path below also
|
|
158
162
|
// uses upsertContactChannel to keep contacts in sync.
|
|
159
|
-
if (existingChannel) {
|
|
163
|
+
if (existingChannel && !targetMismatch) {
|
|
160
164
|
// Sentinel error used to trigger a transaction rollback when the invite
|
|
161
165
|
// was concurrently revoked/expired between pre-validation and write time.
|
|
162
166
|
const STALE_INVITE = Symbol("stale_invite");
|
|
@@ -195,6 +199,7 @@ export function redeemInvite(params: {
|
|
|
195
199
|
inviteId: invite.id,
|
|
196
200
|
verifiedAt: Date.now(),
|
|
197
201
|
verifiedVia: "invite",
|
|
202
|
+
contactId: invite.contactId,
|
|
198
203
|
});
|
|
199
204
|
|
|
200
205
|
const recorded = recordInviteUse({
|
|
@@ -226,6 +231,16 @@ export function redeemInvite(params: {
|
|
|
226
231
|
|
|
227
232
|
// Fresh member creation: upsert into contacts tables and consume an invite
|
|
228
233
|
// use atomically, mirroring the reactivation path above.
|
|
234
|
+
// When the invite targets a specific contact (targetMismatch path), preserve
|
|
235
|
+
// the target contact's guardian-assigned display name if it has one.
|
|
236
|
+
let freshDisplayName = displayName;
|
|
237
|
+
if (invite.contactId) {
|
|
238
|
+
const targetContact = getContact(invite.contactId);
|
|
239
|
+
if (targetContact?.displayName?.trim().length) {
|
|
240
|
+
freshDisplayName = targetContact.displayName;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
229
244
|
const STALE_INVITE_FRESH = Symbol("stale_invite_fresh");
|
|
230
245
|
let freshResult: ReturnType<typeof upsertContactChannel> | undefined;
|
|
231
246
|
try {
|
|
@@ -235,13 +250,14 @@ export function redeemInvite(params: {
|
|
|
235
250
|
sourceChannel,
|
|
236
251
|
externalUserId,
|
|
237
252
|
externalChatId,
|
|
238
|
-
displayName,
|
|
253
|
+
displayName: freshDisplayName,
|
|
239
254
|
username,
|
|
240
255
|
status: "active",
|
|
241
256
|
policy: "allow",
|
|
242
257
|
inviteId: invite.id,
|
|
243
258
|
verifiedAt: Date.now(),
|
|
244
259
|
verifiedVia: "invite",
|
|
260
|
+
contactId: invite.contactId,
|
|
245
261
|
});
|
|
246
262
|
|
|
247
263
|
const recorded = recordInviteUse({
|
|
@@ -346,7 +362,16 @@ export function redeemVoiceInviteCode(params: {
|
|
|
346
362
|
const existingVoiceChannel = voiceContactResult?.channel ?? null;
|
|
347
363
|
const voiceContact = voiceContactResult?.contact ?? null;
|
|
348
364
|
|
|
349
|
-
|
|
365
|
+
// If the invite targets a specific contact and the sender's existing channel
|
|
366
|
+
// belongs to a different contact, ignore the existing match — the invite
|
|
367
|
+
// should bind the sender's identity to the target contact, not the existing one.
|
|
368
|
+
const targetMismatch = voiceContact && voiceContact.id !== invite.contactId;
|
|
369
|
+
|
|
370
|
+
if (
|
|
371
|
+
existingVoiceChannel &&
|
|
372
|
+
existingVoiceChannel.status === "active" &&
|
|
373
|
+
!targetMismatch
|
|
374
|
+
) {
|
|
350
375
|
return {
|
|
351
376
|
ok: true,
|
|
352
377
|
type: "already_member",
|
|
@@ -359,21 +384,21 @@ export function redeemVoiceInviteCode(params: {
|
|
|
359
384
|
return { ok: false, reason: "invalid_or_expired" };
|
|
360
385
|
}
|
|
361
386
|
|
|
362
|
-
// Guardian channels must not be reactivated via regular invite redemption —
|
|
363
|
-
// their lifecycle is managed exclusively through the guardian binding flow.
|
|
364
|
-
if (voiceContact && voiceContact.role === "guardian") {
|
|
365
|
-
return { ok: false, reason: "invalid_or_expired" };
|
|
366
|
-
}
|
|
367
|
-
|
|
368
387
|
// Atomic redemption: upsert member + consume invite use in a transaction
|
|
369
388
|
const STALE_INVITE = Symbol("stale_invite");
|
|
370
389
|
let memberId: string | undefined;
|
|
371
390
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
391
|
+
// When the invite targets a specific contact (targetMismatch path), preserve
|
|
392
|
+
// the target contact's guardian-assigned display name if it has one.
|
|
393
|
+
let preservedDisplayName = voiceContact?.displayName?.trim().length
|
|
375
394
|
? voiceContact.displayName
|
|
376
395
|
: (invite.friendName ?? undefined);
|
|
396
|
+
if (targetMismatch && invite.contactId) {
|
|
397
|
+
const targetContact = getContact(invite.contactId);
|
|
398
|
+
if (targetContact?.displayName?.trim().length) {
|
|
399
|
+
preservedDisplayName = targetContact.displayName;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
377
402
|
|
|
378
403
|
try {
|
|
379
404
|
getSqlite()
|
|
@@ -388,6 +413,7 @@ export function redeemVoiceInviteCode(params: {
|
|
|
388
413
|
inviteId: invite.id,
|
|
389
414
|
verifiedAt: Date.now(),
|
|
390
415
|
verifiedVia: "invite",
|
|
416
|
+
contactId: invite.contactId,
|
|
391
417
|
});
|
|
392
418
|
memberId = writeResult!.channel.id;
|
|
393
419
|
|
|
@@ -488,7 +514,17 @@ export function redeemInviteByCode(params: {
|
|
|
488
514
|
const existingChannel = contactResult?.channel ?? null;
|
|
489
515
|
const existingContact = contactResult?.contact ?? null;
|
|
490
516
|
|
|
491
|
-
|
|
517
|
+
// If the invite targets a specific contact and the sender's existing channel
|
|
518
|
+
// belongs to a different contact, ignore the existing match — the invite
|
|
519
|
+
// should bind the sender's identity to the target contact, not the existing one.
|
|
520
|
+
const targetMismatch =
|
|
521
|
+
existingContact && existingContact.id !== invite.contactId;
|
|
522
|
+
|
|
523
|
+
if (
|
|
524
|
+
existingChannel &&
|
|
525
|
+
existingChannel.status === "active" &&
|
|
526
|
+
!targetMismatch
|
|
527
|
+
) {
|
|
492
528
|
return { ok: true, type: "already_member", memberId: existingChannel.id };
|
|
493
529
|
}
|
|
494
530
|
|
|
@@ -499,15 +535,9 @@ export function redeemInviteByCode(params: {
|
|
|
499
535
|
return { ok: false, reason: "invalid_token" };
|
|
500
536
|
}
|
|
501
537
|
|
|
502
|
-
// Guardian channels must not be reactivated via regular invite redemption —
|
|
503
|
-
// their lifecycle is managed exclusively through the guardian binding flow.
|
|
504
|
-
if (existingContact && existingContact.role === "guardian") {
|
|
505
|
-
return { ok: false, reason: "invalid_token" };
|
|
506
|
-
}
|
|
507
|
-
|
|
508
538
|
// Inactive member reactivation: reactivate via upsertContactChannel and consume
|
|
509
539
|
// an invite use atomically.
|
|
510
|
-
if (existingChannel) {
|
|
540
|
+
if (existingChannel && !targetMismatch) {
|
|
511
541
|
const STALE_INVITE_REACTIVATE = Symbol("stale_invite_reactivate");
|
|
512
542
|
const canonicalMemberId = existingChannel.externalUserId
|
|
513
543
|
? canonicalizeInboundIdentity(
|
|
@@ -543,6 +573,7 @@ export function redeemInviteByCode(params: {
|
|
|
543
573
|
inviteId: invite.id,
|
|
544
574
|
verifiedAt: Date.now(),
|
|
545
575
|
verifiedVia: "invite",
|
|
576
|
+
contactId: invite.contactId,
|
|
546
577
|
});
|
|
547
578
|
|
|
548
579
|
const recorded = recordInviteUse({
|
|
@@ -571,6 +602,16 @@ export function redeemInviteByCode(params: {
|
|
|
571
602
|
|
|
572
603
|
// Fresh member creation: upsert into contacts tables and consume an invite
|
|
573
604
|
// use atomically.
|
|
605
|
+
// When the invite targets a specific contact (targetMismatch path), preserve
|
|
606
|
+
// the target contact's guardian-assigned display name if it has one.
|
|
607
|
+
let freshDisplayName = displayName;
|
|
608
|
+
if (invite.contactId) {
|
|
609
|
+
const targetContact = getContact(invite.contactId);
|
|
610
|
+
if (targetContact?.displayName?.trim().length) {
|
|
611
|
+
freshDisplayName = targetContact.displayName;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
574
615
|
const STALE_INVITE_FRESH = Symbol("stale_invite_fresh");
|
|
575
616
|
let freshResult: ReturnType<typeof upsertContactChannel> | undefined;
|
|
576
617
|
try {
|
|
@@ -580,13 +621,14 @@ export function redeemInviteByCode(params: {
|
|
|
580
621
|
sourceChannel,
|
|
581
622
|
externalUserId,
|
|
582
623
|
externalChatId,
|
|
583
|
-
displayName,
|
|
624
|
+
displayName: freshDisplayName,
|
|
584
625
|
username,
|
|
585
626
|
status: "active",
|
|
586
627
|
policy: "allow",
|
|
587
628
|
inviteId: invite.id,
|
|
588
629
|
verifiedAt: Date.now(),
|
|
589
630
|
verifiedVia: "invite",
|
|
631
|
+
contactId: invite.contactId,
|
|
590
632
|
});
|
|
591
633
|
|
|
592
634
|
const recorded = recordInviteUse({
|
|
@@ -12,11 +12,13 @@ import { startInviteCall } from "../calls/call-domain.js";
|
|
|
12
12
|
import { isChannelId } from "../channels/types.js";
|
|
13
13
|
import {
|
|
14
14
|
createInvite,
|
|
15
|
+
findById,
|
|
15
16
|
findByTokenHash,
|
|
16
17
|
hashToken,
|
|
17
18
|
type IngressInvite,
|
|
18
19
|
type InviteStatus,
|
|
19
20
|
listInvites,
|
|
21
|
+
markInviteExpired,
|
|
20
22
|
revokeInvite,
|
|
21
23
|
} from "../memory/invite-store.js";
|
|
22
24
|
import {
|
|
@@ -24,7 +26,6 @@ import {
|
|
|
24
26
|
DEFAULT_USER_REFERENCE,
|
|
25
27
|
resolveGuardianName,
|
|
26
28
|
} from "../prompts/user-reference.js";
|
|
27
|
-
import { getLogger } from "../util/logger.js";
|
|
28
29
|
import { isValidE164 } from "../util/phone.js";
|
|
29
30
|
import { generateVoiceCode, hashVoiceCode } from "../util/voice-code.js";
|
|
30
31
|
import {
|
|
@@ -39,8 +40,6 @@ import {
|
|
|
39
40
|
type VoiceRedemptionOutcome,
|
|
40
41
|
} from "./invite-redemption-service.js";
|
|
41
42
|
|
|
42
|
-
const log = getLogger("invite-service");
|
|
43
|
-
|
|
44
43
|
// ---------------------------------------------------------------------------
|
|
45
44
|
// Response shapes — used by both HTTP routes and message handlers
|
|
46
45
|
// ---------------------------------------------------------------------------
|
|
@@ -160,11 +159,16 @@ export async function createIngressInvite(params: {
|
|
|
160
159
|
voiceCodeDigits?: number;
|
|
161
160
|
friendName?: string;
|
|
162
161
|
guardianName?: string;
|
|
162
|
+
contactId: string;
|
|
163
163
|
}): Promise<IngressResult<InviteResponseData>> {
|
|
164
164
|
if (!params.sourceChannel) {
|
|
165
165
|
return { ok: false, error: "sourceChannel is required for create" };
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
if (!params.contactId) {
|
|
169
|
+
return { ok: false, error: "contactId is required for create" };
|
|
170
|
+
}
|
|
171
|
+
|
|
168
172
|
// For voice invites: generate a one-time numeric code, hash it, and pass
|
|
169
173
|
// the hash to the store. The plaintext code is included in the response
|
|
170
174
|
// exactly once and never stored.
|
|
@@ -214,6 +218,7 @@ export async function createIngressInvite(params: {
|
|
|
214
218
|
|
|
215
219
|
const { invite, rawToken } = createInvite({
|
|
216
220
|
sourceChannel: params.sourceChannel,
|
|
221
|
+
contactId: params.contactId,
|
|
217
222
|
note: params.note,
|
|
218
223
|
maxUses: params.maxUses,
|
|
219
224
|
expiresInMs: params.expiresInMs,
|
|
@@ -254,25 +259,8 @@ export async function createIngressInvite(params: {
|
|
|
254
259
|
});
|
|
255
260
|
}
|
|
256
261
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
params.sourceChannel === "phone" &&
|
|
261
|
-
params.expectedExternalUserId &&
|
|
262
|
-
params.friendName &&
|
|
263
|
-
effectiveGuardianName
|
|
264
|
-
) {
|
|
265
|
-
// Fire-and-forget: don't block invite creation on call initiation
|
|
266
|
-
startInviteCall({
|
|
267
|
-
phoneNumber: params.expectedExternalUserId,
|
|
268
|
-
friendName: params.friendName,
|
|
269
|
-
guardianName: effectiveGuardianName,
|
|
270
|
-
}).catch((err) => {
|
|
271
|
-
log.warn(
|
|
272
|
-
{ err, inviteId: invite.id },
|
|
273
|
-
"Failed to initiate outbound invite call",
|
|
274
|
-
);
|
|
275
|
-
});
|
|
262
|
+
if (isVoice && params.friendName) {
|
|
263
|
+
guardianInstruction = `${params.friendName} will need this code when they answer. Share it with them first.`;
|
|
276
264
|
}
|
|
277
265
|
|
|
278
266
|
// Voice invites must not expose the token — callers must redeem via the
|
|
@@ -316,6 +304,36 @@ export function revokeIngressInvite(
|
|
|
316
304
|
return { ok: true, data: inviteToResponse(revoked) };
|
|
317
305
|
}
|
|
318
306
|
|
|
307
|
+
export async function triggerInviteCall(
|
|
308
|
+
inviteId: string,
|
|
309
|
+
): Promise<IngressResult<{ callSid: string }>> {
|
|
310
|
+
if (!inviteId) return { ok: false, error: "inviteId is required" };
|
|
311
|
+
const invite = findById(inviteId);
|
|
312
|
+
if (!invite) return { ok: false, error: "Invite not found" };
|
|
313
|
+
if (invite.status !== "active")
|
|
314
|
+
return { ok: false, error: "Invite is not active" };
|
|
315
|
+
if (invite.expiresAt && invite.expiresAt <= Date.now()) {
|
|
316
|
+
markInviteExpired(invite.id);
|
|
317
|
+
return { ok: false, error: "Invite has expired" };
|
|
318
|
+
}
|
|
319
|
+
if (invite.sourceChannel !== "phone")
|
|
320
|
+
return { ok: false, error: "Only phone invites support call triggering" };
|
|
321
|
+
if (
|
|
322
|
+
!invite.expectedExternalUserId ||
|
|
323
|
+
!invite.friendName ||
|
|
324
|
+
!invite.guardianName
|
|
325
|
+
) {
|
|
326
|
+
return { ok: false, error: "Invite is missing required voice metadata" };
|
|
327
|
+
}
|
|
328
|
+
const result = await startInviteCall({
|
|
329
|
+
phoneNumber: invite.expectedExternalUserId,
|
|
330
|
+
friendName: invite.friendName,
|
|
331
|
+
guardianName: invite.guardianName,
|
|
332
|
+
});
|
|
333
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
334
|
+
return { ok: true, data: { callSid: result.callSid } };
|
|
335
|
+
}
|
|
336
|
+
|
|
319
337
|
export function redeemIngressInvite(params: {
|
|
320
338
|
token?: string;
|
|
321
339
|
externalUserId?: string;
|
|
@@ -57,7 +57,7 @@ export async function validateTwilioWebhook(
|
|
|
57
57
|
): Promise<{ body: string } | Response> {
|
|
58
58
|
const rawBody = await req.text();
|
|
59
59
|
|
|
60
|
-
const authToken = TwilioConversationRelayProvider.getAuthToken();
|
|
60
|
+
const authToken = await TwilioConversationRelayProvider.getAuthToken();
|
|
61
61
|
|
|
62
62
|
if (!authToken) {
|
|
63
63
|
log.error(
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { buildToolDefinitions } from "../../daemon/session-tool-setup.js";
|
|
12
|
-
import {
|
|
12
|
+
import { getConversationByKey } from "../../memory/conversation-key-store.js";
|
|
13
13
|
import {
|
|
14
14
|
createTimeout,
|
|
15
15
|
userMessage,
|
|
@@ -53,10 +53,15 @@ async function handleBtw(
|
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
56
|
+
// Look up an existing conversation — never create one. BTW is ephemeral
|
|
57
|
+
// (the file header promises "No messages are persisted"), so we must not
|
|
58
|
+
// call getOrCreateConversation which would insert a DB row. When no
|
|
59
|
+
// conversation exists (e.g. greeting generation for a draft thread), we
|
|
60
|
+
// still get a usable session via getOrCreateSession with the raw key; the
|
|
61
|
+
// session lives only in memory and disappears on restart.
|
|
62
|
+
const mapping = getConversationByKey(conversationKey);
|
|
63
|
+
const sessionId = mapping?.conversationId ?? conversationKey;
|
|
64
|
+
const session = await deps.sendMessageDeps.getOrCreateSession(sessionId);
|
|
60
65
|
|
|
61
66
|
const messages = [...session.getMessages(), userMessage(content.trim())];
|
|
62
67
|
const tools = buildToolDefinitions();
|
|
@@ -315,16 +315,53 @@ export function handleListMessages(
|
|
|
315
315
|
let prevAssistantTimestamp = 0;
|
|
316
316
|
const messages: RuntimeMessagePayload[] = parsed.map((m) => {
|
|
317
317
|
let msgAttachments: RuntimeAttachmentMetadata[] = [];
|
|
318
|
-
if (m.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
318
|
+
if (m.id) {
|
|
319
|
+
if (m.role === "user") {
|
|
320
|
+
// Use metadata-only query first to avoid loading large base64
|
|
321
|
+
// blobs for non-image attachments (documents, audio). Then
|
|
322
|
+
// selectively fetch full data only for images so the client can
|
|
323
|
+
// generate thumbnails for inline display on history restore.
|
|
324
|
+
const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id);
|
|
325
|
+
if (linked.length > 0) {
|
|
326
|
+
msgAttachments = linked.map((a) => {
|
|
327
|
+
if (a.mimeType.startsWith("image/")) {
|
|
328
|
+
const full = attachmentsStore.getAttachmentById(a.id);
|
|
329
|
+
return {
|
|
330
|
+
id: a.id,
|
|
331
|
+
filename: a.originalFilename,
|
|
332
|
+
mimeType: a.mimeType,
|
|
333
|
+
sizeBytes: a.sizeBytes,
|
|
334
|
+
kind: a.kind,
|
|
335
|
+
...(full?.dataBase64 ? { data: full.dataBase64 } : {}),
|
|
336
|
+
...(a.thumbnailBase64
|
|
337
|
+
? { thumbnailData: a.thumbnailBase64 }
|
|
338
|
+
: {}),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
id: a.id,
|
|
343
|
+
filename: a.originalFilename,
|
|
344
|
+
mimeType: a.mimeType,
|
|
345
|
+
sizeBytes: a.sizeBytes,
|
|
346
|
+
kind: a.kind,
|
|
347
|
+
...(a.thumbnailBase64
|
|
348
|
+
? { thumbnailData: a.thumbnailBase64 }
|
|
349
|
+
: {}),
|
|
350
|
+
};
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id);
|
|
355
|
+
if (linked.length > 0) {
|
|
356
|
+
msgAttachments = linked.map((a) => ({
|
|
357
|
+
id: a.id,
|
|
358
|
+
filename: a.originalFilename,
|
|
359
|
+
mimeType: a.mimeType,
|
|
360
|
+
sizeBytes: a.sizeBytes,
|
|
361
|
+
kind: a.kind,
|
|
362
|
+
...(a.thumbnailBase64 ? { thumbnailData: a.thumbnailBase64 } : {}),
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
328
365
|
}
|
|
329
366
|
}
|
|
330
367
|
|
|
@@ -20,8 +20,8 @@ import type { RouteDefinition } from "../../../http-router.js";
|
|
|
20
20
|
/**
|
|
21
21
|
* GET /v1/integrations/slack/channel/config
|
|
22
22
|
*/
|
|
23
|
-
export function handleGetSlackChannelConfig(): Response {
|
|
24
|
-
const result = getSlackChannelConfig();
|
|
23
|
+
export async function handleGetSlackChannelConfig(): Promise<Response> {
|
|
24
|
+
const result = await getSlackChannelConfig();
|
|
25
25
|
return Response.json(result);
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -20,8 +20,8 @@ import type { RouteDefinition } from "../../http-router.js";
|
|
|
20
20
|
/**
|
|
21
21
|
* GET /v1/integrations/telegram/config
|
|
22
22
|
*/
|
|
23
|
-
export function handleGetTelegramConfig(): Response {
|
|
24
|
-
const result = getTelegramConfig();
|
|
23
|
+
export async function handleGetTelegramConfig(): Promise<Response> {
|
|
24
|
+
const result = await getTelegramConfig();
|
|
25
25
|
return Response.json(result);
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -66,10 +66,10 @@ function pruneAssistantPhoneNumbers(
|
|
|
66
66
|
/**
|
|
67
67
|
* GET /v1/integrations/twilio/config
|
|
68
68
|
*/
|
|
69
|
-
export function handleGetTwilioConfig(): Response {
|
|
70
|
-
const hasCredentials = hasTwilioCredentials();
|
|
69
|
+
export async function handleGetTwilioConfig(): Promise<Response> {
|
|
70
|
+
const hasCredentials = await hasTwilioCredentials();
|
|
71
71
|
const accountSid = hasCredentials
|
|
72
|
-
? getTwilioCredentials().accountSid
|
|
72
|
+
? (await getTwilioCredentials()).accountSid
|
|
73
73
|
: undefined;
|
|
74
74
|
const raw = loadRawConfig();
|
|
75
75
|
const twilio = (raw?.twilio ?? {}) as Record<string, unknown>;
|
|
@@ -100,7 +100,7 @@ export async function handleSetTwilioCredentials(
|
|
|
100
100
|
return Response.json(
|
|
101
101
|
{
|
|
102
102
|
success: false,
|
|
103
|
-
hasCredentials: hasTwilioCredentials(),
|
|
103
|
+
hasCredentials: await hasTwilioCredentials(),
|
|
104
104
|
error: "accountSid and authToken are required",
|
|
105
105
|
},
|
|
106
106
|
{ status: 400 },
|
|
@@ -120,7 +120,7 @@ export async function handleSetTwilioCredentials(
|
|
|
120
120
|
const errBody = await res.text();
|
|
121
121
|
return Response.json({
|
|
122
122
|
success: false,
|
|
123
|
-
hasCredentials: hasTwilioCredentials(),
|
|
123
|
+
hasCredentials: await hasTwilioCredentials(),
|
|
124
124
|
error: `Twilio API validation failed (${res.status}): ${errBody}`,
|
|
125
125
|
});
|
|
126
126
|
}
|
|
@@ -128,7 +128,7 @@ export async function handleSetTwilioCredentials(
|
|
|
128
128
|
const message = err instanceof Error ? err.message : String(err);
|
|
129
129
|
return Response.json({
|
|
130
130
|
success: false,
|
|
131
|
-
hasCredentials: hasTwilioCredentials(),
|
|
131
|
+
hasCredentials: await hasTwilioCredentials(),
|
|
132
132
|
error: `Failed to validate Twilio credentials: ${message}`,
|
|
133
133
|
});
|
|
134
134
|
}
|
|
@@ -231,7 +231,7 @@ export async function handleClearTwilioCredentials(): Promise<Response> {
|
|
|
231
231
|
* GET /v1/integrations/twilio/numbers
|
|
232
232
|
*/
|
|
233
233
|
export async function handleListTwilioNumbers(): Promise<Response> {
|
|
234
|
-
if (!hasTwilioCredentials()) {
|
|
234
|
+
if (!(await hasTwilioCredentials())) {
|
|
235
235
|
return Response.json({
|
|
236
236
|
success: false,
|
|
237
237
|
hasCredentials: false,
|
|
@@ -239,7 +239,7 @@ export async function handleListTwilioNumbers(): Promise<Response> {
|
|
|
239
239
|
});
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
const { accountSid, authToken } = getTwilioCredentials();
|
|
242
|
+
const { accountSid, authToken } = await getTwilioCredentials();
|
|
243
243
|
const numbers = await listIncomingPhoneNumbers(accountSid, authToken);
|
|
244
244
|
|
|
245
245
|
return Response.json({ success: true, hasCredentials: true, numbers });
|
|
@@ -253,7 +253,7 @@ export async function handleListTwilioNumbers(): Promise<Response> {
|
|
|
253
253
|
export async function handleProvisionTwilioNumber(
|
|
254
254
|
req: Request,
|
|
255
255
|
): Promise<Response> {
|
|
256
|
-
if (!hasTwilioCredentials()) {
|
|
256
|
+
if (!(await hasTwilioCredentials())) {
|
|
257
257
|
return Response.json({
|
|
258
258
|
success: false,
|
|
259
259
|
hasCredentials: false,
|
|
@@ -265,7 +265,7 @@ export async function handleProvisionTwilioNumber(
|
|
|
265
265
|
country?: string;
|
|
266
266
|
areaCode?: string;
|
|
267
267
|
};
|
|
268
|
-
const { accountSid, authToken } = getTwilioCredentials();
|
|
268
|
+
const { accountSid, authToken } = await getTwilioCredentials();
|
|
269
269
|
const country = body.country ?? "US";
|
|
270
270
|
|
|
271
271
|
const available = await searchAvailableNumbers(
|
|
@@ -324,7 +324,7 @@ export async function handleAssignTwilioNumber(
|
|
|
324
324
|
return Response.json(
|
|
325
325
|
{
|
|
326
326
|
success: false,
|
|
327
|
-
hasCredentials: hasTwilioCredentials(),
|
|
327
|
+
hasCredentials: await hasTwilioCredentials(),
|
|
328
328
|
error: "phoneNumber is required",
|
|
329
329
|
},
|
|
330
330
|
{ status: 400 },
|
|
@@ -339,9 +339,9 @@ export async function handleAssignTwilioNumber(
|
|
|
339
339
|
|
|
340
340
|
// Best-effort webhook configuration when credentials are available
|
|
341
341
|
let webhookWarning: string | undefined;
|
|
342
|
-
if (hasTwilioCredentials()) {
|
|
342
|
+
if (await hasTwilioCredentials()) {
|
|
343
343
|
const { accountSid: acctSid, authToken: acctToken } =
|
|
344
|
-
getTwilioCredentials();
|
|
344
|
+
await getTwilioCredentials();
|
|
345
345
|
const webhookResult = await syncTwilioWebhooks(
|
|
346
346
|
body.phoneNumber,
|
|
347
347
|
acctSid,
|
|
@@ -353,7 +353,7 @@ export async function handleAssignTwilioNumber(
|
|
|
353
353
|
|
|
354
354
|
return Response.json({
|
|
355
355
|
success: true,
|
|
356
|
-
hasCredentials: hasTwilioCredentials(),
|
|
356
|
+
hasCredentials: await hasTwilioCredentials(),
|
|
357
357
|
phoneNumber: body.phoneNumber,
|
|
358
358
|
warning: webhookWarning,
|
|
359
359
|
});
|
|
@@ -367,7 +367,7 @@ export async function handleAssignTwilioNumber(
|
|
|
367
367
|
export async function handleReleaseTwilioNumber(
|
|
368
368
|
req: Request,
|
|
369
369
|
): Promise<Response> {
|
|
370
|
-
if (!hasTwilioCredentials()) {
|
|
370
|
+
if (!(await hasTwilioCredentials())) {
|
|
371
371
|
return Response.json({
|
|
372
372
|
success: false,
|
|
373
373
|
hasCredentials: false,
|
|
@@ -389,7 +389,7 @@ export async function handleReleaseTwilioNumber(
|
|
|
389
389
|
});
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
const { accountSid, authToken } = getTwilioCredentials();
|
|
392
|
+
const { accountSid, authToken } = await getTwilioCredentials();
|
|
393
393
|
|
|
394
394
|
await releasePhoneNumber(accountSid, authToken, phoneNumber);
|
|
395
395
|
|
|
@@ -416,7 +416,7 @@ export function twilioRouteDefinitions(): RouteDefinition[] {
|
|
|
416
416
|
{
|
|
417
417
|
endpoint: "integrations/twilio/config",
|
|
418
418
|
method: "GET",
|
|
419
|
-
handler: () => handleGetTwilioConfig(),
|
|
419
|
+
handler: async () => handleGetTwilioConfig(),
|
|
420
420
|
},
|
|
421
421
|
{
|
|
422
422
|
endpoint: "integrations/twilio/credentials",
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Route handlers for invite management.
|
|
3
3
|
*
|
|
4
4
|
* Invites:
|
|
5
|
-
* GET /v1/contacts/invites
|
|
6
|
-
* POST /v1/contacts/invites
|
|
7
|
-
* DELETE /v1/contacts/invites/:id
|
|
8
|
-
* POST /v1/contacts/invites/redeem
|
|
5
|
+
* GET /v1/contacts/invites — list invites
|
|
6
|
+
* POST /v1/contacts/invites — create an invite (supports voice)
|
|
7
|
+
* DELETE /v1/contacts/invites/:id — revoke an invite
|
|
8
|
+
* POST /v1/contacts/invites/redeem — redeem an invite (token or voice code)
|
|
9
|
+
* POST /v1/contacts/invites/:id/call — trigger an outbound call for a phone invite
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import type { RouteDefinition } from "../http-router.js";
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
redeemIngressInvite,
|
|
16
17
|
redeemVoiceInviteCode,
|
|
17
18
|
revokeIngressInvite,
|
|
19
|
+
triggerInviteCall,
|
|
18
20
|
} from "../invite-service.js";
|
|
19
21
|
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
@@ -57,6 +59,7 @@ export async function handleCreateInvite(req: Request): Promise<Response> {
|
|
|
57
59
|
voiceCodeDigits: body.voiceCodeDigits as number | undefined,
|
|
58
60
|
friendName: body.friendName as string | undefined,
|
|
59
61
|
guardianName: body.guardianName as string | undefined,
|
|
62
|
+
contactId: body.contactId as string,
|
|
60
63
|
});
|
|
61
64
|
|
|
62
65
|
if (!result.ok) {
|
|
@@ -141,6 +144,22 @@ export async function handleRedeemInvite(req: Request): Promise<Response> {
|
|
|
141
144
|
return Response.json({ ok: true, invite: result.data });
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
/**
|
|
148
|
+
* POST /v1/contacts/invites/:id/call
|
|
149
|
+
*
|
|
150
|
+
* Trigger an outbound call for a phone invite. The invite must be active and
|
|
151
|
+
* have sourceChannel "phone" with the required voice metadata populated.
|
|
152
|
+
*/
|
|
153
|
+
export async function handleTriggerInviteCall(
|
|
154
|
+
inviteId: string,
|
|
155
|
+
): Promise<Response> {
|
|
156
|
+
const result = await triggerInviteCall(inviteId);
|
|
157
|
+
if (!result.ok) {
|
|
158
|
+
return Response.json({ ok: false, error: result.error }, { status: 400 });
|
|
159
|
+
}
|
|
160
|
+
return Response.json({ ok: true, callSid: result.data.callSid });
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
// ---------------------------------------------------------------------------
|
|
145
164
|
// Route definitions
|
|
146
165
|
// ---------------------------------------------------------------------------
|
|
@@ -168,5 +187,11 @@ export function inviteRouteDefinitions(): RouteDefinition[] {
|
|
|
168
187
|
policyKey: "contacts/invites",
|
|
169
188
|
handler: ({ params }) => handleRevokeInvite(params.id),
|
|
170
189
|
},
|
|
190
|
+
{
|
|
191
|
+
endpoint: "contacts/invites/:id/call",
|
|
192
|
+
method: "POST",
|
|
193
|
+
policyKey: "contacts/invites",
|
|
194
|
+
handler: async ({ params }) => handleTriggerInviteCall(params.id),
|
|
195
|
+
},
|
|
171
196
|
];
|
|
172
197
|
}
|