niahere 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Phone channel — voice calling via Twilio + OpenAI Realtime.
3
3
  *
4
- * Boots an HTTP+WebSocket server inside the daemon. Twilio reaches it
5
- * through a public tunnel (cloudflared in our setup). Two surfaces:
4
+ * Registers HTTP + WebSocket routes on the shared TwilioWebhookServer
5
+ * (which other Twilio channels sms, whatsapp — also use). The server
6
+ * handles signature validation, dedup, and rate-limit middleware so this
7
+ * file only owns voice-specific logic:
6
8
  *
7
9
  * - Inbound: caller dials our Twilio number; we return TwiML that opens
8
10
  * a Media Stream back to us; the stream is bridged to OpenAI Realtime.
@@ -11,33 +13,30 @@
11
13
  * with a per-call goal seeded into the realtime session.
12
14
  *
13
15
  * Submodules:
14
- * - twilio.ts REST + webhook signature helpers
15
- * - twiml.ts TwiML XML builders
16
- * - relay.ts Twilio Media Stream <-> OpenAI Realtime bridge
17
- * - instructions.ts system-prompt builders for inbound/outbound
18
- * - tools.ts function-calling tools exposed to the realtime model
19
- * - consult.ts escape hatch to Claude for memory-aware reasoning
16
+ * - ../twilio/server.ts shared HTTP+WS server
17
+ * - ../twilio/rest.ts Twilio REST API helpers (placeCall, etc.)
18
+ * - twiml.ts TwiML XML builders
19
+ * - relay.ts Twilio Media Stream <-> OpenAI Realtime bridge
20
+ * - instructions.ts system-prompt builders for inbound/outbound
21
+ * - tools.ts function-calling tools exposed to the realtime model
22
+ * - consult.ts — escape hatch to Claude for memory-aware reasoning
20
23
  */
21
- import type { Server, ServerWebSocket } from "bun";
22
- import type { Channel, PhoneConfig } from "../../types";
24
+ import type { ServerWebSocket } from "bun";
25
+ import type { Channel, Outbound, PhoneConfig, TwilioConfig } from "../../types";
23
26
  import { getConfig } from "../../utils/config";
24
27
  import { log } from "../../utils/log";
25
28
  import { getChannel } from "../registry";
26
29
  import { Session, Message } from "../../db/models";
27
30
  import { runMigrations } from "../../db/migrate";
28
31
 
29
- import { placeCall as twilioPlaceCall, validateTwilioSignature } from "./twilio";
32
+ import { getTwilioServer, type WsConnectionData } from "../twilio/server";
33
+ import { placeCall as twilioPlaceCall } from "../twilio/rest";
30
34
  import { streamTwiML, sayAndHangupTwiML, rejectTwiML } from "./twiml";
31
35
  import { createRelay, type CallContext, type RelayHandle, type RelayResult } from "./relay";
32
36
  import { buildInboundInstructions, buildOutboundInstructions } from "./instructions";
33
37
  import { buildPhoneTools } from "./tools";
34
38
 
35
- interface WsData {
36
- streamSid: string | null;
37
- }
38
-
39
39
  interface PendingCall {
40
- /** Context written when the call is initiated; consumed by the WS upgrade. */
41
40
  context: Omit<CallContext, "streamSid" | "tools">;
42
41
  startedAt: number;
43
42
  }
@@ -64,97 +63,77 @@ function defer<T>(): Deferred<T> {
64
63
  const DEFAULT_MAX_MINUTES = 10;
65
64
  const HARD_MAX_MINUTES = 30;
66
65
 
66
+ const WS_PATH = "/twilio/voice/stream";
67
+
67
68
  class PhoneChannel implements Channel {
68
- name = "phone";
69
- private server: Server<WsData> | null = null;
70
- private readonly cfg: PhoneConfig;
71
- /** Calls placed/answered but whose Media Stream hasn't connected yet. */
69
+ name = "phone" as const;
70
+ private readonly phone: PhoneConfig;
71
+ private readonly twilio: TwilioConfig;
72
72
  private readonly pending = new Map<string, PendingCall>();
73
- /** Active relays, keyed by streamSid (assigned on Twilio "start" event). */
73
+ /** Active relays keyed by streamSid. */
74
74
  private readonly active = new Map<string, ActiveCall>();
75
- /** Per-callSid completion deferreds. Resolved when the call's transcript is persisted. */
75
+ /** Per-callSid completion deferreds; resolved after persistCall. */
76
76
  private readonly completions = new Map<string, Deferred<RelayResult>>();
77
77
 
78
- constructor(cfg: PhoneConfig) {
79
- this.cfg = cfg;
78
+ constructor(phone: PhoneConfig, twilio: TwilioConfig) {
79
+ this.phone = phone;
80
+ this.twilio = twilio;
80
81
  }
81
82
 
82
- // --- Channel lifecycle ---
83
-
84
83
  async start(): Promise<void> {
85
84
  await runMigrations();
86
- const self = this;
87
-
88
- this.server = Bun.serve<WsData, never>({
89
- port: this.cfg.port,
90
- async fetch(req, server) {
91
- const path = new URL(req.url).pathname;
92
-
93
- if (path === "/healthz") return new Response("ok", { status: 200 });
94
-
95
- if (path === "/twilio/voice/stream") {
96
- const ok = server.upgrade(req, { data: { streamSid: null } });
97
- return ok ? undefined : new Response("expected websocket", { status: 400 });
98
- }
99
- if (path === "/twilio/voice/incoming" && req.method === "POST") {
100
- return await self.handleIncoming(req);
101
- }
102
- if (path === "/twilio/voice/outbound" && req.method === "POST") {
103
- return await self.handleOutboundTwiml(req);
104
- }
105
- if (path === "/twilio/voice/status" && req.method === "POST") {
106
- return await self.handleStatus(req);
107
- }
108
- return new Response("not found", { status: 404 });
109
- },
110
- websocket: {
111
- message(ws, message) {
112
- const data = typeof message === "string" ? message : new TextDecoder().decode(message);
113
- const sid = ws.data.streamSid;
114
- if (sid) {
115
- const active = self.active.get(sid);
116
- if (active) {
117
- active.handle.onTwilioMessage(data);
118
- return;
119
- }
120
- }
121
- self.bootstrapWsMessage(ws, data).catch((err) => {
122
- log.error({ err }, "phone: ws bootstrap failed");
123
- try {
124
- ws.close();
125
- } catch {}
126
- });
127
- },
128
- close(ws) {
129
- const sid = ws.data.streamSid;
130
- if (!sid) return;
131
- const active = self.active.get(sid);
132
- if (active) active.handle.onTwilioClose();
133
- },
134
- },
85
+
86
+ const server = getTwilioServer();
87
+ server.configure({
88
+ port: this.twilio.port,
89
+ publicBaseUrl: this.twilio.public_base_url,
90
+ signingToken: this.twilio.auth_token || this.twilio.secret,
91
+ });
92
+
93
+ server.registerHttp("/twilio/voice/incoming", (req, ctx) => this.handleIncoming(req, ctx.params));
94
+ server.registerHttp("/twilio/voice/outbound", (req, ctx) => this.handleOutboundTwiml(req, ctx.params));
95
+ server.registerHttp("/twilio/voice/status", (_req, ctx) => this.handleStatus(ctx.params), {
96
+ dedupOn: "CallSid",
97
+ });
98
+
99
+ server.registerWs(WS_PATH, {
100
+ onMessage: (ws, data) => this.onWsMessage(ws, data),
101
+ onClose: (ws) => this.onWsClose(ws),
135
102
  });
136
103
 
104
+ // Owner number must never be rate-limited (e.g. urgent rapid retries).
105
+ if (this.twilio.owner_number) server.exemptFromRateLimit(this.twilio.owner_number);
106
+
107
+ await server.start();
108
+
137
109
  log.info(
138
110
  {
139
- port: this.cfg.port,
140
- publicBaseUrl: this.cfg.public_base_url,
141
- from: this.cfg.from_number,
142
- owner: this.cfg.owner_number,
143
- realtimeModel: this.cfg.realtime_model,
144
- voice: this.cfg.voice,
111
+ port: this.twilio.port,
112
+ publicBaseUrl: this.twilio.public_base_url,
113
+ from: this.phone.from_number,
114
+ owner: this.twilio.owner_number,
115
+ realtimeModel: this.phone.realtime_model,
116
+ voice: this.phone.voice,
145
117
  },
146
118
  "phone channel started",
147
119
  );
148
120
  }
149
121
 
150
122
  async stop(): Promise<void> {
151
- if (this.server) {
152
- this.server.stop(true);
153
- this.server = null;
154
- }
155
123
  for (const active of this.active.values()) active.handle.onTwilioClose();
156
124
  this.active.clear();
157
125
  this.pending.clear();
126
+ // The shared server is stopped by the daemon's channel teardown — leaving
127
+ // it running here would block SMS/WhatsApp if they're also bound to it.
128
+ }
129
+
130
+ /**
131
+ * Phone is voice-only — agent-initiated text/media doesn't have a sensible
132
+ * delivery shape over Twilio Voice. Callers that want a text notification
133
+ * about a call should target a text channel (telegram, slack, whatsapp).
134
+ */
135
+ async deliver(_out: Outbound): Promise<void> {
136
+ throw new Error("phone: text/media delivery is not supported — use a text channel or placeCall() for voice");
158
137
  }
159
138
 
160
139
  // --- Outbound entrypoint (used by the place_call MCP tool and CLI test) ---
@@ -174,10 +153,6 @@ class PhoneChannel implements Channel {
174
153
  opts.maxMinutes && opts.maxMinutes > 0 ? Math.min(opts.maxMinutes, HARD_MAX_MINUTES) : DEFAULT_MAX_MINUTES;
175
154
  const instructions = buildOutboundInstructions(opts.goal, opts.context);
176
155
 
177
- // Twilio always includes `CallSid` in the form body of the TwiML
178
- // webhook, so we don't need to encode it in the URL path — one static
179
- // endpoint handles every outbound call, and we look up context by the
180
- // CallSid Twilio sends us back.
181
156
  const result = await twilioPlaceCall({
182
157
  ...creds,
183
158
  to: opts.number,
@@ -205,21 +180,14 @@ class PhoneChannel implements Channel {
205
180
  return result;
206
181
  }
207
182
 
208
- /**
209
- * Wait for an in-flight call to finish. Resolves with the final transcript
210
- * once the call has been persisted; returns null if the callSid is unknown.
211
- */
212
183
  async awaitCallCompletion(callSid: string): Promise<RelayResult | null> {
213
184
  const deferred = this.completions.get(callSid);
214
185
  return deferred ? deferred.promise : null;
215
186
  }
216
187
 
217
- // --- HTTP handlers ---
218
-
219
- private async handleIncoming(req: Request): Promise<Response> {
220
- const { params } = await readForm(req);
221
- if (!this.verifySignature(req, params)) return forbidden();
188
+ // --- HTTP handlers (signature already validated by the shared server) ---
222
189
 
190
+ private async handleIncoming(_req: Request, params: Record<string, string>): Promise<Response> {
223
191
  const callSid = params.CallSid || "";
224
192
  const from = params.From || "";
225
193
  const { label, allowed } = this.classifyCaller(from);
@@ -227,7 +195,7 @@ class PhoneChannel implements Channel {
227
195
  if (!allowed) {
228
196
  log.warn({ from, callSid }, "phone: rejecting unauthorized caller");
229
197
  getChannel("telegram")
230
- ?.sendMessage?.(`Phone: rejected call from ${from} (CallSid ${callSid})`)
198
+ ?.deliver({ text: `Phone: rejected call from ${from} (CallSid ${callSid})` })
231
199
  .catch(() => {});
232
200
  return twimlResponse(sayAndHangupTwiML("Sorry, this line is not currently accepting calls. Goodbye."));
233
201
  }
@@ -254,10 +222,7 @@ class PhoneChannel implements Channel {
254
222
  return twimlResponse(streamTwiML(this.buildWssUrl(), { callSid, direction: "inbound" }));
255
223
  }
256
224
 
257
- private async handleOutboundTwiml(req: Request): Promise<Response> {
258
- const { params } = await readForm(req);
259
- if (!this.verifySignature(req, params)) return forbidden();
260
-
225
+ private async handleOutboundTwiml(_req: Request, params: Record<string, string>): Promise<Response> {
261
226
  const callSid = params.CallSid || "";
262
227
  const pending = this.pending.get(callSid);
263
228
  if (!pending) {
@@ -270,9 +235,7 @@ class PhoneChannel implements Channel {
270
235
  return twimlResponse(streamTwiML(this.buildWssUrl(), { callSid, direction: "outbound" }));
271
236
  }
272
237
 
273
- private async handleStatus(req: Request): Promise<Response> {
274
- const { params } = await readForm(req);
275
- if (!this.verifySignature(req, params)) return forbidden();
238
+ private handleStatus(params: Record<string, string>): Response {
276
239
  log.info(
277
240
  {
278
241
  callSid: params.CallSid,
@@ -285,16 +248,40 @@ class PhoneChannel implements Channel {
285
248
  return new Response("", { status: 204 });
286
249
  }
287
250
 
288
- // --- WebSocket bootstrap ---
251
+ // --- WebSocket plumbing (delegated by shared server to this channel) ---
252
+
253
+ private onWsMessage(ws: ServerWebSocket<WsConnectionData>, data: string): void {
254
+ const sid = (ws.data.channel as { streamSid?: string }).streamSid;
255
+ if (sid) {
256
+ const active = this.active.get(sid);
257
+ if (active) {
258
+ active.handle.onTwilioMessage(data);
259
+ return;
260
+ }
261
+ }
262
+ this.bootstrapWsMessage(ws, data).catch((err) => {
263
+ log.error({ err }, "phone: ws bootstrap failed");
264
+ try {
265
+ ws.close();
266
+ } catch {}
267
+ });
268
+ }
269
+
270
+ private onWsClose(ws: ServerWebSocket<WsConnectionData>): void {
271
+ const sid = (ws.data.channel as { streamSid?: string }).streamSid;
272
+ if (!sid) return;
273
+ const active = this.active.get(sid);
274
+ if (active) active.handle.onTwilioClose();
275
+ }
289
276
 
290
- private async bootstrapWsMessage(ws: ServerWebSocket<WsData>, data: string): Promise<void> {
277
+ private async bootstrapWsMessage(ws: ServerWebSocket<WsConnectionData>, data: string): Promise<void> {
291
278
  let msg: any;
292
279
  try {
293
280
  msg = JSON.parse(data);
294
281
  } catch {
295
282
  return;
296
283
  }
297
- if (msg.event === "connected") return; // first frame, no streamSid yet
284
+ if (msg.event === "connected") return;
298
285
 
299
286
  if (msg.event !== "start") {
300
287
  log.warn({ event: msg.event }, "phone: unexpected first ws event");
@@ -321,16 +308,16 @@ class PhoneChannel implements Channel {
321
308
 
322
309
  const handle = createRelay({
323
310
  twilioWs: ws,
324
- openAiKey: this.cfg.openai_api_key!,
325
- model: this.cfg.realtime_model,
326
- voice: this.cfg.voice,
311
+ openAiKey: this.phone.openai_api_key!,
312
+ model: this.phone.realtime_model,
313
+ voice: this.phone.voice,
327
314
  context,
328
315
  });
329
316
 
330
- ws.data.streamSid = streamSid;
317
+ (ws.data.channel as { streamSid?: string }).streamSid = streamSid;
331
318
  this.active.set(streamSid, { handle, context, startedAt: pending.startedAt });
332
319
 
333
- handle.onTwilioMessage(data); // forward the "start" event itself
320
+ handle.onTwilioMessage(data);
334
321
 
335
322
  handle.completion
336
323
  .then((result) => this.persistCall(context, pending.startedAt, result))
@@ -341,50 +328,36 @@ class PhoneChannel implements Channel {
341
328
  // --- Helpers ---
342
329
 
343
330
  private canStartRealtime(): boolean {
344
- return Boolean(this.cfg.openai_api_key && this.cfg.public_base_url);
331
+ return Boolean(this.phone.openai_api_key && this.twilio.public_base_url);
345
332
  }
346
333
 
347
334
  private buildWssUrl(): string {
348
- const base = this.cfg.public_base_url!.replace(/^http/, "ws");
349
- return `${base}/twilio/voice/stream`;
335
+ return getTwilioServer().buildWssUrl(WS_PATH);
350
336
  }
351
337
 
352
338
  private classifyCaller(from: string): { label: string; allowed: boolean } {
353
- if (from && from === this.cfg.owner_number) return { label: "Aman", allowed: true };
354
- if (from && this.cfg.allowlist.includes(from)) return { label: from, allowed: true };
339
+ if (from && from === this.twilio.owner_number) return { label: "Aman", allowed: true };
340
+ if (from && this.twilio.allowlist.includes(from)) return { label: from, allowed: true };
355
341
  return { label: from || "unknown", allowed: false };
356
342
  }
357
343
 
358
- private verifySignature(req: Request, params: Record<string, string>): boolean {
359
- // X-Twilio-Signature is HMAC-SHA1 with the account's Auth Token. When an
360
- // API Key (SK…) + Secret is used for REST auth, the Auth Token is a
361
- // separate value set as TWILIO_AUTH_TOKEN. We fall back to TWILIO_SECRET
362
- // so the simple "Account SID + Auth Token" pairing also works.
363
- const signingToken = this.cfg.twilio_auth_token || this.cfg.twilio_secret;
364
- if (!signingToken) return false;
365
- const signature = req.headers.get("X-Twilio-Signature") || "";
366
- const fullUrl = this.cfg.public_base_url ? `${this.cfg.public_base_url}${new URL(req.url).pathname}` : req.url;
367
- return validateTwilioSignature({ authToken: signingToken, fullUrl, params, signature });
368
- }
369
-
370
- private requireCreds(): { accountSid: string; authToken: string } {
371
- const sid = this.cfg.twilio_sid;
372
- const secret = this.cfg.twilio_secret;
373
- if (!sid || !secret)
374
- throw new Error("phone: channels.phone.twilio_sid and twilio_secret not set (config.yaml or env)");
375
- return { accountSid: sid, authToken: secret };
344
+ private requireCreds(): { accountSid: string; authSid: string; authSecret: string } {
345
+ const sid = this.twilio.sid;
346
+ const secret = this.twilio.secret;
347
+ if (!sid || !secret) throw new Error("twilio: sid and secret not set (channels.twilio.* in config.yaml or env)");
348
+ return { accountSid: sid, authSid: sid, authSecret: secret };
376
349
  }
377
350
 
378
351
  private requirePublicBaseUrl(): string {
379
- if (!this.cfg.public_base_url)
380
- throw new Error("phone: channels.phone.public_base_url not set (config.yaml or PUBLIC_BASE_URL env)");
381
- return this.cfg.public_base_url;
352
+ if (!this.twilio.public_base_url)
353
+ throw new Error("twilio: public_base_url not set (channels.twilio.public_base_url or PUBLIC_BASE_URL env)");
354
+ return this.twilio.public_base_url;
382
355
  }
383
356
 
384
357
  private requireFromNumber(): string {
385
- if (!this.cfg.from_number)
386
- throw new Error("phone: channels.phone.from_number not set (config.yaml or PHONE_FROM_NUMBER env)");
387
- return this.cfg.from_number;
358
+ if (!this.phone.from_number)
359
+ throw new Error("phone: from_number not set (channels.phone.from_number or PHONE_FROM_NUMBER env)");
360
+ return this.phone.from_number;
388
361
  }
389
362
 
390
363
  private async persistCall(context: CallContext, startedAt: number, result: RelayResult): Promise<void> {
@@ -432,34 +405,22 @@ class PhoneChannel implements Channel {
432
405
 
433
406
  // --- Pure helpers ---
434
407
 
435
- async function readForm(req: Request): Promise<{ params: Record<string, string> }> {
436
- const body = await req.text();
437
- const params: Record<string, string> = {};
438
- const usp = new URLSearchParams(body);
439
- for (const [k, v] of usp) params[k] = v;
440
- return { params };
441
- }
442
-
443
408
  function twimlResponse(xml: string): Response {
444
409
  return new Response(xml, { status: 200, headers: { "Content-Type": "text/xml" } });
445
410
  }
446
411
 
447
- function forbidden(): Response {
448
- return new Response("invalid signature", { status: 403 });
449
- }
450
-
451
412
  // --- Factory ---
452
413
 
453
414
  let _instance: PhoneChannel | null = null;
454
415
 
455
416
  export function createPhoneChannel(): PhoneChannel | null {
456
- const cfg = getConfig().channels.phone;
457
- if (!cfg.twilio_sid || !cfg.twilio_secret || !cfg.from_number) return null;
458
- _instance = new PhoneChannel(cfg);
417
+ const { twilio, phone } = getConfig().channels;
418
+ if (!phone.enabled) return null;
419
+ if (!twilio.sid || !twilio.secret || !phone.from_number) return null;
420
+ _instance = new PhoneChannel(phone, twilio);
459
421
  return _instance;
460
422
  }
461
423
 
462
- /** Used by the place_call MCP tool and CLI test entrypoint. Null if not running. */
463
424
  export function getPhoneChannel(): PhoneChannel | null {
464
425
  return _instance;
465
426
  }
@@ -47,8 +47,8 @@ export function buildPhoneTools(ctx: ToolContextOpts): PhoneToolDefinition[] {
47
47
  handler: async (args) => {
48
48
  const text = String(args.text || "").slice(0, 1000);
49
49
  const tg = getChannel("telegram");
50
- if (!tg || !tg.sendMessage) return "telegram unavailable";
51
- await tg.sendMessage(`[Phone] ${text}`);
50
+ if (!tg) return "telegram unavailable";
51
+ await tg.deliver({ text: `[Phone] ${text}` });
52
52
  return "sent";
53
53
  },
54
54
  },
@@ -2,8 +2,7 @@ import { App } from "@slack/bolt";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { createHash } from "crypto";
5
- import { createChatEngine } from "../chat/engine";
6
- import type { Channel, ChatState, Attachment, AttachmentType } from "../types";
5
+ import type { Channel, ChatState, Attachment, AttachmentType, Outbound, Recipient } from "../types";
7
6
  import { getConfig, updateRawConfig, resetConfig } from "../utils/config";
8
7
  import { relativeTime } from "../utils/format";
9
8
  import { runMigrations } from "../db/migrate";
@@ -13,6 +12,7 @@ import { getMcpServers } from "../mcp";
13
12
  import { getNiaHome, getPaths } from "../utils/paths";
14
13
  import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
15
14
  import { resolveWatchBehavior } from "../utils/watches";
15
+ import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
16
16
 
17
17
  /** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
18
18
  function cleanSentinel(text: string): string {
@@ -20,47 +20,39 @@ function cleanSentinel(text: string): string {
20
20
  }
21
21
 
22
22
  class SlackChannel implements Channel {
23
- name = "slack";
23
+ name = "slack" as const;
24
24
  private app: App | null = null;
25
25
  private dmUserId: string | null = null;
26
26
  /** Timestamps of messages Nia posted proactively (used to detect replies to our own messages) */
27
27
  private outboundTs = new Set<string>();
28
28
 
29
- async sendMessage(text: string): Promise<void> {
29
+ async deliver(out: Outbound): Promise<void> {
30
30
  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
- }
31
+ const dest = this.resolveDest(out.to);
32
+
33
+ if (out.media) {
34
+ const buffer = Buffer.from(out.media.data);
35
+ const filename = out.media.filename || `file.${out.media.mimeType.split("/")[1] || "bin"}`;
36
+ await this.app.client.filesUploadV2({
37
+ channel_id: dest.channel,
38
+ file: buffer,
39
+ filename,
40
+ ...(dest.threadTs ? { thread_ts: dest.threadTs } : {}),
41
+ } as any);
42
+ }
36
43
 
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);
44
+ if (out.text) {
45
+ const opts: Record<string, unknown> = { channel: dest.channel, text: out.text };
46
+ if (dest.threadTs) opts.thread_ts = dest.threadTs;
47
+ const result = await this.app.client.chat.postMessage(opts as any);
48
+ if (result.ts) this.outboundTs.add(result.ts);
49
+ }
43
50
  }
44
51
 
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
- });
54
- }
55
-
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);
52
+ private resolveDest(to: Recipient | undefined): { channel: string; threadTs?: string } {
53
+ if (to?.kind === "thread") return { channel: to.channelId, threadTs: to.threadTs };
54
+ if (!this.dmUserId) throw new Error("No Slack recipient — set dm_user_id in config");
55
+ return { channel: this.dmUserId };
64
56
  }
65
57
 
66
58
  async start(): Promise<void> {
@@ -87,62 +79,46 @@ class SlackChannel implements Channel {
87
79
  return name;
88
80
  }
89
81
 
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
82
  interface SlackContext {
99
83
  slackChannelId?: string;
100
84
  slackThreadTs?: string;
101
85
  }
102
86
 
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;
87
+ function roomPrefix(k: string): string {
88
+ return `slack-${k}`;
120
89
  }
121
90
 
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);
91
+ function roomName(k: string, index: number): string {
92
+ return `slack-${k}-${index}`;
93
+ }
134
94
 
135
- log.info({ key, room }, "slack: creating chat engine");
136
- const engine = await createChatEngine({
137
- room,
95
+ function buildEngineOpts(watchBehavior?: { channel: string; behavior: string }, slackCtx?: SlackContext) {
96
+ return (room: string) => ({
138
97
  channel: "slack",
139
- resume: false,
140
98
  mcpServers: getMcpServers({ channel: "slack", room, ...slackCtx }),
141
99
  watchBehavior,
142
100
  });
143
- const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
101
+ }
102
+
103
+ async function getState(
104
+ key: string,
105
+ watchBehavior?: { channel: string; behavior: string },
106
+ slackCtx?: SlackContext,
107
+ ): Promise<ChatState> {
108
+ let state = chats.get(key);
109
+ if (state) return state;
110
+ state = await openChatEngine(roomPrefix(key), buildEngineOpts(watchBehavior, slackCtx));
111
+ chats.set(key, state);
112
+ return state;
113
+ }
114
+
115
+ async function restartChat(
116
+ key: string,
117
+ watchBehavior?: { channel: string; behavior: string },
118
+ slackCtx?: SlackContext,
119
+ ): Promise<ChatState> {
120
+ const state = await rotateRoom(roomPrefix(key), chats.get(key), buildEngineOpts(watchBehavior, slackCtx));
144
121
  chats.set(key, state);
145
- log.info({ key, room, activeSessions: chats.size }, "slack: engine ready");
146
122
  return state;
147
123
  }
148
124
 
@@ -152,9 +128,7 @@ class SlackChannel implements Channel {
152
128
  fn().catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
153
129
  return;
154
130
  }
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"));
131
+ chainLock(state, fn);
158
132
  }
159
133
 
160
134
  const self = this;
@@ -364,7 +338,7 @@ class SlackChannel implements Channel {
364
338
 
365
339
  try {
366
340
  const data = await downloadSlackFile(file.url_private_download);
367
- const error = validateAttachment(data, mime);
341
+ const error = validateAttachment(data);
368
342
  if (error) {
369
343
  log.warn({ file: file.name, error }, "skipping slack attachment");
370
344
  continue;
@@ -383,7 +357,13 @@ class SlackChannel implements Channel {
383
357
  const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
384
358
  fileIndex.set(indexedKey, entry);
385
359
 
386
- attachments.push({ type: attType, data: finalData, mimeType: finalMime, filename: file.name, sourcePath: diskPath });
360
+ attachments.push({
361
+ type: attType,
362
+ data: finalData,
363
+ mimeType: finalMime,
364
+ filename: file.name,
365
+ sourcePath: diskPath,
366
+ });
387
367
  } catch (err) {
388
368
  log.warn({ err, file: file.name }, "failed to download slack file");
389
369
  }