niahere 0.3.2 → 0.3.4
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/slack/attachments.ts +145 -0
- package/src/channels/slack/watch.ts +73 -0
- package/src/channels/slack.ts +10 -194
- package/src/channels/telegram.ts +210 -205
- package/src/channels/twilio/rest.ts +1 -0
- package/src/core/alive.ts +1 -0
- package/src/db/models/session.ts +13 -11
- 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/mcp/tools.ts +0 -481
package/src/channels/telegram.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
@@ -26,6 +26,36 @@ class TelegramChannel implements Channel {
|
|
|
26
26
|
name = "telegram" as const;
|
|
27
27
|
private bot: Bot | null = null;
|
|
28
28
|
private outboundChatId: number | null = null;
|
|
29
|
+
private isOpen = false;
|
|
30
|
+
private readonly chats = new Map<number, ChatState>();
|
|
31
|
+
|
|
32
|
+
async start(): Promise<void> {
|
|
33
|
+
const config = getConfig();
|
|
34
|
+
const token = config.channels.telegram.bot_token!;
|
|
35
|
+
|
|
36
|
+
await runMigrations();
|
|
37
|
+
|
|
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;
|
|
43
|
+
|
|
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));
|
|
49
|
+
|
|
50
|
+
bot.start({ onStart: () => log.info("telegram bot polling started") });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async stop(): Promise<void> {
|
|
54
|
+
if (this.bot) {
|
|
55
|
+
this.bot.stop();
|
|
56
|
+
this.bot = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
29
59
|
|
|
30
60
|
async deliver(out: Outbound): Promise<void> {
|
|
31
61
|
if (!this.bot) throw new Error("Telegram not started");
|
|
@@ -47,236 +77,211 @@ class TelegramChannel implements Channel {
|
|
|
47
77
|
}
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
return Buffer.from(await resp.arrayBuffer());
|
|
80
|
+
// --- Inbound handlers ---
|
|
81
|
+
|
|
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"));
|
|
58
87
|
}
|
|
59
88
|
|
|
60
|
-
private
|
|
61
|
-
chatId
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
): string {
|
|
67
|
-
const scope = `telegram-${chatId}-${roomIndex}`;
|
|
68
|
-
const dir = join(getNiaHome(), "tmp", "attachments", scope);
|
|
69
|
-
mkdirSync(dir, { recursive: true });
|
|
70
|
-
const ext = cacheExtension(filename, mimeType);
|
|
71
|
-
const hash = createHash("sha256").update(data).digest("hex").slice(0, 16);
|
|
72
|
-
const path = join(dir, `${hash}.${ext}`);
|
|
73
|
-
writeFileSync(path, data);
|
|
74
|
-
return path;
|
|
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.");
|
|
75
95
|
}
|
|
76
96
|
|
|
77
|
-
async
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
80
104
|
|
|
81
|
-
|
|
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(() => {});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
82
125
|
|
|
83
|
-
|
|
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
|
+
}
|
|
84
164
|
|
|
85
|
-
|
|
165
|
+
// --- Core message loop ---
|
|
86
166
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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");
|
|
90
177
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
chats.set(chatId, state);
|
|
96
|
-
return state;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function restartChat(chatId: number): Promise<ChatState> {
|
|
100
|
-
const state = await rotateRoom(keyOf(chatId), chats.get(chatId), () => ({
|
|
101
|
-
channel: "telegram",
|
|
102
|
-
mcpServers: getMcpServers(),
|
|
103
|
-
}));
|
|
104
|
-
chats.set(chatId, state);
|
|
105
|
-
return state;
|
|
106
|
-
}
|
|
178
|
+
const typingInterval = setInterval(() => {
|
|
179
|
+
bot.api.sendChatAction(chatId, "typing").catch(() => {});
|
|
180
|
+
}, 4000);
|
|
181
|
+
bot.api.sendChatAction(chatId, "typing").catch(() => {});
|
|
107
182
|
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
183
|
+
try {
|
|
184
|
+
const { result, messageId } = await state.engine.send(text, {}, attachments);
|
|
185
|
+
const reply = result.trim() || "(no response)";
|
|
186
|
+
try {
|
|
187
|
+
await bot.api.sendMessage(chatId, reply);
|
|
188
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
|
|
189
|
+
log.info({ chatId, chars: result.length }, "telegram reply sent");
|
|
190
|
+
} catch (sendErr) {
|
|
191
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
192
|
+
throw sendErr;
|
|
113
193
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
function registerOutbound(chatId: number): void {
|
|
121
|
-
if (self.outboundChatId) return;
|
|
122
|
-
self.outboundChatId = chatId;
|
|
123
|
-
updateRawConfig({ channels: { telegram: { chat_id: chatId } } });
|
|
124
|
-
log.info({ chatId }, "auto-registered outbound chat ID");
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const errText = err instanceof Error ? err.message : String(err);
|
|
196
|
+
log.error({ err, chatId }, "telegram message processing failed");
|
|
197
|
+
await bot.api.sendMessage(chatId, `[error] ${errText}`).catch(() => {});
|
|
198
|
+
} finally {
|
|
199
|
+
clearInterval(typingInterval);
|
|
125
200
|
}
|
|
201
|
+
}
|
|
126
202
|
|
|
127
|
-
|
|
128
|
-
if (isOpen) return true;
|
|
129
|
-
if (!self.outboundChatId) return true; // first user always allowed (gets registered)
|
|
130
|
-
return chatId === self.outboundChatId;
|
|
131
|
-
}
|
|
203
|
+
// --- Session / state helpers ---
|
|
132
204
|
|
|
133
|
-
|
|
205
|
+
private async getState(chatId: number): Promise<ChatState> {
|
|
206
|
+
let state = this.chats.get(chatId);
|
|
207
|
+
if (state) return state;
|
|
208
|
+
state = await openChatEngine(this.keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
|
|
209
|
+
this.chats.set(chatId, state);
|
|
210
|
+
return state;
|
|
211
|
+
}
|
|
134
212
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const typingInterval = setInterval(() => {
|
|
144
|
-
bot.api.sendChatAction(chatId, "typing").catch(() => {});
|
|
145
|
-
}, 4000);
|
|
146
|
-
bot.api.sendChatAction(chatId, "typing").catch(() => {});
|
|
213
|
+
private async restartChat(chatId: number): Promise<ChatState> {
|
|
214
|
+
const state = await rotateRoom(this.keyOf(chatId), this.chats.get(chatId), () => ({
|
|
215
|
+
channel: "telegram",
|
|
216
|
+
mcpServers: getMcpServers(),
|
|
217
|
+
}));
|
|
218
|
+
this.chats.set(chatId, state);
|
|
219
|
+
return state;
|
|
220
|
+
}
|
|
147
221
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
|
|
155
|
-
} catch {
|
|
156
|
-
await bot.api.sendMessage(chatId, reply);
|
|
157
|
-
}
|
|
158
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
|
|
159
|
-
log.info({ chatId, chars: result.length }, "telegram reply sent");
|
|
160
|
-
} catch (sendErr) {
|
|
161
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
162
|
-
throw sendErr;
|
|
163
|
-
}
|
|
164
|
-
} catch (err) {
|
|
165
|
-
const errText = err instanceof Error ? err.message : String(err);
|
|
166
|
-
log.error({ err, chatId }, "telegram message processing failed");
|
|
167
|
-
await bot.api.sendMessage(chatId, `[error] ${errText}`).catch(() => {});
|
|
168
|
-
} finally {
|
|
169
|
-
clearInterval(typingInterval);
|
|
170
|
-
}
|
|
222
|
+
private withLock(chatId: number, fn: () => Promise<void>): void {
|
|
223
|
+
const state = this.chats.get(chatId);
|
|
224
|
+
if (!state) {
|
|
225
|
+
fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
|
|
226
|
+
return;
|
|
171
227
|
}
|
|
228
|
+
chainLock(state, fn);
|
|
229
|
+
}
|
|
172
230
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
registerOutbound(ctx.chatId);
|
|
179
|
-
const state = await getState(ctx.chatId);
|
|
180
|
-
withLock(ctx.chatId, () => processMessage(ctx, state, "hi"));
|
|
181
|
-
});
|
|
231
|
+
private keyOf(chatId: number): string {
|
|
232
|
+
return `tg-${chatId}`;
|
|
233
|
+
}
|
|
182
234
|
|
|
183
|
-
|
|
184
|
-
if (!isAllowed(ctx.chatId)) {
|
|
185
|
-
await ctx.reply("Unauthorized.");
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
registerOutbound(ctx.chatId);
|
|
189
|
-
const state = await restartChat(ctx.chatId);
|
|
190
|
-
log.info({ chatId: ctx.chatId, room: `tg-${ctx.chatId}-${state.roomIndex}` }, "new telegram conversation");
|
|
191
|
-
await ctx.reply("New conversation started.");
|
|
192
|
-
});
|
|
235
|
+
// --- Authorization / outbound binding ---
|
|
193
236
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
withLock(ctx.chatId, () => processMessage(ctx, state, ctx.message.text));
|
|
202
|
-
});
|
|
237
|
+
/** Authorization check + auto-reply with "Unauthorized." if not allowed. Returns true to continue. */
|
|
238
|
+
private gate(ctx: Context): boolean {
|
|
239
|
+
if (!ctx.chatId) return false;
|
|
240
|
+
if (this.isAllowed(ctx.chatId)) return true;
|
|
241
|
+
ctx.reply("Unauthorized.").catch(() => {});
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
203
244
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
registerOutbound(ctx.chatId);
|
|
210
|
-
const state = await getState(ctx.chatId);
|
|
211
|
-
withLock(ctx.chatId, async () => {
|
|
212
|
-
try {
|
|
213
|
-
const photos = ctx.message.photo;
|
|
214
|
-
const largest = photos[photos.length - 1];
|
|
215
|
-
const raw = await self.downloadFile(largest.file_id);
|
|
216
|
-
const { data, mimeType } = await prepareImage(raw, "image/jpeg");
|
|
217
|
-
const attachment: Attachment = { type: "image", data, mimeType };
|
|
218
|
-
const caption = ctx.message.caption || "What's in this image?";
|
|
219
|
-
await processMessage(ctx, state, caption, [attachment]);
|
|
220
|
-
} catch (err) {
|
|
221
|
-
log.error({ err, chatId: ctx.chatId }, "failed to process photo");
|
|
222
|
-
await ctx.reply("Failed to process image.").catch(() => {});
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
});
|
|
245
|
+
private isAllowed(chatId: number): boolean {
|
|
246
|
+
if (this.isOpen) return true;
|
|
247
|
+
if (!this.outboundChatId) return true; // first user always allowed (gets registered)
|
|
248
|
+
return chatId === this.outboundChatId;
|
|
249
|
+
}
|
|
226
250
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const state = await getState(ctx.chatId);
|
|
234
|
-
withLock(ctx.chatId, async () => {
|
|
235
|
-
try {
|
|
236
|
-
const doc = ctx.message.document;
|
|
237
|
-
const mime = doc.mime_type || "application/octet-stream";
|
|
238
|
-
const attType = classifyMime(mime) || "file";
|
|
239
|
-
let data = await self.downloadFile(doc.file_id);
|
|
240
|
-
const error = validateAttachment(data);
|
|
241
|
-
if (error) {
|
|
242
|
-
await ctx.reply(error);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
let finalMime = mime;
|
|
246
|
-
if (attType === "image") {
|
|
247
|
-
const prepared = await prepareImage(data, mime);
|
|
248
|
-
data = prepared.data;
|
|
249
|
-
finalMime = prepared.mimeType;
|
|
250
|
-
}
|
|
251
|
-
const sourcePath = self.cacheAttachment(ctx.chatId, state.roomIndex, data, finalMime, doc.file_name);
|
|
252
|
-
const attachment: Attachment = {
|
|
253
|
-
type: attType,
|
|
254
|
-
data,
|
|
255
|
-
mimeType: finalMime,
|
|
256
|
-
filename: doc.file_name,
|
|
257
|
-
sourcePath,
|
|
258
|
-
};
|
|
259
|
-
const caption = ctx.message.caption || (attType === "image" ? "What's in this image?" : "Here's a file.");
|
|
260
|
-
await processMessage(ctx, state, caption, [attachment]);
|
|
261
|
-
} catch (err) {
|
|
262
|
-
log.error({ err, chatId: ctx.chatId }, "failed to process document");
|
|
263
|
-
await ctx.reply("Failed to process document.").catch(() => {});
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
});
|
|
251
|
+
private registerOutbound(chatId: number): void {
|
|
252
|
+
if (this.outboundChatId) return;
|
|
253
|
+
this.outboundChatId = chatId;
|
|
254
|
+
updateRawConfig({ channels: { telegram: { chat_id: chatId } } });
|
|
255
|
+
log.info({ chatId }, "auto-registered outbound chat ID");
|
|
256
|
+
}
|
|
267
257
|
|
|
268
|
-
|
|
269
|
-
onStart: () => log.info("telegram bot polling started"),
|
|
270
|
-
});
|
|
258
|
+
// --- File helpers ---
|
|
271
259
|
|
|
272
|
-
|
|
260
|
+
private async downloadFile(fileId: string): Promise<Buffer> {
|
|
261
|
+
if (!this.bot) throw new Error("Telegram not started");
|
|
262
|
+
const file = await this.bot.api.getFile(fileId);
|
|
263
|
+
const token = getConfig().channels.telegram.bot_token!;
|
|
264
|
+
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
|
265
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
266
|
+
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
|
|
267
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
273
268
|
}
|
|
274
269
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
270
|
+
private cacheAttachment(
|
|
271
|
+
chatId: number,
|
|
272
|
+
roomIndex: number,
|
|
273
|
+
data: Buffer,
|
|
274
|
+
mimeType: string,
|
|
275
|
+
filename?: string,
|
|
276
|
+
): string {
|
|
277
|
+
const scope = `telegram-${chatId}-${roomIndex}`;
|
|
278
|
+
const dir = join(getNiaHome(), "tmp", "attachments", scope);
|
|
279
|
+
mkdirSync(dir, { recursive: true });
|
|
280
|
+
const ext = cacheExtension(filename, mimeType);
|
|
281
|
+
const hash = createHash("sha256").update(data).digest("hex").slice(0, 16);
|
|
282
|
+
const path = join(dir, `${hash}.${ext}`);
|
|
283
|
+
writeFileSync(path, data);
|
|
284
|
+
return path;
|
|
280
285
|
}
|
|
281
286
|
}
|
|
282
287
|
|
|
@@ -33,6 +33,7 @@ async function twilioPost<T = unknown>(creds: TwilioCreds, suffix: string, body:
|
|
|
33
33
|
method: "POST",
|
|
34
34
|
headers: { Authorization: basicAuth(creds), "Content-Type": "application/x-www-form-urlencoded" },
|
|
35
35
|
body: body.toString(),
|
|
36
|
+
signal: AbortSignal.timeout(15_000),
|
|
36
37
|
});
|
|
37
38
|
if (!resp.ok) {
|
|
38
39
|
const text = await resp.text().catch(() => "");
|
package/src/core/alive.ts
CHANGED
|
@@ -107,6 +107,7 @@ async function notifyUser(message: string): Promise<void> {
|
|
|
107
107
|
method: "POST",
|
|
108
108
|
headers: { Authorization: `Bearer ${slToken}`, "Content-Type": "application/json" },
|
|
109
109
|
body: JSON.stringify({ channel: slRecipient, text: message }),
|
|
110
|
+
signal: AbortSignal.timeout(10_000),
|
|
110
111
|
});
|
|
111
112
|
if (resp.ok) {
|
|
112
113
|
log.info("alive: notified user via slack");
|
package/src/db/models/session.ts
CHANGED
|
@@ -98,7 +98,10 @@ export async function setSummary(id: string, summary: string): Promise<void> {
|
|
|
98
98
|
await sql`UPDATE sessions SET summary = ${summary} WHERE id = ${id}`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
export async function getRecentSummaries(
|
|
101
|
+
export async function getRecentSummaries(
|
|
102
|
+
room: string,
|
|
103
|
+
limit = 3,
|
|
104
|
+
): Promise<Array<{ summary: string; updatedAt: string }>> {
|
|
102
105
|
const sql = getSql();
|
|
103
106
|
// Match summaries from sessions in the same channel (e.g. slack-dm-U...-*)
|
|
104
107
|
// by extracting the room prefix (everything before the last -N index)
|
|
@@ -171,17 +174,16 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
|
|
|
171
174
|
`;
|
|
172
175
|
}
|
|
173
176
|
|
|
177
|
+
/** Max numeric suffix among rooms matching `${prefix}-N`. Used by rotateRoom() to allocate idx+1 without collisions. */
|
|
174
178
|
export async function getLatestRoomIndex(prefix: string): Promise<number> {
|
|
175
179
|
const sql = getSql();
|
|
176
180
|
const pattern = `^${escapeRegex(prefix)}-\\d+$`;
|
|
177
|
-
const rows = await sql`
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const idx = parseInt(parts[parts.length - 1], 10);
|
|
186
|
-
return isNaN(idx) ? 0 : idx;
|
|
181
|
+
const rows = await sql`SELECT room FROM sessions WHERE room ~ ${pattern}`;
|
|
182
|
+
let max = 0;
|
|
183
|
+
for (const row of rows) {
|
|
184
|
+
const parts = (row.room as string).split("-");
|
|
185
|
+
const idx = parseInt(parts[parts.length - 1], 10);
|
|
186
|
+
if (!isNaN(idx) && idx > max) max = idx;
|
|
187
|
+
}
|
|
188
|
+
return max;
|
|
187
189
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool handler barrel. Domain modules live next to this file. Callers
|
|
3
|
+
* `import * as handlers from "./tools"` and get all of them.
|
|
4
|
+
*/
|
|
5
|
+
export * from "./jobs";
|
|
6
|
+
export * from "./send";
|
|
7
|
+
export * from "./messages";
|
|
8
|
+
export * from "./watch";
|
|
9
|
+
export * from "./misc";
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Job } from "../../db/models";
|
|
2
|
+
import { computeInitialNextRun } from "../../core/scheduler";
|
|
3
|
+
import { getConfig } from "../../utils/config";
|
|
4
|
+
import { resolveJobPrompt } from "../../core/job-prompt";
|
|
5
|
+
import type { ScheduleType } from "../../types";
|
|
6
|
+
|
|
7
|
+
export async function listJobs(): Promise<string> {
|
|
8
|
+
const jobs = await Job.list();
|
|
9
|
+
if (jobs.length === 0) return "No jobs found.";
|
|
10
|
+
const withPromptSource = jobs.map((job) => {
|
|
11
|
+
const resolvedPrompt = resolveJobPrompt(job);
|
|
12
|
+
return {
|
|
13
|
+
...job,
|
|
14
|
+
prompt: resolvedPrompt.prompt,
|
|
15
|
+
promptSource: resolvedPrompt.source,
|
|
16
|
+
promptPath: resolvedPrompt.filePath,
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
return JSON.stringify(withPromptSource, null, 2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function addJob(args: {
|
|
23
|
+
name: string;
|
|
24
|
+
schedule: string;
|
|
25
|
+
prompt: string;
|
|
26
|
+
schedule_type?: ScheduleType;
|
|
27
|
+
always?: boolean;
|
|
28
|
+
agent?: string;
|
|
29
|
+
employee?: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
stateless?: boolean;
|
|
32
|
+
}): Promise<string> {
|
|
33
|
+
const scheduleType = args.schedule_type || "cron";
|
|
34
|
+
const always = args.always || false;
|
|
35
|
+
const stateless = args.stateless || false;
|
|
36
|
+
const config = getConfig();
|
|
37
|
+
|
|
38
|
+
const nextRunAt = computeInitialNextRun(scheduleType, args.schedule, config.timezone);
|
|
39
|
+
await Job.create(
|
|
40
|
+
args.name,
|
|
41
|
+
args.schedule,
|
|
42
|
+
args.prompt,
|
|
43
|
+
always,
|
|
44
|
+
scheduleType,
|
|
45
|
+
nextRunAt,
|
|
46
|
+
args.agent,
|
|
47
|
+
stateless,
|
|
48
|
+
args.model,
|
|
49
|
+
args.employee,
|
|
50
|
+
);
|
|
51
|
+
const agentNote = args.agent ? ` [agent: ${args.agent}]` : "";
|
|
52
|
+
const employeeNote = args.employee ? ` [employee: ${args.employee}]` : "";
|
|
53
|
+
const modelNote = args.model ? ` [model: ${args.model}]` : "";
|
|
54
|
+
return `Job "${args.name}" created (${scheduleType}: ${args.schedule})${agentNote}${employeeNote}${modelNote}. Next run: ${nextRunAt.toISOString()}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function updateJob(args: {
|
|
58
|
+
name: string;
|
|
59
|
+
schedule?: string;
|
|
60
|
+
prompt?: string;
|
|
61
|
+
always?: boolean;
|
|
62
|
+
agent?: string | null;
|
|
63
|
+
employee?: string | null;
|
|
64
|
+
model?: string | null;
|
|
65
|
+
stateless?: boolean;
|
|
66
|
+
schedule_type?: "cron" | "interval" | "once";
|
|
67
|
+
}): Promise<string> {
|
|
68
|
+
const fields: Partial<{
|
|
69
|
+
schedule: string;
|
|
70
|
+
prompt: string;
|
|
71
|
+
always: boolean;
|
|
72
|
+
stateless: boolean;
|
|
73
|
+
model: string | null;
|
|
74
|
+
agent: string | null;
|
|
75
|
+
employee: string | null;
|
|
76
|
+
scheduleType: "cron" | "interval" | "once";
|
|
77
|
+
}> = {};
|
|
78
|
+
if (args.schedule) fields.schedule = args.schedule;
|
|
79
|
+
if (args.prompt) fields.prompt = args.prompt;
|
|
80
|
+
if (args.always !== undefined) fields.always = args.always;
|
|
81
|
+
if (args.stateless !== undefined) fields.stateless = args.stateless;
|
|
82
|
+
if (args.model !== undefined) fields.model = args.model;
|
|
83
|
+
if (args.agent !== undefined) fields.agent = args.agent;
|
|
84
|
+
if (args.employee !== undefined) fields.employee = args.employee;
|
|
85
|
+
if (args.schedule_type) fields.scheduleType = args.schedule_type;
|
|
86
|
+
|
|
87
|
+
if (Object.keys(fields).length === 0)
|
|
88
|
+
return "Nothing to update. Pass at least one field (schedule, prompt, always, stateless, model, agent, employee, or schedule_type).";
|
|
89
|
+
|
|
90
|
+
const updated = await Job.update(args.name, fields);
|
|
91
|
+
if (!updated) return `Job "${args.name}" not found.`;
|
|
92
|
+
if (fields.prompt !== undefined) {
|
|
93
|
+
const job = await Job.get(args.name);
|
|
94
|
+
if (job) {
|
|
95
|
+
const resolvedPrompt = resolveJobPrompt(job);
|
|
96
|
+
if (resolvedPrompt.source === "file") {
|
|
97
|
+
return `Job "${args.name}" updated. Note: runtime prompt is still overridden by ${resolvedPrompt.filePath}.`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return `Job "${args.name}" updated.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function removeJob(name: string): Promise<string> {
|
|
105
|
+
const removed = await Job.remove(name);
|
|
106
|
+
return removed ? `Job "${name}" removed.` : `Job "${name}" not found.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function enableJob(name: string): Promise<string> {
|
|
110
|
+
const updated = await Job.update(name, { status: "active" });
|
|
111
|
+
if (!updated) return `Job "${name}" not found.`;
|
|
112
|
+
|
|
113
|
+
const job = await Job.get(name);
|
|
114
|
+
if (job) {
|
|
115
|
+
const config = getConfig();
|
|
116
|
+
const nextRun = computeInitialNextRun(job.scheduleType, job.schedule, config.timezone);
|
|
117
|
+
const { getSql } = await import("../../db/connection");
|
|
118
|
+
await getSql()`UPDATE jobs SET next_run_at = ${nextRun} WHERE name = ${name}`;
|
|
119
|
+
}
|
|
120
|
+
return `Job "${name}" enabled.`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function disableJob(name: string): Promise<string> {
|
|
124
|
+
const updated = await Job.update(name, { status: "disabled" });
|
|
125
|
+
return updated ? `Job "${name}" disabled.` : `Job "${name}" not found.`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function archiveJob(name: string): Promise<string> {
|
|
129
|
+
const updated = await Job.update(name, { status: "archived" });
|
|
130
|
+
return updated ? `Job "${name}" archived.` : `Job "${name}" not found.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function unarchiveJob(name: string): Promise<string> {
|
|
134
|
+
const updated = await Job.update(name, { status: "disabled" });
|
|
135
|
+
return updated ? `Job "${name}" unarchived (disabled). Enable with enable_job.` : `Job "${name}" not found.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runJobNow(name: string): Promise<string> {
|
|
139
|
+
const job = await Job.get(name);
|
|
140
|
+
if (!job) return `Job "${name}" not found.`;
|
|
141
|
+
|
|
142
|
+
const { getSql } = await import("../../db/connection");
|
|
143
|
+
await getSql()`UPDATE jobs SET next_run_at = NOW() WHERE name = ${name}`;
|
|
144
|
+
return `Job "${name}" queued for immediate execution.`;
|
|
145
|
+
}
|