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,16 +1,16 @@
1
- import { Bot, InputFile } from "grammy";
1
+ import { Bot, type Context, 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,50 +23,11 @@ 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
-
30
- async sendMessage(text: string): Promise<void> {
31
- if (!this.bot) throw new Error("Telegram not started");
32
- const chatId = this.outboundChatId;
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);
47
- }
48
- }
49
-
50
- private async downloadFile(fileId: string): Promise<Buffer> {
51
- if (!this.bot) throw new Error("Telegram not started");
52
- const file = await this.bot.api.getFile(fileId);
53
- const token = getConfig().channels.telegram.bot_token!;
54
- const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
55
- const resp = await fetch(url);
56
- if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
57
- return Buffer.from(await resp.arrayBuffer());
58
- }
59
-
60
- private cacheAttachment(chatId: number, roomIndex: number, data: Buffer, mimeType: string, filename?: string): string {
61
- const scope = `telegram-${chatId}-${roomIndex}`;
62
- const dir = join(getNiaHome(), "tmp", "attachments", scope);
63
- mkdirSync(dir, { recursive: true });
64
- const ext = cacheExtension(filename, mimeType);
65
- const hash = createHash("sha256").update(data).digest("hex").slice(0, 16);
66
- const path = join(dir, `${hash}.${ext}`);
67
- writeFileSync(path, data);
68
- return path;
69
- }
29
+ private isOpen = false;
30
+ private readonly chats = new Map<number, ChatState>();
70
31
 
71
32
  async start(): Promise<void> {
72
33
  const config = getConfig();
@@ -75,216 +36,256 @@ class TelegramChannel implements Channel {
75
36
  await runMigrations();
76
37
 
77
38
  this.outboundChatId = config.channels.telegram.chat_id;
39
+ this.isOpen = config.channels.telegram.open;
40
+
41
+ const bot = new Bot(token);
42
+ this.bot = bot;
78
43
 
79
- const chats = new Map<number, ChatState>();
44
+ bot.command("start", (ctx) => this.handleStart(ctx));
45
+ bot.command(["restart", "new"], (ctx) => this.handleRestart(ctx));
46
+ bot.on("message:text", (ctx) => this.handleText(ctx));
47
+ bot.on("message:photo", (ctx) => this.handlePhoto(ctx));
48
+ bot.on("message:document", (ctx) => this.handleDocument(ctx));
80
49
 
81
- function roomPrefix(chatId: number): string {
82
- return `tg-${chatId}`;
83
- }
50
+ bot.start({ onStart: () => log.info("telegram bot polling started") });
51
+ }
84
52
 
85
- function roomName(chatId: number, index: number): string {
86
- return `tg-${chatId}-${index}`;
53
+ async stop(): Promise<void> {
54
+ if (this.bot) {
55
+ this.bot.stop();
56
+ this.bot = null;
87
57
  }
58
+ }
88
59
 
89
- async function getState(chatId: number): Promise<ChatState> {
90
- 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");
60
+ async deliver(out: Outbound): Promise<void> {
61
+ if (!this.bot) throw new Error("Telegram not started");
62
+ const chatId = this.outboundChatId;
63
+ if (!chatId) throw new Error("No outbound chat ID registered");
64
+ // Telegram has no native threading; thread recipients fall back to the
65
+ // configured DM chat (the same place we'd send to for `owner`).
66
+
67
+ if (out.media) {
68
+ const file = new InputFile(Buffer.from(out.media.data), out.media.filename);
69
+ if (out.media.mimeType.startsWith("image/")) {
70
+ await this.bot.api.sendPhoto(chatId, file);
71
+ } else {
72
+ await this.bot.api.sendDocument(chatId, file);
100
73
  }
101
- return state;
102
74
  }
75
+ if (out.text) {
76
+ await this.bot.api.sendMessage(chatId, out.text);
77
+ }
78
+ }
103
79
 
104
- async function restartChat(chatId: number): Promise<ChatState> {
105
- const old = chats.get(chatId);
106
- if (old) old.engine.close();
80
+ // --- Inbound handlers ---
107
81
 
108
- const prefix = roomPrefix(chatId);
109
- const prevIdx = await Session.getLatestRoomIndex(prefix);
110
- const newIdx = prevIdx + 1;
111
- const room = roomName(chatId, newIdx);
82
+ private async handleStart(ctx: Context): Promise<void> {
83
+ if (!ctx.chatId || !this.gate(ctx)) return;
84
+ this.registerOutbound(ctx.chatId);
85
+ const state = await this.getState(ctx.chatId);
86
+ this.withLock(ctx.chatId, () => this.processMessage(ctx, state, "hi"));
87
+ }
112
88
 
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);
89
+ private async handleRestart(ctx: Context): Promise<void> {
90
+ if (!ctx.chatId || !this.gate(ctx)) return;
91
+ this.registerOutbound(ctx.chatId);
92
+ const state = await this.restartChat(ctx.chatId);
93
+ log.info({ chatId: ctx.chatId, room: `tg-${ctx.chatId}-${state.roomIndex}` }, "new telegram conversation");
94
+ await ctx.reply("New conversation started.");
95
+ }
116
96
 
117
- const engine = await createChatEngine({ room, channel: "telegram", resume: false, mcpServers: getMcpServers() });
118
- const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
119
- chats.set(chatId, state);
120
- return state;
121
- }
97
+ private async handleText(ctx: Context): Promise<void> {
98
+ if (!ctx.chatId || !ctx.message?.text || !this.gate(ctx)) return;
99
+ this.registerOutbound(ctx.chatId);
100
+ const state = await this.getState(ctx.chatId);
101
+ const text = ctx.message.text;
102
+ this.withLock(ctx.chatId, () => this.processMessage(ctx, state, text));
103
+ }
122
104
 
123
- function withLock(chatId: number, fn: () => Promise<void>): void {
124
- const state = chats.get(chatId);
125
- if (!state) {
126
- fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
127
- return;
105
+ private async handlePhoto(ctx: Context): Promise<void> {
106
+ if (!ctx.chatId || !ctx.message?.photo || !this.gate(ctx)) return;
107
+ this.registerOutbound(ctx.chatId);
108
+ const state = await this.getState(ctx.chatId);
109
+ const chatId = ctx.chatId;
110
+ this.withLock(chatId, async () => {
111
+ try {
112
+ const photos = ctx.message!.photo!;
113
+ const largest = photos[photos.length - 1];
114
+ const raw = await this.downloadFile(largest.file_id);
115
+ const { data, mimeType } = await prepareImage(raw, "image/jpeg");
116
+ const attachment: Attachment = { type: "image", data, mimeType };
117
+ const caption = ctx.message!.caption || "What's in this image?";
118
+ await this.processMessage(ctx, state, caption, [attachment]);
119
+ } catch (err) {
120
+ log.error({ err, chatId }, "failed to process photo");
121
+ await ctx.reply("Failed to process image.").catch(() => {});
128
122
  }
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);
132
- }
133
-
134
- const isOpen = config.channels.telegram.open;
135
- const self = this;
136
-
137
- function registerOutbound(chatId: number): void {
138
- if (self.outboundChatId) return;
139
- self.outboundChatId = chatId;
140
- updateRawConfig({ channels: { telegram: { chat_id: chatId } } });
141
- log.info({ chatId }, "auto-registered outbound chat ID");
142
- }
123
+ });
124
+ }
143
125
 
144
- function isAllowed(chatId: number): boolean {
145
- if (isOpen) return true;
146
- if (!self.outboundChatId) return true; // first user always allowed (gets registered)
147
- return chatId === self.outboundChatId;
148
- }
126
+ private async handleDocument(ctx: Context): Promise<void> {
127
+ if (!ctx.chatId || !ctx.message?.document || !this.gate(ctx)) return;
128
+ this.registerOutbound(ctx.chatId);
129
+ const state = await this.getState(ctx.chatId);
130
+ const chatId = ctx.chatId;
131
+ this.withLock(chatId, async () => {
132
+ try {
133
+ const doc = ctx.message!.document!;
134
+ const mime = doc.mime_type || "application/octet-stream";
135
+ const attType = classifyMime(mime) || "file";
136
+ let data = await this.downloadFile(doc.file_id);
137
+ const error = validateAttachment(data);
138
+ if (error) {
139
+ await ctx.reply(error);
140
+ return;
141
+ }
142
+ let finalMime = mime;
143
+ if (attType === "image") {
144
+ const prepared = await prepareImage(data, mime);
145
+ data = prepared.data;
146
+ finalMime = prepared.mimeType;
147
+ }
148
+ const sourcePath = this.cacheAttachment(chatId, state.roomIndex, data, finalMime, doc.file_name);
149
+ const attachment: Attachment = {
150
+ type: attType,
151
+ data,
152
+ mimeType: finalMime,
153
+ filename: doc.file_name,
154
+ sourcePath,
155
+ };
156
+ const caption = ctx.message!.caption || (attType === "image" ? "What's in this image?" : "Here's a file.");
157
+ await this.processMessage(ctx, state, caption, [attachment]);
158
+ } catch (err) {
159
+ log.error({ err, chatId }, "failed to process document");
160
+ await ctx.reply("Failed to process document.").catch(() => {});
161
+ }
162
+ });
163
+ }
149
164
 
150
- const bot = new Bot(token);
165
+ // --- Core message loop ---
151
166
 
152
- async function processMessage(ctx: any, state: ChatState, text: string, attachments?: Attachment[]): Promise<void> {
153
- const chatId = ctx.chatId;
154
- log.info({ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 }, "telegram message received");
167
+ private async processMessage(
168
+ ctx: Context,
169
+ state: ChatState,
170
+ text: string,
171
+ attachments?: Attachment[],
172
+ ): Promise<void> {
173
+ if (!this.bot) return;
174
+ const bot = this.bot;
175
+ const chatId = ctx.chatId!;
176
+ log.info({ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 }, "telegram message received");
155
177
 
156
- // Show typing indicator throughout
157
- const typingInterval = setInterval(() => {
158
- bot.api.sendChatAction(chatId, "typing").catch(() => {});
159
- }, 4000);
178
+ const typingInterval = setInterval(() => {
160
179
  bot.api.sendChatAction(chatId, "typing").catch(() => {});
180
+ }, 4000);
181
+ bot.api.sendChatAction(chatId, "typing").catch(() => {});
161
182
 
183
+ try {
184
+ const { result, messageId } = await state.engine.send(text, {}, attachments);
185
+ const reply = result.trim() || "(no response)";
162
186
  try {
163
- const { result, messageId } = await state.engine.send(text, {}, attachments);
164
-
165
- const reply = result.trim() || "(no response)";
166
187
  try {
167
- try {
168
- await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
169
- } catch {
170
- await bot.api.sendMessage(chatId, reply);
171
- }
172
- if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
173
- log.info({ chatId, chars: result.length }, "telegram reply sent");
174
- } catch (sendErr) {
175
- if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
176
- throw sendErr;
188
+ await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
189
+ } catch {
190
+ await bot.api.sendMessage(chatId, reply);
177
191
  }
178
- } catch (err) {
179
- const errText = err instanceof Error ? err.message : String(err);
180
- log.error({ err, chatId }, "telegram message processing failed");
181
- await bot.api.sendMessage(chatId, `[error] ${errText}`).catch(() => {});
182
- } finally {
183
- clearInterval(typingInterval);
192
+ if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
193
+ log.info({ chatId, chars: result.length }, "telegram reply sent");
194
+ } catch (sendErr) {
195
+ if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
196
+ throw sendErr;
184
197
  }
198
+ } catch (err) {
199
+ const errText = err instanceof Error ? err.message : String(err);
200
+ log.error({ err, chatId }, "telegram message processing failed");
201
+ await bot.api.sendMessage(chatId, `[error] ${errText}`).catch(() => {});
202
+ } finally {
203
+ clearInterval(typingInterval);
185
204
  }
205
+ }
186
206
 
187
- bot.command("start", async (ctx) => {
188
- if (!isAllowed(ctx.chatId)) {
189
- await ctx.reply("Unauthorized.");
190
- return;
191
- }
192
- registerOutbound(ctx.chatId);
193
- const state = await getState(ctx.chatId);
194
- withLock(ctx.chatId, () => processMessage(ctx, state, "hi"));
195
- });
207
+ // --- Session / state helpers ---
196
208
 
197
- bot.command(["restart", "new"], async (ctx) => {
198
- if (!isAllowed(ctx.chatId)) {
199
- await ctx.reply("Unauthorized.");
200
- return;
201
- }
202
- registerOutbound(ctx.chatId);
203
- const state = await restartChat(ctx.chatId);
204
- log.info({ chatId: ctx.chatId, room: `tg-${ctx.chatId}-${state.roomIndex}` }, "new telegram conversation");
205
- await ctx.reply("New conversation started.");
206
- });
209
+ private async getState(chatId: number): Promise<ChatState> {
210
+ let state = this.chats.get(chatId);
211
+ if (state) return state;
212
+ state = await openChatEngine(this.keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
213
+ this.chats.set(chatId, state);
214
+ return state;
215
+ }
207
216
 
208
- bot.on("message:text", async (ctx) => {
209
- if (!isAllowed(ctx.chatId)) {
210
- await ctx.reply("Unauthorized.");
211
- return;
212
- }
213
- registerOutbound(ctx.chatId);
214
- const state = await getState(ctx.chatId);
215
- withLock(ctx.chatId, () => processMessage(ctx, state, ctx.message.text));
216
- });
217
+ private async restartChat(chatId: number): Promise<ChatState> {
218
+ const state = await rotateRoom(this.keyOf(chatId), this.chats.get(chatId), () => ({
219
+ channel: "telegram",
220
+ mcpServers: getMcpServers(),
221
+ }));
222
+ this.chats.set(chatId, state);
223
+ return state;
224
+ }
217
225
 
218
- bot.on("message:photo", async (ctx) => {
219
- if (!isAllowed(ctx.chatId)) {
220
- await ctx.reply("Unauthorized.");
221
- return;
222
- }
223
- registerOutbound(ctx.chatId);
224
- const state = await getState(ctx.chatId);
225
- withLock(ctx.chatId, async () => {
226
- try {
227
- const photos = ctx.message.photo;
228
- const largest = photos[photos.length - 1];
229
- const raw = await self.downloadFile(largest.file_id);
230
- const { data, mimeType } = await prepareImage(raw, "image/jpeg");
231
- const attachment: Attachment = { type: "image", data, mimeType };
232
- const caption = ctx.message.caption || "What's in this image?";
233
- await processMessage(ctx, state, caption, [attachment]);
234
- } catch (err) {
235
- log.error({ err, chatId: ctx.chatId }, "failed to process photo");
236
- await ctx.reply("Failed to process image.").catch(() => {});
237
- }
238
- });
239
- });
226
+ private withLock(chatId: number, fn: () => Promise<void>): void {
227
+ const state = this.chats.get(chatId);
228
+ if (!state) {
229
+ fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
230
+ return;
231
+ }
232
+ chainLock(state, fn);
233
+ }
240
234
 
241
- bot.on("message:document", async (ctx) => {
242
- if (!isAllowed(ctx.chatId)) {
243
- await ctx.reply("Unauthorized.");
244
- return;
245
- }
246
- registerOutbound(ctx.chatId);
247
- const state = await getState(ctx.chatId);
248
- withLock(ctx.chatId, async () => {
249
- try {
250
- const doc = ctx.message.document;
251
- const mime = doc.mime_type || "application/octet-stream";
252
- const attType = classifyMime(mime) || "file";
253
- let data = await self.downloadFile(doc.file_id);
254
- const error = validateAttachment(data, mime);
255
- if (error) {
256
- await ctx.reply(error);
257
- return;
258
- }
259
- let finalMime = mime;
260
- if (attType === "image") {
261
- const prepared = await prepareImage(data, mime);
262
- data = prepared.data;
263
- finalMime = prepared.mimeType;
264
- }
265
- 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 };
267
- const caption = ctx.message.caption || (attType === "image" ? "What's in this image?" : "Here's a file.");
268
- await processMessage(ctx, state, caption, [attachment]);
269
- } catch (err) {
270
- log.error({ err, chatId: ctx.chatId }, "failed to process document");
271
- await ctx.reply("Failed to process document.").catch(() => {});
272
- }
273
- });
274
- });
235
+ private keyOf(chatId: number): string {
236
+ return `tg-${chatId}`;
237
+ }
275
238
 
276
- bot.start({
277
- onStart: () => log.info("telegram bot polling started"),
278
- });
239
+ // --- Authorization / outbound binding ---
279
240
 
280
- this.bot = bot;
241
+ /** Authorization check + auto-reply with "Unauthorized." if not allowed. Returns true to continue. */
242
+ private gate(ctx: Context): boolean {
243
+ if (!ctx.chatId) return false;
244
+ if (this.isAllowed(ctx.chatId)) return true;
245
+ ctx.reply("Unauthorized.").catch(() => {});
246
+ return false;
281
247
  }
282
248
 
283
- async stop(): Promise<void> {
284
- if (this.bot) {
285
- this.bot.stop();
286
- this.bot = null;
287
- }
249
+ private isAllowed(chatId: number): boolean {
250
+ if (this.isOpen) return true;
251
+ if (!this.outboundChatId) return true; // first user always allowed (gets registered)
252
+ return chatId === this.outboundChatId;
253
+ }
254
+
255
+ private registerOutbound(chatId: number): void {
256
+ if (this.outboundChatId) return;
257
+ this.outboundChatId = chatId;
258
+ updateRawConfig({ channels: { telegram: { chat_id: chatId } } });
259
+ log.info({ chatId }, "auto-registered outbound chat ID");
260
+ }
261
+
262
+ // --- File helpers ---
263
+
264
+ private async downloadFile(fileId: string): Promise<Buffer> {
265
+ if (!this.bot) throw new Error("Telegram not started");
266
+ const file = await this.bot.api.getFile(fileId);
267
+ const token = getConfig().channels.telegram.bot_token!;
268
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
269
+ const resp = await fetch(url);
270
+ if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
271
+ return Buffer.from(await resp.arrayBuffer());
272
+ }
273
+
274
+ private cacheAttachment(
275
+ chatId: number,
276
+ roomIndex: number,
277
+ data: Buffer,
278
+ mimeType: string,
279
+ filename?: string,
280
+ ): string {
281
+ const scope = `telegram-${chatId}-${roomIndex}`;
282
+ const dir = join(getNiaHome(), "tmp", "attachments", scope);
283
+ mkdirSync(dir, { recursive: true });
284
+ const ext = cacheExtension(filename, mimeType);
285
+ const hash = createHash("sha256").update(data).digest("hex").slice(0, 16);
286
+ const path = join(dir, `${hash}.${ext}`);
287
+ writeFileSync(path, data);
288
+ return path;
288
289
  }
289
290
  }
290
291