bloby-bot 0.48.0 → 0.48.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.48.0",
3
+ "version": "0.48.2",
4
4
  "releaseNotes": [
5
5
  "1. Something great..",
6
6
  "2. ",
package/shared/config.ts CHANGED
@@ -13,6 +13,13 @@ export interface ChannelConfig {
13
13
  allowGroups?: boolean;
14
14
  }
15
15
 
16
+ export interface AlexaChannelConfig {
17
+ enabled: boolean;
18
+ /** Per-user shared secret minted by the relay when the user first pairs an Alexa device.
19
+ * Used to verify that inbound /api/channels/alexa/handle calls actually came from the relay. */
20
+ sharedSecret?: string;
21
+ }
22
+
16
23
  export interface BotConfig {
17
24
  port: number;
18
25
  username: string;
@@ -39,6 +46,7 @@ export interface BotConfig {
39
46
  };
40
47
  channels?: {
41
48
  whatsapp?: ChannelConfig;
49
+ alexa?: AlexaChannelConfig;
42
50
  };
43
51
  tunnelUrl?: string;
44
52
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Alexa channel provider.
3
+ *
4
+ * Unlike WhatsApp, this channel has no socket, no QR, no auth state of its own.
5
+ * It's a degenerate provider whose `sendMessage` resolves a promise held by an
6
+ * inbound HTTP handler. The relay (api.bloby.bot) is the public entry point that
7
+ * verifies Amazon's signature, then forwards the parsed utterance to this Pi
8
+ * via POST /api/channels/alexa/handle. That route waits for the agent's reply
9
+ * and returns it as the HTTP response, which the relay then formats as the
10
+ * Alexa-flavored JSON envelope.
11
+ *
12
+ * The promise-resolution flow is per-conversation FIFO, mirroring the same
13
+ * anti-bleed contract WhatsApp uses: each pushed utterance reserves a resolver
14
+ * slot, each bot:response consumes the head slot.
15
+ */
16
+
17
+ import { loadConfig } from '../../shared/config.js';
18
+ import { log } from '../../shared/logger.js';
19
+ import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
20
+
21
+ /** Credentials + state needed to fire Progressive Response for a single Alexa turn. */
22
+ export interface AlexaTurnState {
23
+ apiEndpoint: string;
24
+ apiAccessToken: string;
25
+ requestId: string;
26
+ /** Wall-clock start of the turn — used for log timing. */
27
+ startedAt: number;
28
+ /** Static-fallback timer that fires "Working on it" if no preamble text arrives in time. */
29
+ fallbackTimer: ReturnType<typeof setTimeout> | null;
30
+ /** True once we've sent at least one Progressive Response for this turn. */
31
+ sentAny: boolean;
32
+ }
33
+
34
+ interface PendingSlot {
35
+ resolve: (text: string) => void;
36
+ reject: (err: Error) => void;
37
+ createdAt: number;
38
+ turn: AlexaTurnState | null;
39
+ }
40
+
41
+ const STATIC_FALLBACK_MS = 1_500; // Fire early enough to extend Alexa's budget on cold start
42
+ const MAX_PROGRESSIVE_SPEECH = 600;
43
+
44
+ export class AlexaChannel implements ChannelProvider {
45
+ readonly type: ChannelType = 'alexa';
46
+
47
+ /** Per-conversation FIFO of pending HTTP-response resolvers + turn state.
48
+ * Each inbound Alexa utterance enqueues one slot; each bot:response (or
49
+ * turn-complete safety net) dequeues one. */
50
+ private pending = new Map<string, PendingSlot[]>();
51
+
52
+ /** Reserve a resolver slot. The caller pushes the user utterance into the
53
+ * live conversation IMMEDIATELY after this returns, so the FIFO order on
54
+ * this map matches the FIFO order the routing queue uses.
55
+ *
56
+ * If `creds` are provided (apiEndpoint + apiAccessToken + requestId), this
57
+ * also schedules a static "Working on it" Progressive Response to fire if
58
+ * the agent emits no preamble text within STATIC_FALLBACK_MS. The fallback
59
+ * is cancelled the moment any Progressive Response is sent for this turn. */
60
+ reservePending(
61
+ convId: string,
62
+ creds: { apiEndpoint: string; apiAccessToken: string; requestId: string } | null,
63
+ timeoutMs = 25_000,
64
+ ): Promise<string> {
65
+ return new Promise<string>((resolve, reject) => {
66
+ const turn: AlexaTurnState | null = creds ? {
67
+ apiEndpoint: creds.apiEndpoint,
68
+ apiAccessToken: creds.apiAccessToken,
69
+ requestId: creds.requestId,
70
+ startedAt: Date.now(),
71
+ fallbackTimer: null,
72
+ sentAny: false,
73
+ } : null;
74
+
75
+ const slot: PendingSlot = { resolve, reject, createdAt: Date.now(), turn };
76
+ let q = this.pending.get(convId);
77
+ if (!q) {
78
+ q = [];
79
+ this.pending.set(convId, q);
80
+ }
81
+ q.push(slot);
82
+
83
+ // NOTE: the relay fires an immediate "On it." Progressive Response on
84
+ // every AgentIntent turn (see relay's sendImmediateProgressive), so the
85
+ // budget is already extended by the time we get here. We DON'T schedule
86
+ // a static fallback on the Pi side — it would double up and the user
87
+ // would hear "On it. Working on it." If the agent emits a preamble, we
88
+ // still flush it as a Progressive in `tryFlushProgressive`.
89
+
90
+ // Hard safety timeout — if the agent never responds at all, the HTTP
91
+ // handler unblocks so the relay can return a friendly fallback. The
92
+ // slot is removed so a late bot:response doesn't resolve a dead promise.
93
+ setTimeout(() => {
94
+ const list = this.pending.get(convId);
95
+ if (!list) return;
96
+ const idx = list.indexOf(slot);
97
+ if (idx >= 0) {
98
+ if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer);
99
+ list.splice(idx, 1);
100
+ if (list.length === 0) this.pending.delete(convId);
101
+ reject(new Error('alexa-timeout'));
102
+ }
103
+ }, timeoutMs);
104
+ });
105
+ }
106
+
107
+ /** Resolve the head resolver for a conversation. Called by the channel
108
+ * manager when bot:response (or turn-complete with no response) fires for
109
+ * an alexa-surface turn. */
110
+ resolveHead(convId: string, text: string): boolean {
111
+ const q = this.pending.get(convId);
112
+ if (!q || q.length === 0) return false;
113
+ const slot = q.shift()!;
114
+ if (q.length === 0) this.pending.delete(convId);
115
+ if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer);
116
+ slot.resolve(text);
117
+ return true;
118
+ }
119
+
120
+ /** Drop the head resolver (used when a turn ends without a bot:response). */
121
+ rejectHead(convId: string, reason: string): boolean {
122
+ const q = this.pending.get(convId);
123
+ if (!q || q.length === 0) return false;
124
+ const slot = q.shift()!;
125
+ if (q.length === 0) this.pending.delete(convId);
126
+ if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer);
127
+ slot.reject(new Error(reason));
128
+ return true;
129
+ }
130
+
131
+ /** Try to flush a buffered preamble chunk as Progressive Response on the
132
+ * head turn. Called by the channel manager on bot:tool events when the
133
+ * routing target's surface is 'alexa' and there's buffered text. */
134
+ tryFlushProgressive(convId: string, text: string): boolean {
135
+ const q = this.pending.get(convId);
136
+ if (!q || q.length === 0) return false;
137
+ const turn = q[0].turn;
138
+ if (!turn) return false;
139
+ this.sendProgressive(turn, text).catch(() => {});
140
+ return true;
141
+ }
142
+
143
+ /** Fire a single Progressive Response directive to Amazon's Directive Service.
144
+ * Best-effort: failures are logged but don't break the agent's stream. */
145
+ private async sendProgressive(turn: AlexaTurnState, speech: string): Promise<void> {
146
+ const trimmed = String(speech || '').trim();
147
+ if (!trimmed) return;
148
+ const fireOffset = Date.now() - turn.startedAt;
149
+ try {
150
+ const r = await fetch(`${turn.apiEndpoint}/v1/directives`, {
151
+ method: 'POST',
152
+ headers: {
153
+ Authorization: `Bearer ${turn.apiAccessToken}`,
154
+ 'Content-Type': 'application/json',
155
+ },
156
+ body: JSON.stringify({
157
+ header: { requestId: turn.requestId },
158
+ directive: {
159
+ type: 'VoicePlayer.Speak',
160
+ speech: trimmed.slice(0, MAX_PROGRESSIVE_SPEECH),
161
+ },
162
+ }),
163
+ });
164
+ if (r.ok) {
165
+ turn.sentAny = true;
166
+ if (turn.fallbackTimer) {
167
+ clearTimeout(turn.fallbackTimer);
168
+ turn.fallbackTimer = null;
169
+ }
170
+ log.info(`[alexa/progressive] sent at +${fireOffset}ms (status ${r.status}) — "${trimmed.slice(0, 60)}"`);
171
+ } else {
172
+ const body = await r.text().catch(() => '');
173
+ log.warn(`[alexa/progressive] REJECTED at +${fireOffset}ms — status ${r.status} body=${body.slice(0, 200)}`);
174
+ }
175
+ } catch (err: any) {
176
+ log.warn(`[alexa/progressive] FAILED at +${fireOffset}ms — ${err.message}`);
177
+ }
178
+ }
179
+
180
+ // ── ChannelProvider implementation ──
181
+
182
+ async connect(): Promise<void> {
183
+ // No-op. Alexa is a passive HTTP receiver.
184
+ log.info('[alexa] Channel ready (passive HTTP receiver)');
185
+ }
186
+
187
+ async disconnect(): Promise<void> {
188
+ // Reject any in-flight resolvers so HTTP handlers don't hang forever,
189
+ // and cancel any pending fallback timers to avoid late progressive calls.
190
+ for (const [, q] of this.pending) {
191
+ for (const slot of q) {
192
+ if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer);
193
+ slot.reject(new Error('alexa-disconnected'));
194
+ }
195
+ }
196
+ this.pending.clear();
197
+ }
198
+
199
+ async sendMessage(_to: string, _text: string): Promise<void> {
200
+ // Alexa is request/response. Replies are delivered by resolving the
201
+ // pending HTTP response, not by calling this method. ChannelManager
202
+ // routes alexa-surface turns through `resolveHead`, not `sendMessage`.
203
+ log.warn('[alexa] sendMessage called on Alexa channel — ignored (use resolveHead)');
204
+ }
205
+
206
+ getStatus(): ChannelStatus {
207
+ const cfg = loadConfig().channels?.alexa;
208
+ return {
209
+ channel: 'alexa',
210
+ connected: !!cfg?.enabled,
211
+ info: {
212
+ linked: !!cfg?.sharedSecret,
213
+ },
214
+ };
215
+ }
216
+
217
+ getQrCode(): string | null { return null; }
218
+ hasCredentials(): boolean { return !!loadConfig().channels?.alexa?.sharedSecret; }
219
+ }
@@ -25,6 +25,7 @@ import { WORKSPACE_DIR } from '../../shared/paths.js';
25
25
  import { log } from '../../shared/logger.js';
26
26
  import { startBlobyAgentQuery, startConversation, pushMessage, hasConversation, type RecentMessage } from '../bloby-agent.js';
27
27
  import { WhatsAppChannel } from './whatsapp.js';
28
+ import { AlexaChannel } from './alexa.js';
28
29
  import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, RoutingTarget, SenderRole } from './types.js';
29
30
  import type { AgentAttachment } from '../bloby-agent.js';
30
31
  import { saveAttachment, type SavedFile } from '../file-saver.js';
@@ -106,34 +107,41 @@ export class ChannelManager {
106
107
  this.opts = opts;
107
108
  }
108
109
 
109
- /** Initialize channels based on config */
110
+ /** Initialize channels based on config. Idempotent — calling repeatedly
111
+ * initializes any newly-enabled channel without disturbing ones already up. */
110
112
  async init(): Promise<void> {
111
113
  const config = loadConfig();
112
114
  const channelConfigs = config.channels;
113
115
 
114
- if (!channelConfigs?.whatsapp?.enabled) {
116
+ if (channelConfigs?.whatsapp?.enabled && !this.providers.has('whatsapp')) {
117
+ log.info('[channels] Initializing WhatsApp channel...');
118
+ const whatsapp = new WhatsAppChannel(
119
+ (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
120
+ const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
121
+ this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
122
+ },
123
+ (status) => this.handleStatusChange(status),
124
+ (audioBase64) => this.transcribeAudio(audioBase64),
125
+ );
126
+ this.providers.set('whatsapp', whatsapp);
127
+
128
+ // Auto-connect if credentials exist (previously linked)
129
+ if (whatsapp.hasCredentials()) {
130
+ try {
131
+ await whatsapp.connect();
132
+ } catch (err: any) {
133
+ log.warn(`[channels] WhatsApp auto-connect failed: ${err.message}`);
134
+ }
135
+ }
136
+ } else if (!channelConfigs?.whatsapp?.enabled && !this.providers.has('whatsapp')) {
115
137
  log.info('[channels] WhatsApp not enabled — skipping');
116
- return;
117
138
  }
118
139
 
119
- log.info('[channels] Initializing WhatsApp channel...');
120
- const whatsapp = new WhatsAppChannel(
121
- (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
122
- const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
123
- this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
124
- },
125
- (status) => this.handleStatusChange(status),
126
- (audioBase64) => this.transcribeAudio(audioBase64),
127
- );
128
- this.providers.set('whatsapp', whatsapp);
129
-
130
- // Auto-connect if credentials exist (previously linked)
131
- if (whatsapp.hasCredentials()) {
132
- try {
133
- await whatsapp.connect();
134
- } catch (err: any) {
135
- log.warn(`[channels] WhatsApp auto-connect failed: ${err.message}`);
136
- }
140
+ if (channelConfigs?.alexa?.enabled && !this.providers.has('alexa')) {
141
+ log.info('[channels] Initializing Alexa channel...');
142
+ const alexa = new AlexaChannel();
143
+ this.providers.set('alexa', alexa);
144
+ await alexa.connect();
137
145
  }
138
146
  }
139
147
 
@@ -446,17 +454,36 @@ export class ChannelManager {
446
454
  // Agent paused for a tool call — flush streamed text so the user sees progress
447
455
  // before the tool result lands. Peek (don't consume) — the final bot:response
448
456
  // is what closes out the turn.
449
- this.sendStreamChunk(this.peekRoute(convId), state.chunkBuf.trim(), botName);
450
- state.chunkBuf = '';
457
+ const head = this.peekRoute(convId);
458
+ if (head?.surface === 'alexa') {
459
+ // For Alexa, send the preamble as a Progressive Response so the user
460
+ // hears the agent's actual "I'll do X..." line. Final bot:response is
461
+ // still what closes the turn with the agent's last words.
462
+ const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
463
+ alexa?.tryFlushProgressive(convId, state.chunkBuf.trim());
464
+ state.chunkBuf = '';
465
+ } else {
466
+ this.sendStreamChunk(head, state.chunkBuf.trim(), botName);
467
+ state.chunkBuf = '';
468
+ }
451
469
  return;
452
470
  }
453
471
 
454
472
  if (type === 'bot:response' && eventData?.content && convId) {
455
473
  const target = this.consumeRoute(convId);
456
474
  const remaining = state.chunkBuf.trim();
457
- if (remaining) this.sendStreamChunk(target, remaining, botName);
458
475
  state.chunkBuf = '';
459
476
 
477
+ if (target?.surface === 'alexa') {
478
+ // Resolve the HTTP handler's promise with the full reply text.
479
+ const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
480
+ if (!alexa || !alexa.resolveHead(convId, eventData.content)) {
481
+ log.warn(`[channels] bot:response for alexa surface but no pending resolver (conv=${convId})`);
482
+ }
483
+ } else if (remaining) {
484
+ this.sendStreamChunk(target, remaining, botName);
485
+ }
486
+
460
487
  // Append the assistant's reply into the per-chat context buffer so the next
461
488
  // trigger in that chat sees it as conversation history.
462
489
  if (target?.assistantBufferKey) {
@@ -473,9 +500,14 @@ export class ChannelManager {
473
500
  // doesn't bleed into the next turn's reply. The SDK guarantees one response per
474
501
  // pushed input; this safety net covers aborts, empty turns, and provider errors.
475
502
  if ((type === 'bot:turn-complete' || type === 'bot:error') && convId) {
476
- if (this.peekRoute(convId)) {
503
+ const head = this.peekRoute(convId);
504
+ if (head) {
477
505
  const dropped = this.consumeRoute(convId);
478
506
  log.warn(`[channels] ${type} without bot:response — dropping pending route (surface=${dropped?.surface}, to=${dropped?.waSendTo || 'none'})`);
507
+ if (dropped?.surface === 'alexa') {
508
+ const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
509
+ alexa?.rejectHead(convId, type);
510
+ }
479
511
  }
480
512
  state.chunkBuf = '';
481
513
  }
@@ -872,6 +904,137 @@ export class ChannelManager {
872
904
  this.pushWithRouting(convId, target, channelContent, agentAttachments, savedFiles);
873
905
  }
874
906
 
907
+ /** Synchronously handle an Alexa utterance: push into the shared conversation,
908
+ * await the agent's reply, return it. Used by /api/channels/alexa/handle.
909
+ *
910
+ * Returns the final reply text. Throws on timeout / aborted turn so the HTTP
911
+ * handler can fall back to "I'll get back to you in chat" (or HA announce).
912
+ */
913
+ async handleAlexaInbound(opts: {
914
+ text: string;
915
+ alexaUserId: string;
916
+ alexaSessionId?: string;
917
+ deviceId?: string;
918
+ locale?: string;
919
+ /** Alexa Directive Service base URL — passed through from the relay. */
920
+ apiEndpoint?: string;
921
+ /** Alexa apiAccessToken for the current request — required to fire Progressive Response. */
922
+ apiAccessToken?: string;
923
+ /** Original Alexa requestId — required to fire Progressive Response. */
924
+ requestId?: string;
925
+ timeoutMs?: number;
926
+ }): Promise<string> {
927
+ const { text, alexaUserId, alexaSessionId, deviceId, locale, apiEndpoint, apiAccessToken, requestId, timeoutMs = 25_000 } = opts;
928
+ const { workerApi, broadcastBloby, getModel } = this.opts;
929
+ const model = getModel();
930
+
931
+ const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
932
+ if (!alexa) throw new Error('alexa-channel-not-initialized');
933
+
934
+ // Get or create the shared conversation (same one chat + WhatsApp use).
935
+ let convId: string | undefined;
936
+ try {
937
+ const ctx = await workerApi('/api/context/current');
938
+ if (ctx.conversationId) {
939
+ convId = ctx.conversationId;
940
+ } else {
941
+ const conv = await workerApi('/api/conversations', 'POST', { title: 'Alexa', model });
942
+ convId = conv.id;
943
+ await workerApi('/api/context/set', 'POST', { conversationId: convId });
944
+ }
945
+ } catch (err: any) {
946
+ log.warn(`[channels/alexa] Failed to get/create conversation: ${err.message}`);
947
+ throw err;
948
+ }
949
+ if (!convId) throw new Error('no-conversation');
950
+
951
+ // Persist + mirror to dashboard so the user sees the Alexa utterance in chat.
952
+ workerApi(`/api/conversations/${convId}/messages`, 'POST', {
953
+ role: 'user',
954
+ content: text,
955
+ meta: { model, channel: 'alexa' },
956
+ }).catch((err: any) => log.warn(`[channels/alexa] DB persist error: ${err.message}`));
957
+ broadcastBloby('chat:sync', {
958
+ conversationId: convId,
959
+ message: { role: 'user', content: text, timestamp: new Date().toISOString() },
960
+ });
961
+
962
+ // Make sure a live conversation exists (mirrors handleAdminMessage's setup).
963
+ if (!hasConversation(convId)) {
964
+ log.info(`[channels/alexa] Starting live conversation: ${convId}`);
965
+ let botName = 'Bloby', humanName = 'Human';
966
+ let recentMessages: RecentMessage[] = [];
967
+ try {
968
+ const [status, recentRaw] = await Promise.all([
969
+ workerApi('/api/onboard/status'),
970
+ workerApi(`/api/conversations/${convId}/messages/recent?limit=20`),
971
+ ]);
972
+ botName = status.agentName || 'Bloby';
973
+ humanName = status.userName || 'Human';
974
+ if (Array.isArray(recentRaw)) {
975
+ const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
976
+ if (filtered.length > 0) {
977
+ recentMessages = filtered.slice(0, -1).map((m: any) => ({
978
+ role: m.role as 'user' | 'assistant',
979
+ content: m.content,
980
+ }));
981
+ }
982
+ }
983
+ } catch {}
984
+
985
+ const waState = this.createWaStreamState();
986
+ await startConversation(convId, model, (type, eventData) => {
987
+ // Same routing as WhatsApp — alexa surface branch lives inside this method.
988
+ this.routeWaStreamEvent(waState, type, eventData, botName);
989
+
990
+ if (type === 'bot:response' && eventData.content) {
991
+ workerApi(`/api/conversations/${convId}/messages`, 'POST', {
992
+ role: 'assistant',
993
+ content: eventData.content,
994
+ meta: { model },
995
+ }).catch(() => {});
996
+ }
997
+
998
+ if (type === 'bot:turn-complete') {
999
+ if (eventData.usedFileTools) this.opts.restartBackend();
1000
+ broadcastBloby('bot:idle', { conversationId: convId });
1001
+ return;
1002
+ }
1003
+
1004
+ if (type === 'bot:conversation-ended') {
1005
+ this.clearRoutes(convId);
1006
+ return;
1007
+ }
1008
+
1009
+ broadcastBloby(type, eventData);
1010
+ }, { botName, humanName }, recentMessages);
1011
+ }
1012
+
1013
+ // Reserve the resolver slot FIRST — the agent may respond very quickly and
1014
+ // we don't want the bot:response to arrive before the resolver is in the FIFO.
1015
+ // Also pass the Alexa Directive Service credentials so the channel can fire
1016
+ // Progressive Response on every preamble chunk the agent emits before tool calls.
1017
+ const creds = (apiEndpoint && apiAccessToken && requestId)
1018
+ ? { apiEndpoint, apiAccessToken, requestId }
1019
+ : null;
1020
+ const pending = alexa.reservePending(convId, creds, timeoutMs);
1021
+
1022
+ // Compact device id so the tag stays readable — agent can correlate the
1023
+ // last 8 chars with its memory of "device XYZ = kitchen / office / ..."
1024
+ const deviceTag = deviceId ? ` | device=${deviceId.slice(-8)}` : '';
1025
+ const sessionTag = alexaSessionId ? ` | session=${alexaSessionId.slice(-6)}` : '';
1026
+ const localeTag = locale ? ` | ${locale}` : '';
1027
+ const channelContext = `[Alexa | user=${alexaUserId.slice(-8)}${deviceTag}${sessionTag}${localeTag}]\n`;
1028
+ const target: RoutingTarget = {
1029
+ surface: 'alexa',
1030
+ isSelfChat: false,
1031
+ isGroup: false,
1032
+ };
1033
+ this.pushWithRouting(convId, target, channelContext + text);
1034
+
1035
+ return pending; // resolves with the reply text (or rejects on timeout)
1036
+ }
1037
+
875
1038
  /** Handle message from a customer — runs support agent in parallel with conversation context */
876
1039
  private async handleCustomerMessage(msg: InboundMessage, channelConfig: ChannelConfig) {
877
1040
  const agentKey = `${msg.channel}:${msg.sender}`;
@@ -2,7 +2,7 @@
2
2
  * Shared types for the multi-channel messaging system.
3
3
  */
4
4
 
5
- export type ChannelType = 'whatsapp' | 'telegram';
5
+ export type ChannelType = 'whatsapp' | 'telegram' | 'alexa';
6
6
  export type SenderRole = 'admin' | 'customer' | 'assistant';
7
7
 
8
8
  export interface ChannelConfig {
@@ -70,8 +70,10 @@ export interface ChannelStatus {
70
70
  * the turn ends without a response (error / empty turn).
71
71
  */
72
72
  export interface RoutingTarget {
73
- /** Which surface triggered this turn. Drives whether the WA reply carries a "🤖 Bot:" prefix. */
74
- surface: 'chat' | 'whatsapp';
73
+ /** Which surface triggered this turn. Drives whether the WA reply carries a "🤖 Bot:" prefix.
74
+ * 'workspace' is a dashboard surface like 'chat' (broadcast-driven, optional WA self-chat mirror)
75
+ * but isolated for telemetry / future per-surface routing. */
76
+ surface: 'chat' | 'whatsapp' | 'alexa' | 'workspace';
75
77
  /** WhatsApp JID to deliver the reply to.
76
78
  * - 'whatsapp' surface → the originating chat JID (group or peer).
77
79
  * - 'chat' surface → optionally the user's own number (self-chat mirror), or undefined.
@@ -29,18 +29,27 @@ function formatTime(iso: string): string {
29
29
  }
30
30
  }
31
31
 
32
- /** Convert WhatsApp QR URL (any format) into a markdown link so the button renders */
32
+ /** Convert channel-pair URLs (any format) into markdown links so the buttons render */
33
33
  function preprocessContent(text: string): string {
34
- const link = '[pair-whatsapp](/api/channels/whatsapp/qr-page)';
35
- // Already a properly formatted markdown link — skip
36
- if (text.includes(link)) return text;
37
- // Backtick-wrapped full URL: `http://localhost:7400/api/channels/whatsapp/qr-page`
38
- // Full URL in plain text: http://localhost:7400/api/channels/whatsapp/qr-page
39
- // Bare relative path: /api/channels/whatsapp/qr-page
40
- return text.replace(
41
- /`?(?:http:\/\/localhost:\d+)?\/api\/channels\/whatsapp\/qr-page`?/g,
42
- link,
43
- );
34
+ let out = text;
35
+
36
+ const waLink = '[pair-whatsapp](/api/channels/whatsapp/qr-page)';
37
+ if (!out.includes(waLink)) {
38
+ out = out.replace(
39
+ /`?(?:http:\/\/localhost:\d+)?\/api\/channels\/whatsapp\/qr-page`?/g,
40
+ waLink,
41
+ );
42
+ }
43
+
44
+ const alexaLink = '[connect-alexa](/api/channels/alexa/pair-page)';
45
+ if (!out.includes(alexaLink)) {
46
+ out = out.replace(
47
+ /`?(?:http:\/\/localhost:\d+)?\/api\/channels\/alexa\/pair-page`?/g,
48
+ alexaLink,
49
+ );
50
+ }
51
+
52
+ return out;
44
53
  }
45
54
 
46
55
  type ContentSegment = { type: 'text'; value: string } | { type: 'env'; group: EnvGroupData } | { type: 'bloby-image'; src: string; alt: string } | { type: 'bloby-text'; title: string; content: string };
@@ -263,6 +272,27 @@ export default function MessageBubble({ role, content, timestamp, hasAttachments
263
272
  </a>
264
273
  );
265
274
  }
275
+ if (href?.includes('/api/channels/alexa/pair-page')) {
276
+ return (
277
+ <a
278
+ href={href}
279
+ target="_blank"
280
+ rel="noopener noreferrer"
281
+ className="flex items-center gap-3 my-2 px-3.5 py-2.5 rounded-xl bg-[#00CAFF]/10 border border-[#00CAFF]/20 hover:bg-[#00CAFF]/15 transition-colors no-underline"
282
+ >
283
+ <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[#00CAFF] shrink-0">
284
+ <svg viewBox="0 0 24 24" className="w-4 h-4 fill-white">
285
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/>
286
+ </svg>
287
+ </div>
288
+ <div className="flex flex-col min-w-0">
289
+ <span className="text-[#00CAFF] font-medium text-sm">Click here to connect Alexa</span>
290
+ <span className="text-muted-foreground text-xs">Generates a one-time linking code</span>
291
+ </div>
292
+ <ExternalLink className="w-3.5 h-3.5 text-muted-foreground/50 ml-auto shrink-0" />
293
+ </a>
294
+ );
295
+ }
266
296
  return (
267
297
  <a
268
298
  href={href}