niahere 0.4.6 → 0.5.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 +2 -2
- package/src/agent/backends/claude-normalize.ts +5 -6
- package/src/agent/backends/claude.ts +6 -4
- package/src/agent/backends/codex-normalize.ts +19 -3
- package/src/agent/backends/codex.ts +100 -35
- package/src/agent/chain.ts +60 -0
- package/src/agent/failure.ts +90 -0
- package/src/agent/health.ts +41 -0
- package/src/agent/index.ts +3 -1
- package/src/agent/mcp-endpoint.ts +5 -3
- package/src/agent/models.ts +59 -0
- package/src/agent/registry.ts +54 -29
- package/src/agent/types.ts +9 -4
- package/src/channels/common/chat-session.ts +60 -0
- package/src/channels/phone/consult.ts +2 -1
- package/src/channels/phone/index.ts +5 -3
- package/src/channels/phone/relay.ts +15 -5
- package/src/channels/slack.ts +24 -34
- package/src/channels/sms.ts +16 -36
- package/src/channels/telegram.ts +22 -34
- package/src/channels/twilio/media-cache.ts +4 -3
- package/src/channels/twilio/rest.ts +0 -31
- package/src/channels/twilio/server.ts +0 -5
- package/src/channels/twilio/shared.ts +36 -0
- package/src/channels/whatsapp.ts +24 -58
- package/src/chat/engine.ts +83 -34
- package/src/chat/gap-marker.ts +63 -0
- package/src/chat/repl.ts +5 -5
- package/src/cli/config.ts +71 -0
- package/src/cli/index.ts +23 -288
- package/src/cli/job.ts +1 -2
- package/src/cli/logs.ts +55 -0
- package/src/cli/run.ts +74 -0
- package/src/cli/skills.ts +13 -0
- package/src/cli/status.ts +0 -1
- package/src/cli/test.ts +32 -0
- package/src/cli/update.ts +79 -0
- package/src/commands/backup.ts +1 -2
- package/src/commands/init.ts +1 -2
- package/src/commands/validate.ts +17 -13
- package/src/core/alive.ts +6 -5
- package/src/core/consolidator.ts +78 -31
- package/src/core/daemon.ts +3 -2
- package/src/core/finalizer.ts +9 -5
- package/src/core/runner.ts +64 -47
- package/src/core/scheduler.ts +19 -4
- package/src/core/skills.ts +0 -4
- package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
- package/src/db/models/active_engine.ts +0 -7
- package/src/db/models/job.ts +3 -2
- package/src/db/models/message.ts +10 -0
- package/src/db/models/session.ts +12 -0
- package/src/mcp/gate.ts +46 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools/misc.ts +2 -1
- package/src/mcp/tools/send.ts +5 -4
- package/src/types/config.ts +4 -4
- package/src/utils/config.ts +6 -8
- package/src/utils/errors.ts +26 -0
- package/src/utils/retry.ts +0 -45
package/src/channels/telegram.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { getConfig, updateRawConfig } from "../utils/config";
|
|
|
7
7
|
import { runMigrations } from "../db/migrate";
|
|
8
8
|
import { Message } from "../db/models";
|
|
9
9
|
import { log } from "../utils/log";
|
|
10
|
+
import { errMsg, ignore } from "../utils/errors";
|
|
10
11
|
import { getMcpServers } from "../mcp";
|
|
11
12
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
12
13
|
import { getNiaHome } from "../utils/paths";
|
|
13
|
-
import {
|
|
14
|
+
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
14
15
|
|
|
15
16
|
function safeExtension(filename?: string): string {
|
|
16
17
|
const ext = filename?.split(".").pop();
|
|
@@ -27,7 +28,10 @@ class TelegramChannel implements Channel {
|
|
|
27
28
|
private bot: Bot | null = null;
|
|
28
29
|
private outboundChatId: number | null = null;
|
|
29
30
|
private isOpen = false;
|
|
30
|
-
private readonly chats = new
|
|
31
|
+
private readonly chats = new ChatSessions<number>(
|
|
32
|
+
(chatId) => this.keyOf(chatId),
|
|
33
|
+
() => ({ channel: "telegram", mcpServers: getMcpServers() }),
|
|
34
|
+
);
|
|
31
35
|
|
|
32
36
|
async start(): Promise<void> {
|
|
33
37
|
const config = getConfig();
|
|
@@ -51,6 +55,7 @@ class TelegramChannel implements Channel {
|
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
async stop(): Promise<void> {
|
|
58
|
+
this.chats.closeAll();
|
|
54
59
|
if (this.bot) {
|
|
55
60
|
this.bot.stop();
|
|
56
61
|
this.bot = null;
|
|
@@ -82,14 +87,14 @@ class TelegramChannel implements Channel {
|
|
|
82
87
|
private async handleStart(ctx: Context): Promise<void> {
|
|
83
88
|
if (!ctx.chatId || !this.gate(ctx)) return;
|
|
84
89
|
this.registerOutbound(ctx.chatId);
|
|
85
|
-
const state = await this.
|
|
90
|
+
const state = await this.chats.get(ctx.chatId);
|
|
86
91
|
this.withLock(ctx.chatId, () => this.processMessage(ctx, state, "hi"));
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
private async handleRestart(ctx: Context): Promise<void> {
|
|
90
95
|
if (!ctx.chatId || !this.gate(ctx)) return;
|
|
91
96
|
this.registerOutbound(ctx.chatId);
|
|
92
|
-
const state = await this.
|
|
97
|
+
const state = await this.chats.rotate(ctx.chatId);
|
|
93
98
|
log.info({ chatId: ctx.chatId, room: `tg-${ctx.chatId}-${state.roomIndex}` }, "new telegram conversation");
|
|
94
99
|
await ctx.reply("New conversation started.");
|
|
95
100
|
}
|
|
@@ -97,7 +102,7 @@ class TelegramChannel implements Channel {
|
|
|
97
102
|
private async handleText(ctx: Context): Promise<void> {
|
|
98
103
|
if (!ctx.chatId || !ctx.message?.text || !this.gate(ctx)) return;
|
|
99
104
|
this.registerOutbound(ctx.chatId);
|
|
100
|
-
const state = await this.
|
|
105
|
+
const state = await this.chats.get(ctx.chatId);
|
|
101
106
|
const text = ctx.message.text;
|
|
102
107
|
this.withLock(ctx.chatId, () => this.processMessage(ctx, state, text));
|
|
103
108
|
}
|
|
@@ -105,7 +110,7 @@ class TelegramChannel implements Channel {
|
|
|
105
110
|
private async handlePhoto(ctx: Context): Promise<void> {
|
|
106
111
|
if (!ctx.chatId || !ctx.message?.photo || !this.gate(ctx)) return;
|
|
107
112
|
this.registerOutbound(ctx.chatId);
|
|
108
|
-
const state = await this.
|
|
113
|
+
const state = await this.chats.get(ctx.chatId);
|
|
109
114
|
const chatId = ctx.chatId;
|
|
110
115
|
this.withLock(chatId, async () => {
|
|
111
116
|
try {
|
|
@@ -118,7 +123,7 @@ class TelegramChannel implements Channel {
|
|
|
118
123
|
await this.processMessage(ctx, state, caption, [attachment]);
|
|
119
124
|
} catch (err) {
|
|
120
125
|
log.error({ err, chatId }, "failed to process photo");
|
|
121
|
-
await ctx.reply("Failed to process image.")
|
|
126
|
+
await ignore(ctx.reply("Failed to process image."), "reply image failure");
|
|
122
127
|
}
|
|
123
128
|
});
|
|
124
129
|
}
|
|
@@ -126,7 +131,7 @@ class TelegramChannel implements Channel {
|
|
|
126
131
|
private async handleDocument(ctx: Context): Promise<void> {
|
|
127
132
|
if (!ctx.chatId || !ctx.message?.document || !this.gate(ctx)) return;
|
|
128
133
|
this.registerOutbound(ctx.chatId);
|
|
129
|
-
const state = await this.
|
|
134
|
+
const state = await this.chats.get(ctx.chatId);
|
|
130
135
|
const chatId = ctx.chatId;
|
|
131
136
|
this.withLock(chatId, async () => {
|
|
132
137
|
try {
|
|
@@ -157,7 +162,7 @@ class TelegramChannel implements Channel {
|
|
|
157
162
|
await this.processMessage(ctx, state, caption, [attachment]);
|
|
158
163
|
} catch (err) {
|
|
159
164
|
log.error({ err, chatId }, "failed to process document");
|
|
160
|
-
await ctx.reply("Failed to process document.")
|
|
165
|
+
await ignore(ctx.reply("Failed to process document."), "reply document failure");
|
|
161
166
|
}
|
|
162
167
|
});
|
|
163
168
|
}
|
|
@@ -176,25 +181,25 @@ class TelegramChannel implements Channel {
|
|
|
176
181
|
log.info({ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 }, "telegram message received");
|
|
177
182
|
|
|
178
183
|
const typingInterval = setInterval(() => {
|
|
179
|
-
bot.api.sendChatAction(chatId, "typing")
|
|
184
|
+
void ignore(bot.api.sendChatAction(chatId, "typing"), "send typing indicator");
|
|
180
185
|
}, 4000);
|
|
181
|
-
bot.api.sendChatAction(chatId, "typing")
|
|
186
|
+
void ignore(bot.api.sendChatAction(chatId, "typing"), "send typing indicator");
|
|
182
187
|
|
|
183
188
|
try {
|
|
184
189
|
const { result, messageId } = await state.engine.send(text, {}, attachments);
|
|
185
190
|
const reply = result.trim() || "(no response)";
|
|
186
191
|
try {
|
|
187
192
|
await bot.api.sendMessage(chatId, reply);
|
|
188
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "sent")
|
|
193
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
|
189
194
|
log.info({ chatId, chars: result.length }, "telegram reply sent");
|
|
190
195
|
} catch (sendErr) {
|
|
191
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "failed")
|
|
196
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
|
|
192
197
|
throw sendErr;
|
|
193
198
|
}
|
|
194
199
|
} catch (err) {
|
|
195
|
-
const errText =
|
|
200
|
+
const errText = errMsg(err);
|
|
196
201
|
log.error({ err, chatId }, "telegram message processing failed");
|
|
197
|
-
await bot.api.sendMessage(chatId, `[error] ${errText}`)
|
|
202
|
+
await ignore(bot.api.sendMessage(chatId, `[error] ${errText}`), "reply engine error");
|
|
198
203
|
} finally {
|
|
199
204
|
clearInterval(typingInterval);
|
|
200
205
|
}
|
|
@@ -202,25 +207,8 @@ class TelegramChannel implements Channel {
|
|
|
202
207
|
|
|
203
208
|
// --- Session / state helpers ---
|
|
204
209
|
|
|
205
|
-
private async getState(chatId: number): Promise<ChatState> {
|
|
206
|
-
let state = this.chats.get(chatId);
|
|
207
|
-
if (state) return state;
|
|
208
|
-
state = await openChatEngine(this.keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
|
|
209
|
-
this.chats.set(chatId, state);
|
|
210
|
-
return state;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
private async restartChat(chatId: number): Promise<ChatState> {
|
|
214
|
-
const state = await rotateRoom(this.keyOf(chatId), this.chats.get(chatId), () => ({
|
|
215
|
-
channel: "telegram",
|
|
216
|
-
mcpServers: getMcpServers(),
|
|
217
|
-
}));
|
|
218
|
-
this.chats.set(chatId, state);
|
|
219
|
-
return state;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
210
|
private withLock(chatId: number, fn: () => Promise<void>): void {
|
|
223
|
-
const state = this.chats.
|
|
211
|
+
const state = this.chats.peek(chatId);
|
|
224
212
|
if (!state) {
|
|
225
213
|
fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
|
|
226
214
|
return;
|
|
@@ -238,7 +226,7 @@ class TelegramChannel implements Channel {
|
|
|
238
226
|
private gate(ctx: Context): boolean {
|
|
239
227
|
if (!ctx.chatId) return false;
|
|
240
228
|
if (this.isAllowed(ctx.chatId)) return true;
|
|
241
|
-
ctx.reply("Unauthorized.")
|
|
229
|
+
void ignore(ctx.reply("Unauthorized."), "reply unauthorized");
|
|
242
230
|
return false;
|
|
243
231
|
}
|
|
244
232
|
|
|
@@ -16,6 +16,7 @@ import { createHash } from "crypto";
|
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
import { getNiaHome } from "../../utils/paths";
|
|
18
18
|
import { log } from "../../utils/log";
|
|
19
|
+
import { ignore } from "../../utils/errors";
|
|
19
20
|
|
|
20
21
|
const MAX_FILES = 100;
|
|
21
22
|
const MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -85,7 +86,7 @@ async function evict(): Promise<void> {
|
|
|
85
86
|
let alive: typeof stats = [];
|
|
86
87
|
for (const s of stats) {
|
|
87
88
|
if (now - s.mtime > MAX_AGE_MS) {
|
|
88
|
-
await unlink(s.path)
|
|
89
|
+
await ignore(unlink(s.path), "prune cached media");
|
|
89
90
|
} else {
|
|
90
91
|
alive.push(s);
|
|
91
92
|
}
|
|
@@ -95,13 +96,13 @@ async function evict(): Promise<void> {
|
|
|
95
96
|
|
|
96
97
|
while (alive.length > MAX_FILES) {
|
|
97
98
|
const victim = alive.shift()!;
|
|
98
|
-
await unlink(victim.path)
|
|
99
|
+
await ignore(unlink(victim.path), "prune cached media");
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
let totalBytes = alive.reduce((sum, f) => sum + f.size, 0);
|
|
102
103
|
while (totalBytes > MAX_BYTES && alive.length > 0) {
|
|
103
104
|
const victim = alive.shift()!;
|
|
104
105
|
totalBytes -= victim.size;
|
|
105
|
-
await unlink(victim.path)
|
|
106
|
+
await ignore(unlink(victim.path), "prune cached media");
|
|
106
107
|
}
|
|
107
108
|
}
|
|
@@ -73,16 +73,6 @@ export async function placeCall(opts: PlaceCallOpts): Promise<PlaceCallResult> {
|
|
|
73
73
|
return { callSid: data.sid, status: data.status };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export async function updateCallUrl(opts: TwilioCreds & { callSid: string; url: string }): Promise<void> {
|
|
77
|
-
const body = new URLSearchParams({ Url: opts.url, Method: "POST" });
|
|
78
|
-
await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export async function hangupCall(opts: TwilioCreds & { callSid: string }): Promise<void> {
|
|
82
|
-
const body = new URLSearchParams({ Status: "completed" });
|
|
83
|
-
await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
76
|
// --- Messages (SMS / WhatsApp) ---
|
|
87
77
|
|
|
88
78
|
export interface SendMessageOpts extends TwilioCreds {
|
|
@@ -111,24 +101,3 @@ export async function sendMessage(opts: SendMessageOpts): Promise<SendMessageRes
|
|
|
111
101
|
}
|
|
112
102
|
|
|
113
103
|
// --- Phone number config (update inbound webhook on a number) ---
|
|
114
|
-
|
|
115
|
-
export async function updateIncomingPhoneNumber(
|
|
116
|
-
opts: TwilioCreds & {
|
|
117
|
-
phoneNumberSid: string;
|
|
118
|
-
voiceUrl?: string;
|
|
119
|
-
voiceMethod?: "GET" | "POST";
|
|
120
|
-
smsUrl?: string;
|
|
121
|
-
smsMethod?: "GET" | "POST";
|
|
122
|
-
statusCallback?: string;
|
|
123
|
-
statusCallbackMethod?: "GET" | "POST";
|
|
124
|
-
},
|
|
125
|
-
): Promise<void> {
|
|
126
|
-
const body = new URLSearchParams();
|
|
127
|
-
if (opts.voiceUrl !== undefined) body.set("VoiceUrl", opts.voiceUrl);
|
|
128
|
-
if (opts.voiceMethod) body.set("VoiceMethod", opts.voiceMethod);
|
|
129
|
-
if (opts.smsUrl !== undefined) body.set("SmsUrl", opts.smsUrl);
|
|
130
|
-
if (opts.smsMethod) body.set("SmsMethod", opts.smsMethod);
|
|
131
|
-
if (opts.statusCallback !== undefined) body.set("StatusCallback", opts.statusCallback);
|
|
132
|
-
if (opts.statusCallbackMethod) body.set("StatusCallbackMethod", opts.statusCallbackMethod);
|
|
133
|
-
await twilioPost(opts, `/IncomingPhoneNumbers/${encodeURIComponent(opts.phoneNumberSid)}.json`, body);
|
|
134
|
-
}
|
|
@@ -248,8 +248,3 @@ export function getTwilioServer(): TwilioServer {
|
|
|
248
248
|
if (!_instance) _instance = new TwilioServerImpl();
|
|
249
249
|
return _instance;
|
|
250
250
|
}
|
|
251
|
-
|
|
252
|
-
/** Test-only: drop the singleton so tests get a fresh server. */
|
|
253
|
-
export function resetTwilioServer(): void {
|
|
254
|
-
_instance = null;
|
|
255
|
-
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TwilioConfig } from "../../types";
|
|
2
|
+
import { log } from "../../utils/log";
|
|
3
|
+
|
|
4
|
+
/** Bits every Twilio-backed channel (sms, whatsapp) needs identically. */
|
|
5
|
+
|
|
6
|
+
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
7
|
+
|
|
8
|
+
/** Ack an inbound webhook immediately. Replies go out over REST instead, so a
|
|
9
|
+
* slow engine cannot blow Twilio's ~15s webhook budget. */
|
|
10
|
+
export function ackTwiml(): Response {
|
|
11
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isAllowedSender(twilio: TwilioConfig, remoteE164: string): boolean {
|
|
15
|
+
if (twilio.owner_number && remoteE164 === twilio.owner_number) return true;
|
|
16
|
+
return twilio.allowlist.includes(remoteE164);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type StatusLogger = (label: string, fields: Record<string, unknown>) => void;
|
|
20
|
+
|
|
21
|
+
const logStatus: StatusLogger = (label, fields) => log.info(fields, `${label}: delivery status`);
|
|
22
|
+
|
|
23
|
+
/** Record a delivery-status callback and ack it. */
|
|
24
|
+
export function deliveryStatusAck(
|
|
25
|
+
label: string,
|
|
26
|
+
params: Record<string, string>,
|
|
27
|
+
record: StatusLogger = logStatus,
|
|
28
|
+
): Response {
|
|
29
|
+
record(label, {
|
|
30
|
+
messageSid: params.MessageSid,
|
|
31
|
+
status: params.MessageStatus,
|
|
32
|
+
errorCode: params.ErrorCode,
|
|
33
|
+
to: params.To,
|
|
34
|
+
});
|
|
35
|
+
return new Response("", { status: 204 });
|
|
36
|
+
}
|
package/src/channels/whatsapp.ts
CHANGED
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
import { getMcpServers } from "../mcp";
|
|
21
21
|
import { Message } from "../db/models";
|
|
22
22
|
import { runMigrations } from "../db/migrate";
|
|
23
|
-
import {
|
|
23
|
+
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
24
|
+
import { ackTwiml, deliveryStatusAck, isAllowedSender } from "./twilio/shared";
|
|
24
25
|
import type { Attachment, Channel, ChatState, Outbound, TwilioConfig, WhatsappConfig, PhoneConfig } from "../types";
|
|
25
26
|
import { getConfig } from "../utils/config";
|
|
26
27
|
import { log } from "../utils/log";
|
|
28
|
+
import { errMsg, ignore } from "../utils/errors";
|
|
27
29
|
import { classifyMime, prepareImage, validateAttachment } from "../utils/attachment";
|
|
28
30
|
import { sendMessage as twilioSendMessage } from "./twilio/rest";
|
|
29
31
|
import { getTwilioServer } from "./twilio/server";
|
|
@@ -32,7 +34,6 @@ import { transcribeAudio } from "./twilio/transcribe";
|
|
|
32
34
|
|
|
33
35
|
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
|
|
34
36
|
const WA_PREFIX = "whatsapp:";
|
|
35
|
-
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
36
37
|
const CHUNK_LIMIT = 4096;
|
|
37
38
|
const RESET_RE = /^\s*\/(reset|new)\s*$/i;
|
|
38
39
|
const VOICE_MIME_PREFIX = "audio/";
|
|
@@ -42,13 +43,14 @@ class WhatsAppChannel implements Channel {
|
|
|
42
43
|
private readonly twilio: TwilioConfig;
|
|
43
44
|
private readonly whatsapp: WhatsappConfig;
|
|
44
45
|
private readonly phone: PhoneConfig;
|
|
45
|
-
private readonly chats
|
|
46
|
+
private readonly chats: ChatSessions<string>;
|
|
46
47
|
private readonly lastInboundAt = new Map<string, number>();
|
|
47
48
|
|
|
48
49
|
constructor(twilio: TwilioConfig, whatsapp: WhatsappConfig, phone: PhoneConfig) {
|
|
49
50
|
this.twilio = twilio;
|
|
50
51
|
this.whatsapp = whatsapp;
|
|
51
52
|
this.phone = phone;
|
|
53
|
+
this.chats = new ChatSessions(roomPrefix, () => ({ channel: "whatsapp", mcpServers: getMcpServers() }));
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
async start(): Promise<void> {
|
|
@@ -86,8 +88,7 @@ class WhatsAppChannel implements Channel {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
async stop(): Promise<void> {
|
|
89
|
-
|
|
90
|
-
this.chats.clear();
|
|
91
|
+
this.chats.closeAll();
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/** Outbound to the owner — used by send_message MCP tool. WhatsApp has no threading. */
|
|
@@ -108,9 +109,9 @@ class WhatsAppChannel implements Channel {
|
|
|
108
109
|
const from = (params.From || "").replace(/^whatsapp:/, "");
|
|
109
110
|
const body = (params.Body || "").trim();
|
|
110
111
|
|
|
111
|
-
if (!this.
|
|
112
|
+
if (!isAllowedSender(this.twilio, from)) {
|
|
112
113
|
log.warn({ from }, "whatsapp: rejecting non-allowlisted sender");
|
|
113
|
-
return
|
|
114
|
+
return ackTwiml();
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
this.lastInboundAt.set(from, Date.now());
|
|
@@ -118,20 +119,20 @@ class WhatsAppChannel implements Channel {
|
|
|
118
119
|
if (RESET_RE.test(body)) {
|
|
119
120
|
// Serialize through the same lock so a /reset chasing an in-flight
|
|
120
121
|
// engine.send() waits its turn instead of yanking the engine away.
|
|
121
|
-
const state = await this.
|
|
122
|
+
const state = await this.chats.get(from);
|
|
122
123
|
chainLock(state, async () => {
|
|
123
|
-
const newState = await this.
|
|
124
|
+
const newState = await this.chats.rotate(from);
|
|
124
125
|
await this.sendTextTo(
|
|
125
126
|
from,
|
|
126
|
-
`New conversation started (room ${
|
|
127
|
+
`New conversation started (room ${roomPrefix(from)}-${newState.roomIndex}).`,
|
|
127
128
|
).catch((err) => log.warn({ err, from }, "whatsapp: failed to send reset confirmation"));
|
|
128
129
|
});
|
|
129
|
-
return
|
|
130
|
+
return ackTwiml();
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
const descriptors = extractMedia(params);
|
|
133
134
|
|
|
134
|
-
const state = await this.
|
|
135
|
+
const state = await this.chats.get(from);
|
|
135
136
|
chainLock(state, async () => {
|
|
136
137
|
let userText = body;
|
|
137
138
|
let attachments: Attachment[] | undefined;
|
|
@@ -162,7 +163,7 @@ class WhatsAppChannel implements Channel {
|
|
|
162
163
|
} catch (err) {
|
|
163
164
|
log.error({ err, from }, "whatsapp: voice transcription failed");
|
|
164
165
|
voiceParts.push(
|
|
165
|
-
`[voice note: transcription failed — ${
|
|
166
|
+
`[voice note: transcription failed — ${errMsg(err)}]`,
|
|
166
167
|
);
|
|
167
168
|
}
|
|
168
169
|
continue;
|
|
@@ -171,7 +172,7 @@ class WhatsAppChannel implements Channel {
|
|
|
171
172
|
const error = validateAttachment(item.data);
|
|
172
173
|
if (error) {
|
|
173
174
|
log.warn({ from, mime: item.mime, error }, "whatsapp: rejecting attachment");
|
|
174
|
-
await this.sendTextTo(from, `[error] ${error}`)
|
|
175
|
+
await ignore(this.sendTextTo(from, `[error] ${error}`), "reply engine error");
|
|
175
176
|
continue;
|
|
176
177
|
}
|
|
177
178
|
|
|
@@ -203,32 +204,23 @@ class WhatsAppChannel implements Channel {
|
|
|
203
204
|
const reply = result.trim() || "(no response)";
|
|
204
205
|
try {
|
|
205
206
|
await this.sendTextTo(from, reply);
|
|
206
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "sent")
|
|
207
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
|
207
208
|
} catch (sendErr) {
|
|
208
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "failed")
|
|
209
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
|
|
209
210
|
throw sendErr;
|
|
210
211
|
}
|
|
211
212
|
} catch (err) {
|
|
212
213
|
log.error({ err, from }, "whatsapp: engine error");
|
|
213
|
-
const errText =
|
|
214
|
-
await this.sendTextTo(from, `[error] ${errText}`)
|
|
214
|
+
const errText = errMsg(err);
|
|
215
|
+
await ignore(this.sendTextTo(from, `[error] ${errText}`), "reply engine error");
|
|
215
216
|
}
|
|
216
217
|
});
|
|
217
218
|
|
|
218
|
-
return
|
|
219
|
+
return ackTwiml();
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
private handleStatus(params: Record<string, string>): Response {
|
|
222
|
-
|
|
223
|
-
{
|
|
224
|
-
messageSid: params.MessageSid,
|
|
225
|
-
status: params.MessageStatus,
|
|
226
|
-
errorCode: params.ErrorCode,
|
|
227
|
-
to: params.To,
|
|
228
|
-
},
|
|
229
|
-
"whatsapp: delivery status",
|
|
230
|
-
);
|
|
231
|
-
return new Response("", { status: 204 });
|
|
223
|
+
return deliveryStatusAck("whatsapp", params);
|
|
232
224
|
}
|
|
233
225
|
|
|
234
226
|
// --- Outbound ---
|
|
@@ -301,36 +293,10 @@ class WhatsAppChannel implements Channel {
|
|
|
301
293
|
return true;
|
|
302
294
|
}
|
|
303
295
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
private isAllowed(remoteE164: string): boolean {
|
|
307
|
-
if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
|
|
308
|
-
return this.twilio.allowlist.includes(remoteE164);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
private roomPrefix(remoteE164: string): string {
|
|
312
|
-
return `wa-${remoteE164}`;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
private async getState(remoteE164: string): Promise<ChatState> {
|
|
316
|
-
let state = this.chats.get(remoteE164);
|
|
317
|
-
if (state) return state;
|
|
318
|
-
state = await openChatEngine(this.roomPrefix(remoteE164), () => ({
|
|
319
|
-
channel: "whatsapp",
|
|
320
|
-
mcpServers: getMcpServers(),
|
|
321
|
-
}));
|
|
322
|
-
this.chats.set(remoteE164, state);
|
|
323
|
-
return state;
|
|
324
|
-
}
|
|
296
|
+
}
|
|
325
297
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
channel: "whatsapp",
|
|
329
|
-
mcpServers: getMcpServers(),
|
|
330
|
-
}));
|
|
331
|
-
this.chats.set(remoteE164, state);
|
|
332
|
-
return state;
|
|
333
|
-
}
|
|
298
|
+
function roomPrefix(remoteE164: string): string {
|
|
299
|
+
return `wa-${remoteE164}`;
|
|
334
300
|
}
|
|
335
301
|
|
|
336
302
|
/**
|