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.
@@ -1,18 +1,14 @@
1
1
  import { App } from "@slack/bolt";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs";
3
- import { join } from "path";
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 { getNiaHome, getPaths } from "../utils/paths";
14
- import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
15
- import { resolveWatchBehavior } from "../utils/watches";
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 sendMessage(text: string): Promise<void> {
25
+ async deliver(out: Outbound): Promise<void> {
30
26
  if (!this.app) throw new Error("Slack not started");
31
- const target = this.dmUserId;
32
- if (!target) throw new Error("No Slack recipient — set dm_user_id in config");
33
- const result = await this.app.client.chat.postMessage({ channel: target, text });
34
- if (result.ts) this.outboundTs.add(result.ts);
35
- }
36
-
37
- async sendToThread(channelId: string, text: string, threadTs?: string): Promise<void> {
38
- if (!this.app) throw new Error("Slack not started");
39
- const opts: Record<string, unknown> = { channel: channelId, text };
40
- if (threadTs) opts.thread_ts = threadTs;
41
- const result = await this.app.client.chat.postMessage(opts as any);
42
- if (result.ts) this.outboundTs.add(result.ts);
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
- async sendMedia(data: Buffer, mimeType: string, filename?: string): Promise<void> {
46
- if (!this.app) throw new Error("Slack not started");
47
- const target = this.dmUserId;
48
- if (!target) throw new Error("No Slack recipient — set dm_user_id in config");
49
- await this.app.client.filesUploadV2({
50
- channel_id: target,
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
- async sendMediaToThread(channelId: string, data: Buffer, mimeType: string, filename?: string, threadTs?: string): Promise<void> {
57
- if (!this.app) throw new Error("Slack not started");
58
- await this.app.client.filesUploadV2({
59
- channel_id: channelId,
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
- async function getState(key: string, watchBehavior?: { channel: string; behavior: string }, slackCtx?: SlackContext): Promise<ChatState> {
104
- let state = chats.get(key);
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
- async function restartChat(key: string, watchBehavior?: { channel: string; behavior: string }, slackCtx?: SlackContext): Promise<ChatState> {
123
- const old = chats.get(key);
124
- if (old) old.engine.close();
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
- log.info({ key, room }, "slack: creating chat engine");
136
- const engine = await createChatEngine({
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
- const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
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
- const queued = state.lock !== Promise.resolve();
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
- // Watch channels: mtime-based hot-reload from config.yaml AND any watch
171
- // behavior files referenced by that config. Keys are channel_id#channel_name.
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 = reloadWatchChannels();
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 extractSlackAttachments(msg.files!, roomPrefix(key));
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 extractSlackAttachments(m.files || [], roomPrefix(key));
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
- reloadWatchChannels();
492
+ watchReloader.reload();
697
493
 
698
494
  log.info("slack bot started (Socket Mode)");
699
495
  this.app = app;
@@ -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, PhoneConfig, SmsConfig, TwilioConfig } from "../types";
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, sms: SmsConfig, fromNumber: string) {
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 to the owner — used by send_message MCP tool. */
81
- async sendMessage(text: string): Promise<void> {
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
- await this.sendTo(this.twilio.owner_number, text);
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.lock = state.lock.then(
101
- async () => {
102
- try {
103
- const { result } = await state.engine.send(body);
104
- const reply = result.trim() || "(no response)";
105
- await this.sendTo(from, reply);
106
- } catch (err) {
107
- log.error({ err, from }, "sms: engine error");
108
- await this.sendTo(from, `[error] ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
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
- const prefix = `sms-${remoteE164}`;
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, sms, fromNumber);
176
+ return new SmsChannel(twilio, fromNumber);
187
177
  }
188
178
 
189
179
  export type { SmsChannel };