niahere 0.3.2 → 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/slack/attachments.ts +142 -0
- package/src/channels/slack/watch.ts +73 -0
- package/src/channels/slack.ts +10 -194
- package/src/channels/telegram.ts +214 -205
- 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,215 @@ class TelegramChannel implements Channel {
|
|
|
47
77
|
}
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
|
|
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
|
-
}
|
|
80
|
+
// --- Inbound handlers ---
|
|
59
81
|
|
|
60
|
-
private
|
|
61
|
-
chatId
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
filename?: string,
|
|
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;
|
|
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"));
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
async
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const chats = new Map<number, ChatState>();
|
|
86
|
-
|
|
87
|
-
function keyOf(chatId: number): string {
|
|
88
|
-
return `tg-${chatId}`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function getState(chatId: number): Promise<ChatState> {
|
|
92
|
-
let state = chats.get(chatId);
|
|
93
|
-
if (state) return state;
|
|
94
|
-
state = await openChatEngine(keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
|
|
95
|
-
chats.set(chatId, state);
|
|
96
|
-
return state;
|
|
97
|
-
}
|
|
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
|
+
}
|
|
98
96
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
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
|
+
}
|
|
107
104
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(() => {});
|
|
113
122
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const isOpen = config.channels.telegram.open;
|
|
118
|
-
const self = this;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
119
125
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|
|
126
164
|
|
|
127
|
-
|
|
128
|
-
if (isOpen) return true;
|
|
129
|
-
if (!self.outboundChatId) return true; // first user always allowed (gets registered)
|
|
130
|
-
return chatId === self.outboundChatId;
|
|
131
|
-
}
|
|
165
|
+
// --- Core message loop ---
|
|
132
166
|
|
|
133
|
-
|
|
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");
|
|
134
177
|
|
|
135
|
-
|
|
136
|
-
const chatId = ctx.chatId;
|
|
137
|
-
log.info(
|
|
138
|
-
{ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 },
|
|
139
|
-
"telegram message received",
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
// Show typing indicator throughout
|
|
143
|
-
const typingInterval = setInterval(() => {
|
|
144
|
-
bot.api.sendChatAction(chatId, "typing").catch(() => {});
|
|
145
|
-
}, 4000);
|
|
178
|
+
const typingInterval = setInterval(() => {
|
|
146
179
|
bot.api.sendChatAction(chatId, "typing").catch(() => {});
|
|
180
|
+
}, 4000);
|
|
181
|
+
bot.api.sendChatAction(chatId, "typing").catch(() => {});
|
|
147
182
|
|
|
183
|
+
try {
|
|
184
|
+
const { result, messageId } = await state.engine.send(text, {}, attachments);
|
|
185
|
+
const reply = result.trim() || "(no response)";
|
|
148
186
|
try {
|
|
149
|
-
const { result, messageId } = await state.engine.send(text, {}, attachments);
|
|
150
|
-
|
|
151
|
-
const reply = result.trim() || "(no response)";
|
|
152
187
|
try {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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;
|
|
188
|
+
await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
|
|
189
|
+
} catch {
|
|
190
|
+
await bot.api.sendMessage(chatId, reply);
|
|
163
191
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
await
|
|
168
|
-
|
|
169
|
-
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;
|
|
170
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);
|
|
171
204
|
}
|
|
205
|
+
}
|
|
172
206
|
|
|
173
|
-
|
|
174
|
-
if (!isAllowed(ctx.chatId)) {
|
|
175
|
-
await ctx.reply("Unauthorized.");
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
registerOutbound(ctx.chatId);
|
|
179
|
-
const state = await getState(ctx.chatId);
|
|
180
|
-
withLock(ctx.chatId, () => processMessage(ctx, state, "hi"));
|
|
181
|
-
});
|
|
207
|
+
// --- Session / state helpers ---
|
|
182
208
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
log.info({ chatId: ctx.chatId, room: `tg-${ctx.chatId}-${state.roomIndex}` }, "new telegram conversation");
|
|
191
|
-
await ctx.reply("New conversation started.");
|
|
192
|
-
});
|
|
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
|
+
}
|
|
193
216
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
});
|
|
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
|
+
}
|
|
203
225
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
});
|
|
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
|
+
}
|
|
226
234
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
registerOutbound(ctx.chatId);
|
|
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
|
-
});
|
|
235
|
+
private keyOf(chatId: number): string {
|
|
236
|
+
return `tg-${chatId}`;
|
|
237
|
+
}
|
|
267
238
|
|
|
268
|
-
|
|
269
|
-
onStart: () => log.info("telegram bot polling started"),
|
|
270
|
-
});
|
|
239
|
+
// --- Authorization / outbound binding ---
|
|
271
240
|
|
|
272
|
-
|
|
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;
|
|
273
247
|
}
|
|
274
248
|
|
|
275
|
-
|
|
276
|
-
if (this.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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;
|
|
280
289
|
}
|
|
281
290
|
}
|
|
282
291
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Message, Session } from "../../db/models";
|
|
2
|
+
|
|
3
|
+
export async function listMessages(limit = 20, room?: string): Promise<string> {
|
|
4
|
+
const messages = await Message.getRecent(limit, room);
|
|
5
|
+
if (messages.length === 0) return "No messages found.";
|
|
6
|
+
return JSON.stringify(messages, null, 2);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function listSessions(limit = 10, room?: string): Promise<string> {
|
|
10
|
+
const sessions = await Session.listRecent(limit, room);
|
|
11
|
+
if (sessions.length === 0) return "No sessions found.";
|
|
12
|
+
return JSON.stringify(sessions, null, 2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function searchMessages(query: string, limit = 20, room?: string): Promise<string> {
|
|
16
|
+
const results = await Message.search(query, limit, room);
|
|
17
|
+
if (results.length === 0) return "No matching messages found.";
|
|
18
|
+
return JSON.stringify(results, null, 2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readSession(sessionId: string): Promise<string> {
|
|
22
|
+
const messages = await Message.getBySession(sessionId);
|
|
23
|
+
if (messages.length === 0) return "Session not found or has no messages.";
|
|
24
|
+
return JSON.stringify(messages, null, 2);
|
|
25
|
+
}
|