bloby-bot 0.47.14 → 0.48.1
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 +1 -1
- package/shared/config.ts +17 -0
- package/supervisor/channels/alexa.ts +117 -0
- package/supervisor/channels/manager.ts +164 -25
- package/supervisor/channels/types.ts +2 -2
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +41 -11
- package/supervisor/index.ts +183 -0
package/package.json
CHANGED
package/shared/config.ts
CHANGED
|
@@ -13,6 +13,22 @@ 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
|
+
/** Optional Home Assistant fallback for replies that exceed Alexa's ~25s budget. */
|
|
22
|
+
overflow?: {
|
|
23
|
+
mode: 'ha-announce' | 'chat-only';
|
|
24
|
+
ha?: {
|
|
25
|
+
url: string;
|
|
26
|
+
token: string;
|
|
27
|
+
device: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
export interface BotConfig {
|
|
17
33
|
port: number;
|
|
18
34
|
username: string;
|
|
@@ -39,6 +55,7 @@ export interface BotConfig {
|
|
|
39
55
|
};
|
|
40
56
|
channels?: {
|
|
41
57
|
whatsapp?: ChannelConfig;
|
|
58
|
+
alexa?: AlexaChannelConfig;
|
|
42
59
|
};
|
|
43
60
|
tunnelUrl?: string;
|
|
44
61
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
export class AlexaChannel implements ChannelProvider {
|
|
22
|
+
readonly type: ChannelType = 'alexa';
|
|
23
|
+
|
|
24
|
+
/** Per-conversation FIFO of pending HTTP-response resolvers. Each inbound
|
|
25
|
+
* Alexa utterance enqueues one; each bot:response (or turn-complete safety
|
|
26
|
+
* net) dequeues one. */
|
|
27
|
+
private pending = new Map<string, Array<{ resolve: (text: string) => void; reject: (err: Error) => void; createdAt: number }>>();
|
|
28
|
+
|
|
29
|
+
/** Reserve a resolver slot. The caller pushes the user utterance into the
|
|
30
|
+
* live conversation IMMEDIATELY after this returns, so the FIFO order on
|
|
31
|
+
* this map matches the FIFO order the routing queue uses. */
|
|
32
|
+
reservePending(convId: string, timeoutMs = 25_000): Promise<string> {
|
|
33
|
+
return new Promise<string>((resolve, reject) => {
|
|
34
|
+
const slot = { resolve, reject, createdAt: Date.now() };
|
|
35
|
+
let q = this.pending.get(convId);
|
|
36
|
+
if (!q) {
|
|
37
|
+
q = [];
|
|
38
|
+
this.pending.set(convId, q);
|
|
39
|
+
}
|
|
40
|
+
q.push(slot);
|
|
41
|
+
|
|
42
|
+
// Safety timeout — if the agent never responds, the HTTP handler still
|
|
43
|
+
// unblocks and the relay can return an Alexa-friendly "I'll get back to
|
|
44
|
+
// you" message. The slot is also removed so a late bot:response doesn't
|
|
45
|
+
// try to resolve a dead promise.
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
const list = this.pending.get(convId);
|
|
48
|
+
if (!list) return;
|
|
49
|
+
const idx = list.indexOf(slot);
|
|
50
|
+
if (idx >= 0) {
|
|
51
|
+
list.splice(idx, 1);
|
|
52
|
+
if (list.length === 0) this.pending.delete(convId);
|
|
53
|
+
reject(new Error('alexa-timeout'));
|
|
54
|
+
}
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve the head resolver for a conversation. Called by the channel
|
|
60
|
+
* manager when bot:response (or turn-complete with no response) fires for
|
|
61
|
+
* an alexa-surface turn. */
|
|
62
|
+
resolveHead(convId: string, text: string): boolean {
|
|
63
|
+
const q = this.pending.get(convId);
|
|
64
|
+
if (!q || q.length === 0) return false;
|
|
65
|
+
const slot = q.shift()!;
|
|
66
|
+
if (q.length === 0) this.pending.delete(convId);
|
|
67
|
+
slot.resolve(text);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drop the head resolver (used when a turn ends without a bot:response). */
|
|
72
|
+
rejectHead(convId: string, reason: string): boolean {
|
|
73
|
+
const q = this.pending.get(convId);
|
|
74
|
+
if (!q || q.length === 0) return false;
|
|
75
|
+
const slot = q.shift()!;
|
|
76
|
+
if (q.length === 0) this.pending.delete(convId);
|
|
77
|
+
slot.reject(new Error(reason));
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── ChannelProvider implementation ──
|
|
82
|
+
|
|
83
|
+
async connect(): Promise<void> {
|
|
84
|
+
// No-op. Alexa is a passive HTTP receiver.
|
|
85
|
+
log.info('[alexa] Channel ready (passive HTTP receiver)');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async disconnect(): Promise<void> {
|
|
89
|
+
// Reject any in-flight resolvers so HTTP handlers don't hang forever.
|
|
90
|
+
for (const [, q] of this.pending) {
|
|
91
|
+
for (const slot of q) slot.reject(new Error('alexa-disconnected'));
|
|
92
|
+
}
|
|
93
|
+
this.pending.clear();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async sendMessage(_to: string, _text: string): Promise<void> {
|
|
97
|
+
// Alexa is request/response. Replies are delivered by resolving the
|
|
98
|
+
// pending HTTP response, not by calling this method. ChannelManager
|
|
99
|
+
// routes alexa-surface turns through `resolveHead`, not `sendMessage`.
|
|
100
|
+
log.warn('[alexa] sendMessage called on Alexa channel — ignored (use resolveHead)');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getStatus(): ChannelStatus {
|
|
104
|
+
const cfg = loadConfig().channels?.alexa;
|
|
105
|
+
return {
|
|
106
|
+
channel: 'alexa',
|
|
107
|
+
connected: !!cfg?.enabled,
|
|
108
|
+
info: {
|
|
109
|
+
linked: !!cfg?.sharedSecret,
|
|
110
|
+
overflow: cfg?.overflow?.mode || 'chat-only',
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getQrCode(): string | null { return null; }
|
|
116
|
+
hasCredentials(): boolean { return !!loadConfig().channels?.alexa?.sharedSecret; }
|
|
117
|
+
}
|
|
@@ -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 (
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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,30 @@ 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
|
-
|
|
450
|
-
|
|
457
|
+
// Alexa can only deliver a single final reply per turn — keep buffering and
|
|
458
|
+
// let bot:response send everything at once.
|
|
459
|
+
if (this.peekRoute(convId)?.surface !== 'alexa') {
|
|
460
|
+
this.sendStreamChunk(this.peekRoute(convId), state.chunkBuf.trim(), botName);
|
|
461
|
+
state.chunkBuf = '';
|
|
462
|
+
}
|
|
451
463
|
return;
|
|
452
464
|
}
|
|
453
465
|
|
|
454
466
|
if (type === 'bot:response' && eventData?.content && convId) {
|
|
455
467
|
const target = this.consumeRoute(convId);
|
|
456
468
|
const remaining = state.chunkBuf.trim();
|
|
457
|
-
if (remaining) this.sendStreamChunk(target, remaining, botName);
|
|
458
469
|
state.chunkBuf = '';
|
|
459
470
|
|
|
471
|
+
if (target?.surface === 'alexa') {
|
|
472
|
+
// Resolve the HTTP handler's promise with the full reply text.
|
|
473
|
+
const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
|
|
474
|
+
if (!alexa || !alexa.resolveHead(convId, eventData.content)) {
|
|
475
|
+
log.warn(`[channels] bot:response for alexa surface but no pending resolver (conv=${convId})`);
|
|
476
|
+
}
|
|
477
|
+
} else if (remaining) {
|
|
478
|
+
this.sendStreamChunk(target, remaining, botName);
|
|
479
|
+
}
|
|
480
|
+
|
|
460
481
|
// Append the assistant's reply into the per-chat context buffer so the next
|
|
461
482
|
// trigger in that chat sees it as conversation history.
|
|
462
483
|
if (target?.assistantBufferKey) {
|
|
@@ -473,9 +494,14 @@ export class ChannelManager {
|
|
|
473
494
|
// doesn't bleed into the next turn's reply. The SDK guarantees one response per
|
|
474
495
|
// pushed input; this safety net covers aborts, empty turns, and provider errors.
|
|
475
496
|
if ((type === 'bot:turn-complete' || type === 'bot:error') && convId) {
|
|
476
|
-
|
|
497
|
+
const head = this.peekRoute(convId);
|
|
498
|
+
if (head) {
|
|
477
499
|
const dropped = this.consumeRoute(convId);
|
|
478
500
|
log.warn(`[channels] ${type} without bot:response — dropping pending route (surface=${dropped?.surface}, to=${dropped?.waSendTo || 'none'})`);
|
|
501
|
+
if (dropped?.surface === 'alexa') {
|
|
502
|
+
const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
|
|
503
|
+
alexa?.rejectHead(convId, type);
|
|
504
|
+
}
|
|
479
505
|
}
|
|
480
506
|
state.chunkBuf = '';
|
|
481
507
|
}
|
|
@@ -872,6 +898,119 @@ export class ChannelManager {
|
|
|
872
898
|
this.pushWithRouting(convId, target, channelContent, agentAttachments, savedFiles);
|
|
873
899
|
}
|
|
874
900
|
|
|
901
|
+
/** Synchronously handle an Alexa utterance: push into the shared conversation,
|
|
902
|
+
* await the agent's reply, return it. Used by /api/channels/alexa/handle.
|
|
903
|
+
*
|
|
904
|
+
* Returns the final reply text. Throws on timeout / aborted turn so the HTTP
|
|
905
|
+
* handler can fall back to "I'll get back to you in chat" (or HA announce).
|
|
906
|
+
*/
|
|
907
|
+
async handleAlexaInbound(opts: {
|
|
908
|
+
text: string;
|
|
909
|
+
alexaUserId: string;
|
|
910
|
+
alexaSessionId?: string;
|
|
911
|
+
timeoutMs?: number;
|
|
912
|
+
}): Promise<string> {
|
|
913
|
+
const { text, alexaUserId, alexaSessionId, timeoutMs = 25_000 } = opts;
|
|
914
|
+
const { workerApi, broadcastBloby, getModel } = this.opts;
|
|
915
|
+
const model = getModel();
|
|
916
|
+
|
|
917
|
+
const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
|
|
918
|
+
if (!alexa) throw new Error('alexa-channel-not-initialized');
|
|
919
|
+
|
|
920
|
+
// Get or create the shared conversation (same one chat + WhatsApp use).
|
|
921
|
+
let convId: string | undefined;
|
|
922
|
+
try {
|
|
923
|
+
const ctx = await workerApi('/api/context/current');
|
|
924
|
+
if (ctx.conversationId) {
|
|
925
|
+
convId = ctx.conversationId;
|
|
926
|
+
} else {
|
|
927
|
+
const conv = await workerApi('/api/conversations', 'POST', { title: 'Alexa', model });
|
|
928
|
+
convId = conv.id;
|
|
929
|
+
await workerApi('/api/context/set', 'POST', { conversationId: convId });
|
|
930
|
+
}
|
|
931
|
+
} catch (err: any) {
|
|
932
|
+
log.warn(`[channels/alexa] Failed to get/create conversation: ${err.message}`);
|
|
933
|
+
throw err;
|
|
934
|
+
}
|
|
935
|
+
if (!convId) throw new Error('no-conversation');
|
|
936
|
+
|
|
937
|
+
// Persist + mirror to dashboard so the user sees the Alexa utterance in chat.
|
|
938
|
+
workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
939
|
+
role: 'user',
|
|
940
|
+
content: text,
|
|
941
|
+
meta: { model, channel: 'alexa' },
|
|
942
|
+
}).catch((err: any) => log.warn(`[channels/alexa] DB persist error: ${err.message}`));
|
|
943
|
+
broadcastBloby('chat:sync', {
|
|
944
|
+
conversationId: convId,
|
|
945
|
+
message: { role: 'user', content: text, timestamp: new Date().toISOString() },
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// Make sure a live conversation exists (mirrors handleAdminMessage's setup).
|
|
949
|
+
if (!hasConversation(convId)) {
|
|
950
|
+
log.info(`[channels/alexa] Starting live conversation: ${convId}`);
|
|
951
|
+
let botName = 'Bloby', humanName = 'Human';
|
|
952
|
+
let recentMessages: RecentMessage[] = [];
|
|
953
|
+
try {
|
|
954
|
+
const [status, recentRaw] = await Promise.all([
|
|
955
|
+
workerApi('/api/onboard/status'),
|
|
956
|
+
workerApi(`/api/conversations/${convId}/messages/recent?limit=20`),
|
|
957
|
+
]);
|
|
958
|
+
botName = status.agentName || 'Bloby';
|
|
959
|
+
humanName = status.userName || 'Human';
|
|
960
|
+
if (Array.isArray(recentRaw)) {
|
|
961
|
+
const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
|
|
962
|
+
if (filtered.length > 0) {
|
|
963
|
+
recentMessages = filtered.slice(0, -1).map((m: any) => ({
|
|
964
|
+
role: m.role as 'user' | 'assistant',
|
|
965
|
+
content: m.content,
|
|
966
|
+
}));
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
} catch {}
|
|
970
|
+
|
|
971
|
+
const waState = this.createWaStreamState();
|
|
972
|
+
await startConversation(convId, model, (type, eventData) => {
|
|
973
|
+
// Same routing as WhatsApp — alexa surface branch lives inside this method.
|
|
974
|
+
this.routeWaStreamEvent(waState, type, eventData, botName);
|
|
975
|
+
|
|
976
|
+
if (type === 'bot:response' && eventData.content) {
|
|
977
|
+
workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
978
|
+
role: 'assistant',
|
|
979
|
+
content: eventData.content,
|
|
980
|
+
meta: { model },
|
|
981
|
+
}).catch(() => {});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (type === 'bot:turn-complete') {
|
|
985
|
+
if (eventData.usedFileTools) this.opts.restartBackend();
|
|
986
|
+
broadcastBloby('bot:idle', { conversationId: convId });
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (type === 'bot:conversation-ended') {
|
|
991
|
+
this.clearRoutes(convId);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
broadcastBloby(type, eventData);
|
|
996
|
+
}, { botName, humanName }, recentMessages);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Reserve the resolver slot FIRST — the agent may respond very quickly and
|
|
1000
|
+
// we don't want the bot:response to arrive before the resolver is in the FIFO.
|
|
1001
|
+
const pending = alexa.reservePending(convId, timeoutMs);
|
|
1002
|
+
|
|
1003
|
+
const channelContext = `[Alexa | ${alexaUserId}${alexaSessionId ? ` | session=${alexaSessionId.slice(-6)}` : ''}]\n`;
|
|
1004
|
+
const target: RoutingTarget = {
|
|
1005
|
+
surface: 'alexa',
|
|
1006
|
+
isSelfChat: false,
|
|
1007
|
+
isGroup: false,
|
|
1008
|
+
};
|
|
1009
|
+
this.pushWithRouting(convId, target, channelContext + text);
|
|
1010
|
+
|
|
1011
|
+
return pending; // resolves with the reply text (or rejects on timeout)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
875
1014
|
/** Handle message from a customer — runs support agent in parallel with conversation context */
|
|
876
1015
|
private async handleCustomerMessage(msg: InboundMessage, channelConfig: ChannelConfig) {
|
|
877
1016
|
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 {
|
|
@@ -71,7 +71,7 @@ export interface ChannelStatus {
|
|
|
71
71
|
*/
|
|
72
72
|
export interface RoutingTarget {
|
|
73
73
|
/** Which surface triggered this turn. Drives whether the WA reply carries a "🤖 Bot:" prefix. */
|
|
74
|
-
surface: 'chat' | 'whatsapp';
|
|
74
|
+
surface: 'chat' | 'whatsapp' | 'alexa';
|
|
75
75
|
/** WhatsApp JID to deliver the reply to.
|
|
76
76
|
* - 'whatsapp' surface → the originating chat JID (group or peer).
|
|
77
77
|
* - '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
|
|
32
|
+
/** Convert channel-pair URLs (any format) into markdown links so the buttons render */
|
|
33
33
|
function preprocessContent(text: string): string {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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}
|
package/supervisor/index.ts
CHANGED
|
@@ -376,6 +376,7 @@ export async function startSupervisor() {
|
|
|
376
376
|
'POST /api/channels/whatsapp/pairing-code',
|
|
377
377
|
'POST /api/channels/whatsapp/react',
|
|
378
378
|
'POST /api/channels/send',
|
|
379
|
+
'POST /api/channels/alexa/handle',
|
|
379
380
|
];
|
|
380
381
|
|
|
381
382
|
function isExemptRoute(method: string, url: string): boolean {
|
|
@@ -813,6 +814,188 @@ ${!connected ? `<script>
|
|
|
813
814
|
return;
|
|
814
815
|
}
|
|
815
816
|
|
|
817
|
+
// POST /api/channels/alexa/handle — relay-forwarded Alexa utterance.
|
|
818
|
+
// Authed via x-bloby-alexa-secret (per-user shared secret minted at pair time).
|
|
819
|
+
// Returns plain text the relay then formats as Alexa SSML.
|
|
820
|
+
if (req.method === 'POST' && channelPath === '/api/channels/alexa/handle') {
|
|
821
|
+
let body = '';
|
|
822
|
+
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
823
|
+
req.on('end', async () => {
|
|
824
|
+
try {
|
|
825
|
+
const cfg = loadConfig();
|
|
826
|
+
const expected = cfg.channels?.alexa?.sharedSecret;
|
|
827
|
+
const provided = req.headers['x-bloby-alexa-secret'];
|
|
828
|
+
if (!expected || !provided || provided !== expected) {
|
|
829
|
+
res.writeHead(401);
|
|
830
|
+
res.end(JSON.stringify({ ok: false, error: 'alexa-auth-failed' }));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (!cfg.channels?.alexa?.enabled) {
|
|
834
|
+
res.writeHead(503);
|
|
835
|
+
res.end(JSON.stringify({ ok: false, error: 'alexa-disabled' }));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const { text, alexaUserId, sessionId, kind } = JSON.parse(body || '{}') as {
|
|
840
|
+
text?: string; alexaUserId?: string; sessionId?: string; kind?: string;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// Launch / Stop / Help intents don't need to hit the agent — the relay
|
|
844
|
+
// can short-circuit, but we accept them here too for symmetry.
|
|
845
|
+
if (kind === 'launch') {
|
|
846
|
+
res.writeHead(200);
|
|
847
|
+
res.end(JSON.stringify({ ok: true, reply: 'Bloby here, what can I help with?', endSession: false }));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (kind === 'stop' || kind === 'cancel') {
|
|
851
|
+
res.writeHead(200);
|
|
852
|
+
res.end(JSON.stringify({ ok: true, reply: 'Goodbye.', endSession: true }));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (!text || !alexaUserId) {
|
|
857
|
+
res.writeHead(400);
|
|
858
|
+
res.end(JSON.stringify({ ok: false, error: 'text and alexaUserId required' }));
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
const reply = await channelManager.handleAlexaInbound({ text, alexaUserId, alexaSessionId: sessionId });
|
|
864
|
+
res.writeHead(200);
|
|
865
|
+
res.end(JSON.stringify({ ok: true, reply, endSession: false }));
|
|
866
|
+
} catch (err: any) {
|
|
867
|
+
// Timeout or aborted turn — agent will still finish and the result lands
|
|
868
|
+
// in the shared conversation. Tell Alexa we'll get back to them.
|
|
869
|
+
const overflow = cfg.channels?.alexa?.overflow?.mode || 'chat-only';
|
|
870
|
+
const fallback = overflow === 'ha-announce'
|
|
871
|
+
? "I'll let you know in a moment."
|
|
872
|
+
: "I'll reply in your chat when ready.";
|
|
873
|
+
res.writeHead(200);
|
|
874
|
+
res.end(JSON.stringify({ ok: true, reply: fallback, endSession: false, overflow: true }));
|
|
875
|
+
}
|
|
876
|
+
} catch (err: any) {
|
|
877
|
+
res.writeHead(500);
|
|
878
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// POST /api/channels/alexa/pair — dashboard mints a pairing code via the relay.
|
|
885
|
+
// Body: optional { ttlSeconds }. Returns: { code, expiresAt, sharedSecret }.
|
|
886
|
+
// We persist sharedSecret locally so future /handle calls can authenticate.
|
|
887
|
+
if (req.method === 'POST' && channelPath === '/api/channels/alexa/pair') {
|
|
888
|
+
(async () => {
|
|
889
|
+
try {
|
|
890
|
+
const cfg = loadConfig();
|
|
891
|
+
if (!cfg.relay?.token) {
|
|
892
|
+
res.writeHead(400);
|
|
893
|
+
res.end(JSON.stringify({ ok: false, error: 'no-relay-token' }));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const relayBase = cfg.relay.url || 'https://api.bloby.bot';
|
|
897
|
+
const resp = await fetch(`${relayBase.replace(/\/$/, '')}/api/alexa/pair`, {
|
|
898
|
+
method: 'POST',
|
|
899
|
+
headers: {
|
|
900
|
+
'Content-Type': 'application/json',
|
|
901
|
+
Authorization: `Bearer ${cfg.relay.token}`,
|
|
902
|
+
},
|
|
903
|
+
body: '{}',
|
|
904
|
+
});
|
|
905
|
+
const data = await resp.json() as { code?: string; expiresAt?: string; sharedSecret?: string; error?: string };
|
|
906
|
+
if (!resp.ok || !data.code || !data.sharedSecret) {
|
|
907
|
+
res.writeHead(resp.status || 500);
|
|
908
|
+
res.end(JSON.stringify({ ok: false, error: data.error || 'pair-failed' }));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Persist the shared secret + mark channel enabled so handleInbound is gated.
|
|
913
|
+
if (!cfg.channels) cfg.channels = {};
|
|
914
|
+
cfg.channels.alexa = {
|
|
915
|
+
...(cfg.channels.alexa || {}),
|
|
916
|
+
enabled: true,
|
|
917
|
+
sharedSecret: data.sharedSecret,
|
|
918
|
+
};
|
|
919
|
+
saveConfig(cfg);
|
|
920
|
+
// Initialize the channel if it wasn't already running.
|
|
921
|
+
await channelManager.init().catch(() => {});
|
|
922
|
+
|
|
923
|
+
res.writeHead(200);
|
|
924
|
+
res.end(JSON.stringify({ ok: true, code: data.code, expiresAt: data.expiresAt }));
|
|
925
|
+
} catch (err: any) {
|
|
926
|
+
log.warn(`[alexa/pair] ${err.message}`);
|
|
927
|
+
res.writeHead(500);
|
|
928
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
929
|
+
}
|
|
930
|
+
})();
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// GET /api/channels/alexa/pair-page — inline HTML page showing the code with countdown.
|
|
935
|
+
// The chat surface auto-converts URLs ending in /pair-page into a button (see MessageBubble).
|
|
936
|
+
if (req.method === 'GET' && channelPath === '/api/channels/alexa/pair-page') {
|
|
937
|
+
res.setHeader('Content-Type', 'text/html');
|
|
938
|
+
res.writeHead(200);
|
|
939
|
+
res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect Alexa | Bloby</title>
|
|
940
|
+
<style>
|
|
941
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
942
|
+
body{font-family:'Inter',system-ui,sans-serif;background:#0a0a0b;color:#e4e4e7;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1.5rem}
|
|
943
|
+
.c{text-align:center;max-width:460px}
|
|
944
|
+
h1{font-size:1.5rem;font-weight:700;margin-bottom:.5rem}
|
|
945
|
+
p{color:#a1a1aa;line-height:1.6;margin-bottom:1rem;font-size:.95rem}
|
|
946
|
+
.code{font-family:'JetBrains Mono','SF Mono',monospace;font-size:3rem;font-weight:700;letter-spacing:.5rem;color:#fff;background:#18181b;border:1px solid #27272a;border-radius:1rem;padding:1.5rem;margin:1.5rem 0;display:inline-block;min-width:280px}
|
|
947
|
+
.countdown{font-size:.85rem;color:#71717a;margin-top:.5rem}
|
|
948
|
+
.btn{display:inline-flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#04D1FE,#AF27E3,#FB4072);border:none;border-radius:9999px;padding:.55rem 1.4rem;color:#fff;font-size:.875rem;font-weight:500;cursor:pointer;height:2.25rem;margin-top:.6rem}
|
|
949
|
+
.btn:hover{opacity:.9}
|
|
950
|
+
.hint{color:#52525b;font-size:.8rem;margin-top:1rem;font-style:italic}
|
|
951
|
+
.err{color:#ef4444;margin-top:1rem}
|
|
952
|
+
</style>
|
|
953
|
+
</head><body><div class="c">
|
|
954
|
+
<h1>Connect Alexa</h1>
|
|
955
|
+
<p>Open the Alexa app on your phone and enable the <strong>Bloby</strong> skill. Then say:</p>
|
|
956
|
+
<div class="code" id="code">— — — — — —</div>
|
|
957
|
+
<p style="font-size:1rem;color:#e4e4e7">"Alexa, ask Bloby to link with code <span id="codeSpoken">———</span>"</p>
|
|
958
|
+
<div class="countdown" id="cd"></div>
|
|
959
|
+
<button class="btn" onclick="mint()">New code</button>
|
|
960
|
+
<p class="hint">Once linked, you can say "Alexa, ask Bloby anything." Codes expire after 10 minutes.</p>
|
|
961
|
+
<p class="err" id="err"></p>
|
|
962
|
+
</div>
|
|
963
|
+
<script>
|
|
964
|
+
let expiresAt=0,timer=null;
|
|
965
|
+
async function mint(){
|
|
966
|
+
document.getElementById('err').textContent='';
|
|
967
|
+
document.getElementById('code').textContent='— — — — — —';
|
|
968
|
+
document.getElementById('codeSpoken').textContent='———';
|
|
969
|
+
try{
|
|
970
|
+
const r=await fetch('/api/channels/alexa/pair',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
|
|
971
|
+
const d=await r.json();
|
|
972
|
+
if(!d.ok){document.getElementById('err').textContent=d.error||'Failed to mint code';return}
|
|
973
|
+
document.getElementById('code').textContent=d.code.split('').join(' ');
|
|
974
|
+
document.getElementById('codeSpoken').textContent=d.code;
|
|
975
|
+
expiresAt=new Date(d.expiresAt).getTime();
|
|
976
|
+
if(timer)clearInterval(timer);
|
|
977
|
+
timer=setInterval(tick,1000);tick();
|
|
978
|
+
}catch(e){document.getElementById('err').textContent=e.message}
|
|
979
|
+
}
|
|
980
|
+
function tick(){
|
|
981
|
+
const s=Math.max(0,Math.round((expiresAt-Date.now())/1000));
|
|
982
|
+
document.getElementById('cd').textContent=s>0?('Expires in '+Math.floor(s/60)+':'+String(s%60).padStart(2,'0')):'Expired — generate a new code';
|
|
983
|
+
if(s<=0&&timer){clearInterval(timer);timer=null}
|
|
984
|
+
}
|
|
985
|
+
mint();
|
|
986
|
+
</script>
|
|
987
|
+
</body></html>`);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// GET /api/channels/alexa/status — current alexa channel status
|
|
992
|
+
if (req.method === 'GET' && channelPath === '/api/channels/alexa/status') {
|
|
993
|
+
const status = channelManager.getStatus('alexa');
|
|
994
|
+
res.writeHead(200);
|
|
995
|
+
res.end(JSON.stringify(status || { channel: 'alexa', connected: false }));
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
816
999
|
// Fallback for unknown channel routes
|
|
817
1000
|
res.writeHead(404);
|
|
818
1001
|
res.end(JSON.stringify({ error: 'Not found' }));
|