niahere 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/channels/common/chat-session.ts +56 -0
- package/src/channels/phone/index.ts +13 -4
- package/src/channels/phone/tools.ts +2 -2
- package/src/channels/slack/attachments.ts +142 -0
- package/src/channels/slack/watch.ts +73 -0
- package/src/channels/slack.ts +63 -267
- package/src/channels/sms.ts +25 -35
- package/src/channels/telegram.ts +224 -223
- package/src/channels/whatsapp.ts +90 -122
- package/src/chat/identity.ts +1 -2
- package/src/cli/phone.ts +9 -6
- package/src/commands/init.ts +17 -14
- package/src/commands/service.ts +26 -7
- package/src/mcp/tools/index.ts +9 -0
- package/src/mcp/tools/jobs.ts +145 -0
- package/src/mcp/tools/messages.ts +25 -0
- package/src/mcp/tools/misc.ts +63 -0
- package/src/mcp/tools/send.ts +202 -0
- package/src/mcp/tools/watch.ts +50 -0
- package/src/types/channel.ts +35 -6
- package/src/types/index.ts +1 -1
- package/src/utils/attachment.ts +8 -2
- package/src/utils/config.ts +12 -27
- package/src/mcp/tools.ts +0 -497
package/src/channels/slack.ts
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { App } from "@slack/bolt";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { createHash } from "crypto";
|
|
5
|
-
import { createChatEngine } from "../chat/engine";
|
|
6
|
-
import type { Channel, ChatState, Attachment, AttachmentType } from "../types";
|
|
7
|
-
import { getConfig, updateRawConfig, resetConfig } from "../utils/config";
|
|
2
|
+
import type { Channel, ChatState, Attachment, Outbound, Recipient } from "../types";
|
|
3
|
+
import { getConfig, updateRawConfig } from "../utils/config";
|
|
8
4
|
import { relativeTime } from "../utils/format";
|
|
9
5
|
import { runMigrations } from "../db/migrate";
|
|
10
6
|
import { Session, Message } from "../db/models";
|
|
11
7
|
import { log } from "../utils/log";
|
|
12
8
|
import { getMcpServers } from "../mcp";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
9
|
+
import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
|
|
10
|
+
import { SlackAttachmentCache } from "./slack/attachments";
|
|
11
|
+
import { SlackWatchReloader } from "./slack/watch";
|
|
16
12
|
|
|
17
13
|
/** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
|
|
18
14
|
function cleanSentinel(text: string): string {
|
|
@@ -20,47 +16,39 @@ function cleanSentinel(text: string): string {
|
|
|
20
16
|
}
|
|
21
17
|
|
|
22
18
|
class SlackChannel implements Channel {
|
|
23
|
-
name = "slack";
|
|
19
|
+
name = "slack" as const;
|
|
24
20
|
private app: App | null = null;
|
|
25
21
|
private dmUserId: string | null = null;
|
|
26
22
|
/** Timestamps of messages Nia posted proactively (used to detect replies to our own messages) */
|
|
27
23
|
private outboundTs = new Set<string>();
|
|
28
24
|
|
|
29
|
-
async
|
|
25
|
+
async deliver(out: Outbound): Promise<void> {
|
|
30
26
|
if (!this.app) throw new Error("Slack not started");
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
27
|
+
const dest = this.resolveDest(out.to);
|
|
28
|
+
|
|
29
|
+
if (out.media) {
|
|
30
|
+
const buffer = Buffer.from(out.media.data);
|
|
31
|
+
const filename = out.media.filename || `file.${out.media.mimeType.split("/")[1] || "bin"}`;
|
|
32
|
+
await this.app.client.filesUploadV2({
|
|
33
|
+
channel_id: dest.channel,
|
|
34
|
+
file: buffer,
|
|
35
|
+
filename,
|
|
36
|
+
...(dest.threadTs ? { thread_ts: dest.threadTs } : {}),
|
|
37
|
+
} as any);
|
|
38
|
+
}
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
file: data,
|
|
52
|
-
filename: filename || `file.${mimeType.split("/")[1] || "bin"}`,
|
|
53
|
-
});
|
|
40
|
+
if (out.text) {
|
|
41
|
+
const opts: Record<string, unknown> = { channel: dest.channel, text: out.text };
|
|
42
|
+
if (dest.threadTs) opts.thread_ts = dest.threadTs;
|
|
43
|
+
const result = await this.app.client.chat.postMessage(opts as any);
|
|
44
|
+
if (result.ts) this.outboundTs.add(result.ts);
|
|
45
|
+
}
|
|
54
46
|
}
|
|
55
47
|
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
file: data,
|
|
61
|
-
filename: filename || `file.${mimeType.split("/")[1] || "bin"}`,
|
|
62
|
-
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
63
|
-
} as any);
|
|
48
|
+
private resolveDest(to: Recipient | undefined): { channel: string; threadTs?: string } {
|
|
49
|
+
if (to?.kind === "thread") return { channel: to.channelId, threadTs: to.threadTs };
|
|
50
|
+
if (!this.dmUserId) throw new Error("No Slack recipient — set dm_user_id in config");
|
|
51
|
+
return { channel: this.dmUserId };
|
|
64
52
|
}
|
|
65
53
|
|
|
66
54
|
async start(): Promise<void> {
|
|
@@ -87,62 +75,46 @@ class SlackChannel implements Channel {
|
|
|
87
75
|
return name;
|
|
88
76
|
}
|
|
89
77
|
|
|
90
|
-
function roomPrefix(key: string): string {
|
|
91
|
-
return `slack-${key}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function roomName(key: string, index: number): string {
|
|
95
|
-
return `slack-${key}-${index}`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
78
|
interface SlackContext {
|
|
99
79
|
slackChannelId?: string;
|
|
100
80
|
slackThreadTs?: string;
|
|
101
81
|
}
|
|
102
82
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!state) {
|
|
106
|
-
const prefix = roomPrefix(key);
|
|
107
|
-
const idx = await Session.getLatestRoomIndex(prefix);
|
|
108
|
-
const room = roomName(key, idx);
|
|
109
|
-
const engine = await createChatEngine({
|
|
110
|
-
room,
|
|
111
|
-
channel: "slack",
|
|
112
|
-
resume: true,
|
|
113
|
-
mcpServers: getMcpServers({ channel: "slack", room, ...slackCtx }),
|
|
114
|
-
watchBehavior,
|
|
115
|
-
});
|
|
116
|
-
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
117
|
-
chats.set(key, state);
|
|
118
|
-
}
|
|
119
|
-
return state;
|
|
83
|
+
function roomPrefix(k: string): string {
|
|
84
|
+
return `slack-${k}`;
|
|
120
85
|
}
|
|
121
86
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const prefix = roomPrefix(key);
|
|
127
|
-
const prevIdx = await Session.getLatestRoomIndex(prefix);
|
|
128
|
-
const newIdx = prevIdx + 1;
|
|
129
|
-
const room = roomName(key, newIdx);
|
|
130
|
-
|
|
131
|
-
// Persist a placeholder session immediately so the room index survives
|
|
132
|
-
// daemon restarts (otherwise getState falls back to the old room).
|
|
133
|
-
await Session.create(`placeholder-${room}`, room);
|
|
87
|
+
function roomName(k: string, index: number): string {
|
|
88
|
+
return `slack-${k}-${index}`;
|
|
89
|
+
}
|
|
134
90
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
room,
|
|
91
|
+
function buildEngineOpts(watchBehavior?: { channel: string; behavior: string }, slackCtx?: SlackContext) {
|
|
92
|
+
return (room: string) => ({
|
|
138
93
|
channel: "slack",
|
|
139
|
-
resume: false,
|
|
140
94
|
mcpServers: getMcpServers({ channel: "slack", room, ...slackCtx }),
|
|
141
95
|
watchBehavior,
|
|
142
96
|
});
|
|
143
|
-
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function getState(
|
|
100
|
+
key: string,
|
|
101
|
+
watchBehavior?: { channel: string; behavior: string },
|
|
102
|
+
slackCtx?: SlackContext,
|
|
103
|
+
): Promise<ChatState> {
|
|
104
|
+
let state = chats.get(key);
|
|
105
|
+
if (state) return state;
|
|
106
|
+
state = await openChatEngine(roomPrefix(key), buildEngineOpts(watchBehavior, slackCtx));
|
|
107
|
+
chats.set(key, state);
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function restartChat(
|
|
112
|
+
key: string,
|
|
113
|
+
watchBehavior?: { channel: string; behavior: string },
|
|
114
|
+
slackCtx?: SlackContext,
|
|
115
|
+
): Promise<ChatState> {
|
|
116
|
+
const state = await rotateRoom(roomPrefix(key), chats.get(key), buildEngineOpts(watchBehavior, slackCtx));
|
|
144
117
|
chats.set(key, state);
|
|
145
|
-
log.info({ key, room, activeSessions: chats.size }, "slack: engine ready");
|
|
146
118
|
return state;
|
|
147
119
|
}
|
|
148
120
|
|
|
@@ -152,9 +124,7 @@ class SlackChannel implements Channel {
|
|
|
152
124
|
fn().catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
|
|
153
125
|
return;
|
|
154
126
|
}
|
|
155
|
-
|
|
156
|
-
if (queued) log.debug({ key }, "slack: message queued behind active lock");
|
|
157
|
-
state.lock = state.lock.then(fn, fn).catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
|
|
127
|
+
chainLock(state, fn);
|
|
158
128
|
}
|
|
159
129
|
|
|
160
130
|
const self = this;
|
|
@@ -167,59 +137,8 @@ class SlackChannel implements Channel {
|
|
|
167
137
|
|
|
168
138
|
let botUserId: string | undefined;
|
|
169
139
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
let watchCache: Map<string, { name: string; behavior: string }> = new Map();
|
|
173
|
-
let watchFilePaths: string[] = [];
|
|
174
|
-
let lastReloadMtime = 0;
|
|
175
|
-
|
|
176
|
-
function maxMtime(paths: string[]): number {
|
|
177
|
-
let max = 0;
|
|
178
|
-
for (const p of paths) {
|
|
179
|
-
try {
|
|
180
|
-
const m = statSync(p).mtimeMs;
|
|
181
|
-
if (m > max) max = m;
|
|
182
|
-
} catch {
|
|
183
|
-
// ignore missing files
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return max;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function reloadWatchChannels(): Map<string, { name: string; behavior: string }> {
|
|
190
|
-
const configPath = getPaths().config;
|
|
191
|
-
const mtime = maxMtime([configPath, ...watchFilePaths]);
|
|
192
|
-
if (mtime === 0) return watchCache;
|
|
193
|
-
if (mtime === lastReloadMtime) return watchCache;
|
|
194
|
-
|
|
195
|
-
resetConfig(); // clear cached config so getConfig() re-reads from disk
|
|
196
|
-
const cfg = getConfig();
|
|
197
|
-
const watch = cfg.channels.slack.watch;
|
|
198
|
-
const fresh = new Map<string, { name: string; behavior: string }>();
|
|
199
|
-
const freshFiles: string[] = [];
|
|
200
|
-
if (watch) {
|
|
201
|
-
for (const [key, entry] of Object.entries(watch)) {
|
|
202
|
-
if (!entry.enabled) continue;
|
|
203
|
-
const hashIdx = key.indexOf("#");
|
|
204
|
-
if (hashIdx === -1) {
|
|
205
|
-
log.warn({ channel: key }, "slack: watch key must use channel_id#name format, skipping");
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
const id = key.slice(0, hashIdx);
|
|
209
|
-
const name = key.slice(hashIdx + 1);
|
|
210
|
-
const resolved = resolveWatchBehavior(entry.behavior, name);
|
|
211
|
-
if (resolved.filePath) freshFiles.push(resolved.filePath);
|
|
212
|
-
fresh.set(id, { name, behavior: resolved.behavior });
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
if (fresh.size !== watchCache.size) {
|
|
216
|
-
log.info({ count: fresh.size }, "slack: watch channels reloaded");
|
|
217
|
-
}
|
|
218
|
-
watchCache = fresh;
|
|
219
|
-
watchFilePaths = freshFiles;
|
|
220
|
-
lastReloadMtime = maxMtime([configPath, ...freshFiles]);
|
|
221
|
-
return watchCache;
|
|
222
|
-
}
|
|
140
|
+
const watchReloader = new SlackWatchReloader();
|
|
141
|
+
const attachmentCache = new SlackAttachmentCache(botToken);
|
|
223
142
|
|
|
224
143
|
// Slash command: /nia
|
|
225
144
|
app.command("/nia", async ({ command, ack, respond }) => {
|
|
@@ -268,129 +187,6 @@ class SlackChannel implements Channel {
|
|
|
268
187
|
await respond("New conversation started.");
|
|
269
188
|
});
|
|
270
189
|
|
|
271
|
-
// Disk-backed file cache: download once, read from disk on subsequent requests
|
|
272
|
-
const attachRoot = join(getNiaHome(), "tmp", "attachments");
|
|
273
|
-
mkdirSync(attachRoot, { recursive: true });
|
|
274
|
-
|
|
275
|
-
interface CachedFile {
|
|
276
|
-
path: string;
|
|
277
|
-
type: AttachmentType;
|
|
278
|
-
mimeType: string;
|
|
279
|
-
filename?: string;
|
|
280
|
-
}
|
|
281
|
-
const fileIndex = new Map<string, CachedFile>();
|
|
282
|
-
|
|
283
|
-
function urlHash(url: string): string {
|
|
284
|
-
return createHash("sha256").update(url).digest("hex").slice(0, 16);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function loadCached(entry: CachedFile): Attachment {
|
|
288
|
-
return {
|
|
289
|
-
type: entry.type,
|
|
290
|
-
data: readFileSync(entry.path),
|
|
291
|
-
mimeType: entry.mimeType,
|
|
292
|
-
filename: entry.filename,
|
|
293
|
-
sourcePath: entry.path,
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
async function downloadSlackFile(url: string): Promise<Buffer> {
|
|
298
|
-
const resp = await fetch(url, {
|
|
299
|
-
headers: { Authorization: `Bearer ${botToken}` },
|
|
300
|
-
});
|
|
301
|
-
if (!resp.ok) throw new Error(`Slack file download failed: ${resp.status}`);
|
|
302
|
-
return Buffer.from(await resp.arrayBuffer());
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function cacheDirForScope(scope: string): string {
|
|
306
|
-
const safeScope = scope.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
307
|
-
const dir = join(attachRoot, safeScope);
|
|
308
|
-
mkdirSync(dir, { recursive: true });
|
|
309
|
-
return dir;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function cacheKey(scope: string, url: string): string {
|
|
313
|
-
return `${scope}:${url}`;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function safeExtension(filename?: string): string {
|
|
317
|
-
const ext = filename?.split(".").pop();
|
|
318
|
-
return ext && /^[a-zA-Z0-9]{1,16}$/.test(ext) ? ext : "bin";
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function cacheExtension(filename: string | undefined, mime: string, attType: AttachmentType): string {
|
|
322
|
-
if (attType === "image" && mime !== "image/gif") return "jpg";
|
|
323
|
-
return safeExtension(filename);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async function extractSlackAttachments(files: any[], scope: string): Promise<Attachment[]> {
|
|
327
|
-
const attachments: Attachment[] = [];
|
|
328
|
-
const scopedAttachDir = cacheDirForScope(scope);
|
|
329
|
-
for (const file of files) {
|
|
330
|
-
const mime = file.mimetype || "application/octet-stream";
|
|
331
|
-
const attType = classifyMime(mime);
|
|
332
|
-
if (!attType) continue;
|
|
333
|
-
if (!file.url_private_download) continue;
|
|
334
|
-
|
|
335
|
-
// Check in-memory index first
|
|
336
|
-
const indexedKey = cacheKey(scope, file.url_private_download);
|
|
337
|
-
const cached = fileIndex.get(indexedKey);
|
|
338
|
-
if (cached && existsSync(cached.path)) {
|
|
339
|
-
attachments.push(loadCached(cached));
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Check disk (survives daemon restarts) — scoped by Slack room/thread.
|
|
344
|
-
const hash = urlHash(file.url_private_download);
|
|
345
|
-
const ext = cacheExtension(file.name, mime, attType);
|
|
346
|
-
const diskPath = join(scopedAttachDir, `${hash}.${ext}`);
|
|
347
|
-
const metaPath = join(scopedAttachDir, `${hash}.meta.json`);
|
|
348
|
-
if (existsSync(diskPath) && existsSync(metaPath)) {
|
|
349
|
-
try {
|
|
350
|
-
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
351
|
-
const entry: CachedFile = {
|
|
352
|
-
path: diskPath,
|
|
353
|
-
type: meta.type || attType,
|
|
354
|
-
mimeType: meta.mimeType || mime,
|
|
355
|
-
filename: meta.filename || file.name,
|
|
356
|
-
};
|
|
357
|
-
fileIndex.set(indexedKey, entry);
|
|
358
|
-
attachments.push(loadCached(entry));
|
|
359
|
-
continue;
|
|
360
|
-
} catch {
|
|
361
|
-
// Corrupt meta — re-download
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
try {
|
|
366
|
-
const data = await downloadSlackFile(file.url_private_download);
|
|
367
|
-
const error = validateAttachment(data, mime);
|
|
368
|
-
if (error) {
|
|
369
|
-
log.warn({ file: file.name, error }, "skipping slack attachment");
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
let finalData = data;
|
|
373
|
-
let finalMime = mime;
|
|
374
|
-
if (attType === "image") {
|
|
375
|
-
const prepared = await prepareImage(data, mime);
|
|
376
|
-
finalData = prepared.data;
|
|
377
|
-
finalMime = prepared.mimeType;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Save file + metadata to disk
|
|
381
|
-
writeFileSync(diskPath, finalData);
|
|
382
|
-
writeFileSync(metaPath, JSON.stringify({ type: attType, mimeType: finalMime, filename: file.name }));
|
|
383
|
-
const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
|
|
384
|
-
fileIndex.set(indexedKey, entry);
|
|
385
|
-
|
|
386
|
-
attachments.push({ type: attType, data: finalData, mimeType: finalMime, filename: file.name, sourcePath: diskPath });
|
|
387
|
-
} catch (err) {
|
|
388
|
-
log.warn({ err, file: file.name }, "failed to download slack file");
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
return attachments;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
190
|
// Handle messages (DMs + @mentions)
|
|
395
191
|
app.message(async ({ message, say, client }) => {
|
|
396
192
|
if (message.subtype && message.subtype !== "file_share") return;
|
|
@@ -455,7 +251,7 @@ class SlackChannel implements Channel {
|
|
|
455
251
|
}
|
|
456
252
|
|
|
457
253
|
// Check if this is a watched channel (hot-reloads from config.yaml via mtime)
|
|
458
|
-
const currentWatch =
|
|
254
|
+
const currentWatch = watchReloader.reload();
|
|
459
255
|
const watchConfig = currentWatch.get(msg.channel);
|
|
460
256
|
const isWatched = !!watchConfig;
|
|
461
257
|
|
|
@@ -517,7 +313,7 @@ class SlackChannel implements Channel {
|
|
|
517
313
|
// Download any file attachments
|
|
518
314
|
let attachments: Attachment[] | undefined;
|
|
519
315
|
if (hasFiles) {
|
|
520
|
-
attachments = await
|
|
316
|
+
attachments = await attachmentCache.extract(msg.files!, roomPrefix(key));
|
|
521
317
|
}
|
|
522
318
|
|
|
523
319
|
if (!text && (!attachments || attachments.length === 0)) return;
|
|
@@ -554,7 +350,7 @@ class SlackChannel implements Channel {
|
|
|
554
350
|
const messagesWithFiles = priorMessages.filter((m: any) => m.files?.length > 0);
|
|
555
351
|
let threadFilesAdded = 0;
|
|
556
352
|
for (const m of messagesWithFiles) {
|
|
557
|
-
const extracted = await
|
|
353
|
+
const extracted = await attachmentCache.extract(m.files || [], roomPrefix(key));
|
|
558
354
|
for (const att of extracted) {
|
|
559
355
|
attachments.push(att);
|
|
560
356
|
threadFilesAdded++;
|
|
@@ -693,7 +489,7 @@ class SlackChannel implements Channel {
|
|
|
693
489
|
}
|
|
694
490
|
|
|
695
491
|
// Initial watch channel load
|
|
696
|
-
|
|
492
|
+
watchReloader.reload();
|
|
697
493
|
|
|
698
494
|
log.info("slack bot started (Socket Mode)");
|
|
699
495
|
this.app = app;
|
package/src/channels/sms.ts
CHANGED
|
@@ -14,29 +14,26 @@
|
|
|
14
14
|
* variable deliverability under TRAI scrubbing rules. Test empirically;
|
|
15
15
|
* if outbound fails, the inbound leg (Aman → Nia) is more reliable.
|
|
16
16
|
*/
|
|
17
|
-
import { createChatEngine } from "../chat/engine";
|
|
18
17
|
import { getMcpServers } from "../mcp";
|
|
19
|
-
import { Session } from "../db/models";
|
|
20
18
|
import { runMigrations } from "../db/migrate";
|
|
21
|
-
import type { Channel, ChatState,
|
|
19
|
+
import type { Channel, ChatState, Outbound, TwilioConfig } from "../types";
|
|
22
20
|
import { getConfig } from "../utils/config";
|
|
23
21
|
import { log } from "../utils/log";
|
|
24
22
|
import { sendMessage as twilioSendMessage } from "./twilio/rest";
|
|
25
23
|
import { getTwilioServer } from "./twilio/server";
|
|
24
|
+
import { chainLock, openChatEngine } from "./common/chat-session";
|
|
26
25
|
|
|
27
26
|
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
28
27
|
|
|
29
28
|
class SmsChannel implements Channel {
|
|
30
|
-
name = "sms";
|
|
29
|
+
name = "sms" as const;
|
|
31
30
|
private readonly twilio: TwilioConfig;
|
|
32
|
-
private readonly sms: SmsConfig;
|
|
33
31
|
/** Cached resolved "from" number: sms.from_number || phone.from_number */
|
|
34
32
|
private readonly fromNumber: string;
|
|
35
33
|
private readonly chats = new Map<string, ChatState>();
|
|
36
34
|
|
|
37
|
-
constructor(twilio: TwilioConfig,
|
|
35
|
+
constructor(twilio: TwilioConfig, fromNumber: string) {
|
|
38
36
|
this.twilio = twilio;
|
|
39
|
-
this.sms = sms;
|
|
40
37
|
this.fromNumber = fromNumber;
|
|
41
38
|
}
|
|
42
39
|
|
|
@@ -77,10 +74,16 @@ class SmsChannel implements Channel {
|
|
|
77
74
|
this.chats.clear();
|
|
78
75
|
}
|
|
79
76
|
|
|
80
|
-
/** Outbound
|
|
81
|
-
async
|
|
77
|
+
/** Outbound — used by send_message MCP tool. SMS is text-only; media is dropped with a warning. */
|
|
78
|
+
async deliver(out: Outbound): Promise<void> {
|
|
82
79
|
if (!this.twilio.owner_number) throw new Error("sms: owner_number not set");
|
|
83
|
-
|
|
80
|
+
// SMS has no threading; recipient kind is ignored.
|
|
81
|
+
if (out.media) {
|
|
82
|
+
log.warn({ filename: out.media.filename }, "sms: media payload dropped (channel is text-only)");
|
|
83
|
+
}
|
|
84
|
+
if (out.text) {
|
|
85
|
+
await this.sendTo(this.twilio.owner_number, out.text);
|
|
86
|
+
}
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
// --- Inbound webhook ---
|
|
@@ -97,19 +100,16 @@ class SmsChannel implements Channel {
|
|
|
97
100
|
const state = await this.getState(from);
|
|
98
101
|
// Ack the webhook immediately; reply via REST asynchronously to avoid
|
|
99
102
|
// Twilio's ~15s webhook timeout when the engine takes longer.
|
|
100
|
-
state
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
},
|
|
111
|
-
(err) => log.error({ err, from }, "sms: lock chain error"),
|
|
112
|
-
);
|
|
103
|
+
chainLock(state, async () => {
|
|
104
|
+
try {
|
|
105
|
+
const { result } = await state.engine.send(body);
|
|
106
|
+
const reply = result.trim() || "(no response)";
|
|
107
|
+
await this.sendTo(from, reply);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
log.error({ err, from }, "sms: engine error");
|
|
110
|
+
await this.sendTo(from, `[error] ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
113
|
|
|
114
114
|
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
115
115
|
}
|
|
@@ -160,17 +160,7 @@ class SmsChannel implements Channel {
|
|
|
160
160
|
private async getState(remoteE164: string): Promise<ChatState> {
|
|
161
161
|
let state = this.chats.get(remoteE164);
|
|
162
162
|
if (state) return state;
|
|
163
|
-
|
|
164
|
-
const idx = await Session.getLatestRoomIndex(prefix);
|
|
165
|
-
const room = `${prefix}-${idx}`;
|
|
166
|
-
log.info({ remoteE164, room }, "sms: creating chat engine");
|
|
167
|
-
const engine = await createChatEngine({
|
|
168
|
-
room,
|
|
169
|
-
channel: "sms",
|
|
170
|
-
resume: true,
|
|
171
|
-
mcpServers: getMcpServers(),
|
|
172
|
-
});
|
|
173
|
-
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
163
|
+
state = await openChatEngine(`sms-${remoteE164}`, () => ({ channel: "sms", mcpServers: getMcpServers() }));
|
|
174
164
|
this.chats.set(remoteE164, state);
|
|
175
165
|
return state;
|
|
176
166
|
}
|
|
@@ -183,7 +173,7 @@ export function createSmsChannel(): SmsChannel | null {
|
|
|
183
173
|
// sms.from_number falls back to phone.from_number (same number for voice + SMS).
|
|
184
174
|
const fromNumber = sms.from_number ?? phone.from_number;
|
|
185
175
|
if (!fromNumber) return null;
|
|
186
|
-
return new SmsChannel(twilio,
|
|
176
|
+
return new SmsChannel(twilio, fromNumber);
|
|
187
177
|
}
|
|
188
178
|
|
|
189
179
|
export type { SmsChannel };
|