niahere 0.3.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared chat-engine lifecycle helpers used by the message-driven
3
+ * channels (telegram, slack, sms, whatsapp). Each channel keeps its
4
+ * own `Map<senderKey, ChatState>`; these helpers cover the bits that
5
+ * were copy-pasted between them:
6
+ *
7
+ * - resolve the latest room index for a prefix and open a fresh engine
8
+ * - rotate to a new room (for `/reset` / `/new` / `/restart`), persisting
9
+ * a placeholder session so the new index survives daemon restarts
10
+ * - chain work onto a per-sender lock so messages from the same sender
11
+ * don't race
12
+ *
13
+ * The caller supplies a builder lambda for the EngineOptions so channels
14
+ * that need room-aware fields (e.g. Slack's per-room `mcpServers`) can
15
+ * compute them with the resolved room name. Channels with static options
16
+ * just ignore the `room` argument in their builder.
17
+ */
18
+ import { createChatEngine } from "../../chat/engine";
19
+ import { Session } from "../../db/models";
20
+ import { log } from "../../utils/log";
21
+ import type { ChatState } from "../../types";
22
+ import type { EngineOptions } from "../../types/engine";
23
+
24
+ type EngineFactory = (room: string) => Omit<EngineOptions, "room" | "resume">;
25
+
26
+ /** Open (or resume) a chat engine for `prefix`. The resulting ChatState is the caller's to cache. */
27
+ export async function openChatEngine(prefix: string, buildOpts: EngineFactory): Promise<ChatState> {
28
+ const roomIndex = await Session.getLatestRoomIndex(prefix);
29
+ const room = `${prefix}-${roomIndex}`;
30
+ const opts = buildOpts(room);
31
+ log.info({ channel: opts.channel, room }, "chat-session: opening engine");
32
+ const engine = await createChatEngine({ ...opts, room, resume: true });
33
+ return { engine, roomIndex, lock: Promise.resolve() };
34
+ }
35
+
36
+ /** Rotate to a fresh room. Closes `prev` if supplied, persists a placeholder Session so the index survives restarts. */
37
+ export async function rotateRoom(
38
+ prefix: string,
39
+ prev: ChatState | undefined,
40
+ buildOpts: EngineFactory,
41
+ ): Promise<ChatState> {
42
+ if (prev) prev.engine.close();
43
+ const prevIdx = await Session.getLatestRoomIndex(prefix);
44
+ const roomIndex = prevIdx + 1;
45
+ const room = `${prefix}-${roomIndex}`;
46
+ await Session.create(`placeholder-${room}`, room);
47
+ const opts = buildOpts(room);
48
+ log.info({ channel: opts.channel, room }, "chat-session: rotated room");
49
+ const engine = await createChatEngine({ ...opts, room, resume: false });
50
+ return { engine, roomIndex, lock: Promise.resolve() };
51
+ }
52
+
53
+ /** Serialize `fn` onto `state.lock`. Both success and failure forward so a thrown error doesn't poison the chain. */
54
+ export function chainLock(state: ChatState, fn: () => Promise<void>): void {
55
+ state.lock = state.lock.then(fn, fn);
56
+ }
@@ -22,7 +22,7 @@
22
22
  * - consult.ts — escape hatch to Claude for memory-aware reasoning
23
23
  */
24
24
  import type { ServerWebSocket } from "bun";
25
- import type { Channel, PhoneConfig, TwilioConfig } from "../../types";
25
+ import type { Channel, Outbound, PhoneConfig, TwilioConfig } from "../../types";
26
26
  import { getConfig } from "../../utils/config";
27
27
  import { log } from "../../utils/log";
28
28
  import { getChannel } from "../registry";
@@ -66,7 +66,7 @@ const HARD_MAX_MINUTES = 30;
66
66
  const WS_PATH = "/twilio/voice/stream";
67
67
 
68
68
  class PhoneChannel implements Channel {
69
- name = "phone";
69
+ name = "phone" as const;
70
70
  private readonly phone: PhoneConfig;
71
71
  private readonly twilio: TwilioConfig;
72
72
  private readonly pending = new Map<string, PendingCall>();
@@ -92,7 +92,7 @@ class PhoneChannel implements Channel {
92
92
 
93
93
  server.registerHttp("/twilio/voice/incoming", (req, ctx) => this.handleIncoming(req, ctx.params));
94
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), {
95
+ server.registerHttp("/twilio/voice/status", (_req, ctx) => this.handleStatus(ctx.params), {
96
96
  dedupOn: "CallSid",
97
97
  });
98
98
 
@@ -127,6 +127,15 @@ class PhoneChannel implements Channel {
127
127
  // it running here would block SMS/WhatsApp if they're also bound to it.
128
128
  }
129
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");
137
+ }
138
+
130
139
  // --- Outbound entrypoint (used by the place_call MCP tool and CLI test) ---
131
140
 
132
141
  async placeCall(opts: {
@@ -186,7 +195,7 @@ class PhoneChannel implements Channel {
186
195
  if (!allowed) {
187
196
  log.warn({ from, callSid }, "phone: rejecting unauthorized caller");
188
197
  getChannel("telegram")
189
- ?.sendMessage?.(`Phone: rejected call from ${from} (CallSid ${callSid})`)
198
+ ?.deliver({ text: `Phone: rejected call from ${from} (CallSid ${callSid})` })
190
199
  .catch(() => {});
191
200
  return twimlResponse(sayAndHangupTwiML("Sorry, this line is not currently accepting calls. Goodbye."));
192
201
  }
@@ -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
  }
@@ -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 };
@@ -2,15 +2,15 @@ import { Bot, InputFile } from "grammy";
2
2
  import { createHash } from "crypto";
3
3
  import { mkdirSync, writeFileSync } from "fs";
4
4
  import { join } from "path";
5
- import { createChatEngine } from "../chat/engine";
6
- import type { Channel, ChatState, Attachment } from "../types";
5
+ import type { Channel, ChatState, Attachment, Outbound } from "../types";
7
6
  import { getConfig, updateRawConfig } from "../utils/config";
8
7
  import { runMigrations } from "../db/migrate";
9
- import { Session, Message } from "../db/models";
8
+ import { Message } from "../db/models";
10
9
  import { log } from "../utils/log";
11
10
  import { getMcpServers } from "../mcp";
12
11
  import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
13
12
  import { getNiaHome } from "../utils/paths";
13
+ import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
14
14
 
15
15
  function safeExtension(filename?: string): string {
16
16
  const ext = filename?.split(".").pop();
@@ -23,27 +23,27 @@ function cacheExtension(filename: string | undefined, mimeType: string): string
23
23
  }
24
24
 
25
25
  class TelegramChannel implements Channel {
26
- name = "telegram";
26
+ name = "telegram" as const;
27
27
  private bot: Bot | null = null;
28
28
  private outboundChatId: number | null = null;
29
29
 
30
- async sendMessage(text: string): Promise<void> {
30
+ async deliver(out: Outbound): Promise<void> {
31
31
  if (!this.bot) throw new Error("Telegram not started");
32
32
  const chatId = this.outboundChatId;
33
33
  if (!chatId) throw new Error("No outbound chat ID registered");
34
- await this.bot.api.sendMessage(chatId, text);
35
- }
36
-
37
- async sendMedia(data: Buffer, mimeType: string, filename?: string): Promise<void> {
38
- if (!this.bot) throw new Error("Telegram not started");
39
- const chatId = this.outboundChatId;
40
- if (!chatId) throw new Error("No outbound chat ID registered");
41
-
42
- const file = new InputFile(data, filename);
43
- if (mimeType.startsWith("image/")) {
44
- await this.bot.api.sendPhoto(chatId, file);
45
- } else {
46
- await this.bot.api.sendDocument(chatId, file);
34
+ // Telegram has no native threading; thread recipients fall back to the
35
+ // configured DM chat (the same place we'd send to for `owner`).
36
+
37
+ if (out.media) {
38
+ const file = new InputFile(Buffer.from(out.media.data), out.media.filename);
39
+ if (out.media.mimeType.startsWith("image/")) {
40
+ await this.bot.api.sendPhoto(chatId, file);
41
+ } else {
42
+ await this.bot.api.sendDocument(chatId, file);
43
+ }
44
+ }
45
+ if (out.text) {
46
+ await this.bot.api.sendMessage(chatId, out.text);
47
47
  }
48
48
  }
49
49
 
@@ -57,7 +57,13 @@ class TelegramChannel implements Channel {
57
57
  return Buffer.from(await resp.arrayBuffer());
58
58
  }
59
59
 
60
- private cacheAttachment(chatId: number, roomIndex: number, data: Buffer, mimeType: string, filename?: string): string {
60
+ private cacheAttachment(
61
+ chatId: number,
62
+ roomIndex: number,
63
+ data: Buffer,
64
+ mimeType: string,
65
+ filename?: string,
66
+ ): string {
61
67
  const scope = `telegram-${chatId}-${roomIndex}`;
62
68
  const dir = join(getNiaHome(), "tmp", "attachments", scope);
63
69
  mkdirSync(dir, { recursive: true });
@@ -78,44 +84,23 @@ class TelegramChannel implements Channel {
78
84
 
79
85
  const chats = new Map<number, ChatState>();
80
86
 
81
- function roomPrefix(chatId: number): string {
87
+ function keyOf(chatId: number): string {
82
88
  return `tg-${chatId}`;
83
89
  }
84
90
 
85
- function roomName(chatId: number, index: number): string {
86
- return `tg-${chatId}-${index}`;
87
- }
88
-
89
91
  async function getState(chatId: number): Promise<ChatState> {
90
92
  let state = chats.get(chatId);
91
- if (!state) {
92
- const prefix = roomPrefix(chatId);
93
- const idx = await Session.getLatestRoomIndex(prefix);
94
- const room = roomName(chatId, idx);
95
- log.info({ chatId, room }, "telegram: creating chat engine");
96
- const engine = await createChatEngine({ room, channel: "telegram", resume: true, mcpServers: getMcpServers() });
97
- state = { engine, roomIndex: idx, lock: Promise.resolve() };
98
- chats.set(chatId, state);
99
- log.info({ chatId, room, activeSessions: chats.size }, "telegram: engine ready");
100
- }
93
+ if (state) return state;
94
+ state = await openChatEngine(keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
95
+ chats.set(chatId, state);
101
96
  return state;
102
97
  }
103
98
 
104
99
  async function restartChat(chatId: number): Promise<ChatState> {
105
- const old = chats.get(chatId);
106
- if (old) old.engine.close();
107
-
108
- const prefix = roomPrefix(chatId);
109
- const prevIdx = await Session.getLatestRoomIndex(prefix);
110
- const newIdx = prevIdx + 1;
111
- const room = roomName(chatId, newIdx);
112
-
113
- // Persist a placeholder session immediately so the room index survives
114
- // daemon restarts (otherwise getState falls back to the old room).
115
- await Session.create(`placeholder-${room}`, room);
116
-
117
- const engine = await createChatEngine({ room, channel: "telegram", resume: false, mcpServers: getMcpServers() });
118
- const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
100
+ const state = await rotateRoom(keyOf(chatId), chats.get(chatId), () => ({
101
+ channel: "telegram",
102
+ mcpServers: getMcpServers(),
103
+ }));
119
104
  chats.set(chatId, state);
120
105
  return state;
121
106
  }
@@ -126,9 +111,7 @@ class TelegramChannel implements Channel {
126
111
  fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
127
112
  return;
128
113
  }
129
- const queued = state.lock !== Promise.resolve();
130
- if (queued) log.debug({ chatId }, "telegram: message queued behind active lock");
131
- state.lock = state.lock.then(fn, fn);
114
+ chainLock(state, fn);
132
115
  }
133
116
 
134
117
  const isOpen = config.channels.telegram.open;
@@ -151,7 +134,10 @@ class TelegramChannel implements Channel {
151
134
 
152
135
  async function processMessage(ctx: any, state: ChatState, text: string, attachments?: Attachment[]): Promise<void> {
153
136
  const chatId = ctx.chatId;
154
- log.info({ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 }, "telegram message received");
137
+ log.info(
138
+ { chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 },
139
+ "telegram message received",
140
+ );
155
141
 
156
142
  // Show typing indicator throughout
157
143
  const typingInterval = setInterval(() => {
@@ -251,7 +237,7 @@ class TelegramChannel implements Channel {
251
237
  const mime = doc.mime_type || "application/octet-stream";
252
238
  const attType = classifyMime(mime) || "file";
253
239
  let data = await self.downloadFile(doc.file_id);
254
- const error = validateAttachment(data, mime);
240
+ const error = validateAttachment(data);
255
241
  if (error) {
256
242
  await ctx.reply(error);
257
243
  return;
@@ -263,7 +249,13 @@ class TelegramChannel implements Channel {
263
249
  finalMime = prepared.mimeType;
264
250
  }
265
251
  const sourcePath = self.cacheAttachment(ctx.chatId, state.roomIndex, data, finalMime, doc.file_name);
266
- const attachment: Attachment = { type: attType, data, mimeType: finalMime, filename: doc.file_name, sourcePath };
252
+ const attachment: Attachment = {
253
+ type: attType,
254
+ data,
255
+ mimeType: finalMime,
256
+ filename: doc.file_name,
257
+ sourcePath,
258
+ };
267
259
  const caption = ctx.message.caption || (attType === "image" ? "What's in this image?" : "Here's a file.");
268
260
  await processMessage(ctx, state, caption, [attachment]);
269
261
  } catch (err) {