niahere 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/channels/common/chat-session.ts +56 -0
- package/src/channels/phone/index.ts +13 -4
- package/src/channels/phone/tools.ts +2 -2
- package/src/channels/slack/attachments.ts +142 -0
- package/src/channels/slack/watch.ts +73 -0
- package/src/channels/slack.ts +63 -267
- package/src/channels/sms.ts +25 -35
- package/src/channels/telegram.ts +224 -223
- package/src/channels/whatsapp.ts +90 -122
- package/src/chat/identity.ts +1 -2
- package/src/cli/phone.ts +9 -6
- package/src/commands/init.ts +17 -14
- package/src/commands/service.ts +26 -7
- package/src/mcp/tools/index.ts +9 -0
- package/src/mcp/tools/jobs.ts +145 -0
- package/src/mcp/tools/messages.ts +25 -0
- package/src/mcp/tools/misc.ts +63 -0
- package/src/mcp/tools/send.ts +202 -0
- package/src/mcp/tools/watch.ts +50 -0
- package/src/types/channel.ts +35 -6
- package/src/types/index.ts +1 -1
- package/src/utils/attachment.ts +8 -2
- package/src/utils/config.ts +12 -27
- package/src/mcp/tools.ts +0 -497
package/src/channels/telegram.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
50
|
+
bot.start({ onStart: () => log.info("telegram bot polling started") });
|
|
51
|
+
}
|
|
84
52
|
|
|
85
|
-
|
|
86
|
-
|
|
53
|
+
async stop(): Promise<void> {
|
|
54
|
+
if (this.bot) {
|
|
55
|
+
this.bot.stop();
|
|
56
|
+
this.bot = null;
|
|
87
57
|
}
|
|
58
|
+
}
|
|
88
59
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
const old = chats.get(chatId);
|
|
106
|
-
if (old) old.engine.close();
|
|
80
|
+
// --- Inbound handlers ---
|
|
107
81
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
165
|
+
// --- Core message loop ---
|
|
151
166
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
277
|
-
onStart: () => log.info("telegram bot polling started"),
|
|
278
|
-
});
|
|
239
|
+
// --- Authorization / outbound binding ---
|
|
279
240
|
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
if (this.
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|