heyio 0.32.0 → 0.33.0

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.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Integration tests for MCP API endpoints (#279).
3
+ *
4
+ * Strategy: spin up a minimal Express server that mounts the same MCP route
5
+ * handlers as src/api/server.ts, but pointed at a temp config file via
6
+ * setMcpConfigPathForTests(). Tests make real HTTP requests using node:http.
7
+ * The /mcp/reload endpoint uses an injectable reload fn to avoid pulling in
8
+ * the full orchestrator (which needs DB + Copilot client).
9
+ */
10
+ import { describe, it, before, after, beforeEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import http from "node:http";
16
+ import express from "express";
17
+ import { setMcpConfigPathForTests, resetMcpConfigPath, loadMcpConfig, saveMcpConfig } from "../mcp/config.js";
18
+ // ── Helpers ───────────────────────────────────────────────────────────────────
19
+ function req(method, port, path, body) {
20
+ return new Promise((resolve, reject) => {
21
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
22
+ const options = {
23
+ hostname: "127.0.0.1",
24
+ port,
25
+ path,
26
+ method,
27
+ headers: {
28
+ ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}),
29
+ },
30
+ };
31
+ const r = http.request(options, (res) => {
32
+ let data = "";
33
+ res.on("data", (chunk) => { data += chunk; });
34
+ res.on("end", () => {
35
+ try {
36
+ resolve({ status: res.statusCode ?? 0, body: JSON.parse(data) });
37
+ }
38
+ catch {
39
+ resolve({ status: res.statusCode ?? 0, body: data });
40
+ }
41
+ });
42
+ });
43
+ r.on("error", reject);
44
+ if (payload)
45
+ r.write(payload);
46
+ r.end();
47
+ });
48
+ }
49
+ // ── Test server setup ─────────────────────────────────────────────────────────
50
+ let server;
51
+ let port;
52
+ let tmpDir;
53
+ let configPath;
54
+ function buildMcpApp(reloadFn = async () => { }) {
55
+ const app = express();
56
+ app.use(express.json());
57
+ app.get("/api/mcp/servers", (_req, res) => {
58
+ try {
59
+ const config = loadMcpConfig();
60
+ res.json({ servers: config.servers });
61
+ }
62
+ catch (e) {
63
+ res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
64
+ }
65
+ });
66
+ app.post("/api/mcp/servers", (req, res) => {
67
+ const { name, command, args, url, env } = req.body;
68
+ if (!name) {
69
+ res.status(400).json({ error: "name is required" });
70
+ return;
71
+ }
72
+ if (!command && !url) {
73
+ res.status(400).json({ error: "command or url is required" });
74
+ return;
75
+ }
76
+ try {
77
+ const config = loadMcpConfig();
78
+ if (config.servers.find(s => s.name === name)) {
79
+ res.status(409).json({ error: "server already exists" });
80
+ return;
81
+ }
82
+ config.servers.push({ name, command, args, url, env, enabled: true });
83
+ saveMcpConfig(config);
84
+ res.status(201).json({ ok: true });
85
+ }
86
+ catch (e) {
87
+ res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
88
+ }
89
+ });
90
+ app.delete("/api/mcp/servers/:name", (req, res) => {
91
+ try {
92
+ const config = loadMcpConfig();
93
+ const idx = config.servers.findIndex(s => s.name === req.params.name);
94
+ if (idx === -1) {
95
+ res.status(404).json({ error: "server not found" });
96
+ return;
97
+ }
98
+ config.servers.splice(idx, 1);
99
+ saveMcpConfig(config);
100
+ res.json({ ok: true });
101
+ }
102
+ catch (e) {
103
+ res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
104
+ }
105
+ });
106
+ app.patch("/api/mcp/servers/:name/toggle", (req, res) => {
107
+ try {
108
+ const config = loadMcpConfig();
109
+ const srv = config.servers.find(s => s.name === req.params.name);
110
+ if (!srv) {
111
+ res.status(404).json({ error: "server not found" });
112
+ return;
113
+ }
114
+ srv.enabled = srv.enabled === false ? true : false;
115
+ saveMcpConfig(config);
116
+ res.json({ ok: true, enabled: srv.enabled });
117
+ }
118
+ catch (e) {
119
+ res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
120
+ }
121
+ });
122
+ app.post("/api/mcp/reload", async (_req, res) => {
123
+ try {
124
+ await reloadFn();
125
+ res.json({ ok: true });
126
+ }
127
+ catch (err) {
128
+ res.status(500).json({ error: err instanceof Error ? err.message : "reload failed" });
129
+ }
130
+ });
131
+ return app;
132
+ }
133
+ before(async () => {
134
+ tmpDir = mkdtempSync(join(tmpdir(), "io-mcp-api-test-"));
135
+ configPath = join(tmpDir, "mcp.json");
136
+ setMcpConfigPathForTests(configPath);
137
+ await new Promise((resolve) => {
138
+ server = buildMcpApp().listen(0, "127.0.0.1", () => {
139
+ port = server.address().port;
140
+ resolve();
141
+ });
142
+ });
143
+ });
144
+ after(async () => {
145
+ await new Promise((resolve, reject) => server.close((e) => e ? reject(e) : resolve()));
146
+ resetMcpConfigPath();
147
+ rmSync(tmpDir, { recursive: true, force: true });
148
+ });
149
+ beforeEach(() => {
150
+ // Reset config file between tests
151
+ saveMcpConfig({ servers: [] });
152
+ });
153
+ // ── Tests ─────────────────────────────────────────────────────────────────────
154
+ describe("GET /api/mcp/servers", () => {
155
+ it("returns empty array when no servers configured", async () => {
156
+ const res = await req("GET", port, "/api/mcp/servers");
157
+ assert.equal(res.status, 200);
158
+ assert.deepEqual(res.body.servers, []);
159
+ });
160
+ it("returns configured servers", async () => {
161
+ saveMcpConfig({ servers: [{ name: "figma", command: "npx", enabled: true }] });
162
+ const res = await req("GET", port, "/api/mcp/servers");
163
+ assert.equal(res.status, 200);
164
+ const { servers } = res.body;
165
+ assert.equal(servers.length, 1);
166
+ assert.equal(servers[0].name, "figma");
167
+ });
168
+ });
169
+ describe("POST /api/mcp/servers", () => {
170
+ it("creates a stdio server successfully", async () => {
171
+ const res = await req("POST", port, "/api/mcp/servers", {
172
+ name: "github",
173
+ command: "npx",
174
+ args: ["-y", "@modelcontextprotocol/server-github"],
175
+ });
176
+ assert.equal(res.status, 201);
177
+ assert.equal(res.body.ok, true);
178
+ const config = loadMcpConfig();
179
+ assert.equal(config.servers.length, 1);
180
+ assert.equal(config.servers[0].name, "github");
181
+ assert.equal(config.servers[0].command, "npx");
182
+ assert.deepEqual(config.servers[0].args, ["-y", "@modelcontextprotocol/server-github"]);
183
+ assert.equal(config.servers[0].enabled, true);
184
+ });
185
+ it("creates an SSE server successfully", async () => {
186
+ const res = await req("POST", port, "/api/mcp/servers", {
187
+ name: "postgres",
188
+ url: "http://localhost:3001/sse",
189
+ });
190
+ assert.equal(res.status, 201);
191
+ const config = loadMcpConfig();
192
+ assert.equal(config.servers[0].url, "http://localhost:3001/sse");
193
+ });
194
+ it("returns 400 when name is missing", async () => {
195
+ const res = await req("POST", port, "/api/mcp/servers", { command: "npx" });
196
+ assert.equal(res.status, 400);
197
+ assert.ok(res.body.error.includes("name"));
198
+ });
199
+ it("returns 400 when both command and url are missing", async () => {
200
+ const res = await req("POST", port, "/api/mcp/servers", { name: "bad-server" });
201
+ assert.equal(res.status, 400);
202
+ assert.ok(res.body.error.includes("command"));
203
+ });
204
+ it("returns 409 for duplicate server name", async () => {
205
+ saveMcpConfig({ servers: [{ name: "figma", command: "npx" }] });
206
+ const res = await req("POST", port, "/api/mcp/servers", { name: "figma", command: "npx" });
207
+ assert.equal(res.status, 409);
208
+ assert.ok(res.body.error.includes("already exists"));
209
+ });
210
+ });
211
+ describe("DELETE /api/mcp/servers/:name", () => {
212
+ it("removes an existing server", async () => {
213
+ saveMcpConfig({ servers: [{ name: "figma", command: "npx" }, { name: "postgres", url: "http://localhost/sse" }] });
214
+ const res = await req("DELETE", port, "/api/mcp/servers/figma");
215
+ assert.equal(res.status, 200);
216
+ assert.equal(res.body.ok, true);
217
+ const config = loadMcpConfig();
218
+ assert.equal(config.servers.length, 1);
219
+ assert.equal(config.servers[0].name, "postgres");
220
+ });
221
+ it("returns 404 for unknown server", async () => {
222
+ const res = await req("DELETE", port, "/api/mcp/servers/nonexistent");
223
+ assert.equal(res.status, 404);
224
+ assert.ok(res.body.error.includes("not found"));
225
+ });
226
+ });
227
+ describe("PATCH /api/mcp/servers/:name/toggle", () => {
228
+ it("disables an enabled server", async () => {
229
+ saveMcpConfig({ servers: [{ name: "figma", command: "npx", enabled: true }] });
230
+ const res = await req("PATCH", port, "/api/mcp/servers/figma/toggle");
231
+ assert.equal(res.status, 200);
232
+ assert.equal(res.body.enabled, false);
233
+ assert.equal(loadMcpConfig().servers[0].enabled, false);
234
+ });
235
+ it("enables a disabled server", async () => {
236
+ saveMcpConfig({ servers: [{ name: "figma", command: "npx", enabled: false }] });
237
+ const res = await req("PATCH", port, "/api/mcp/servers/figma/toggle");
238
+ assert.equal(res.status, 200);
239
+ assert.equal(res.body.enabled, true);
240
+ });
241
+ it("returns 404 for unknown server", async () => {
242
+ const res = await req("PATCH", port, "/api/mcp/servers/nonexistent/toggle");
243
+ assert.equal(res.status, 404);
244
+ assert.ok(res.body.error.includes("not found"));
245
+ });
246
+ });
247
+ describe("POST /api/mcp/reload", () => {
248
+ it("calls reload function and returns ok", async () => {
249
+ let reloadCalled = false;
250
+ const app = buildMcpApp(async () => { reloadCalled = true; });
251
+ await new Promise((resolve, reject) => {
252
+ const s = app.listen(0, "127.0.0.1", async () => {
253
+ const p = s.address().port;
254
+ try {
255
+ const res = await req("POST", p, "/api/mcp/reload");
256
+ assert.equal(res.status, 200);
257
+ assert.equal(res.body.ok, true);
258
+ assert.ok(reloadCalled, "reload function should have been called");
259
+ }
260
+ finally {
261
+ s.close(() => resolve());
262
+ }
263
+ });
264
+ s.on("error", reject);
265
+ });
266
+ });
267
+ it("returns 500 when reload throws", async () => {
268
+ const app = buildMcpApp(async () => { throw new Error("init failed"); });
269
+ await new Promise((resolve, reject) => {
270
+ const s = app.listen(0, "127.0.0.1", async () => {
271
+ const p = s.address().port;
272
+ try {
273
+ const res = await req("POST", p, "/api/mcp/reload");
274
+ assert.equal(res.status, 500);
275
+ assert.ok(res.body.error.includes("init failed"));
276
+ }
277
+ finally {
278
+ s.close(() => resolve());
279
+ }
280
+ });
281
+ s.on("error", reject);
282
+ });
283
+ });
284
+ });
285
+ //# sourceMappingURL=mcp.test.js.map
@@ -17,6 +17,7 @@ import { removeWorktree } from "../store/worktrees.js";
17
17
  import { createFeedEntry } from "../store/feed.js";
18
18
  import { SESSIONS_DIR } from "../paths.js";
19
19
  import { getUniverse } from "./universes.js";
20
+ import { readSquadWikiPages } from "../wiki/fs.js";
20
21
  // Key format: "squadSlug:characterName" for per-agent sessions, "squadSlug" for legacy
21
22
  const agentSessions = new Map();
22
23
  const agentSessionModels = new Map();
@@ -417,6 +418,10 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
417
418
  const squad = getSquad(squadSlug);
418
419
  const client = await getClient();
419
420
  const decisions = getDecisionsSummary(squadSlug);
421
+ const wikiPages = readSquadWikiPages(squadSlug);
422
+ const wikiSection = wikiPages.length > 0
423
+ ? `\n\n## Squad Wiki\n${wikiPages.map(p => `### ${p.path}\n${p.content}`).join("\n\n")}`
424
+ : "";
420
425
  console.error(`[io] Agent ${agent.character_name}: using model "${model}" (agent tier: ${agentTier}, task tier: ${taskTier}, effective: ${effectiveTier})`);
421
426
  const universeName = squad.universe
422
427
  ? getUniverse(squad.universe)?.name ?? squad.universe
@@ -479,7 +484,7 @@ ${agent.charter ?? "General-purpose agent. Handle tasks as they come."}
479
484
  - **Path**: ${squad.project_path}
480
485
 
481
486
  ## Past Decisions
482
- ${decisions}${leadSection}
487
+ ${decisions}${leadSection}${wikiSection}
483
488
 
484
489
  ## Repository Hygiene
485
490
  Before you make ANY code changes, you MUST sync your working copy with the remote default branch and work from a fresh feature branch. This prevents the merge conflicts the team hit on PRs like #45.
@@ -537,6 +542,10 @@ async function getOrCreateSession(squadSlug, taskDescription) {
537
542
  const squad = getSquad(squadSlug);
538
543
  const client = await getClient();
539
544
  const decisions = getDecisionsSummary(squadSlug);
545
+ const wikiPages = readSquadWikiPages(squadSlug);
546
+ const wikiSection = wikiPages.length > 0
547
+ ? `\n\n## Squad Wiki\n${wikiPages.map(p => `### ${p.path}\n${p.content}`).join("\n\n")}`
548
+ : "";
540
549
  const agentTools = buildAgentTools(squadSlug);
541
550
  const model = getModelForTask(taskDescription ?? "", squad.model);
542
551
  const commonConfig = {
@@ -347,7 +347,7 @@ function invalidateSession() {
347
347
  // ---------------------------------------------------------------------------
348
348
  // Message execution
349
349
  // ---------------------------------------------------------------------------
350
- async function executeOnSession(prompt, callback) {
350
+ async function executeOnSession(prompt, callback, attachments) {
351
351
  const session = await ensureOrchestratorSession();
352
352
  let accumulated = "";
353
353
  const unsubDelta = session.on("assistant.message_delta", (event) => {
@@ -356,7 +356,10 @@ async function executeOnSession(prompt, callback) {
356
356
  callback(delta, false);
357
357
  });
358
358
  try {
359
- const result = await session.sendAndWait({ prompt }, SEND_TIMEOUT_MS);
359
+ const sendPayload = { prompt };
360
+ if (attachments && attachments.length > 0)
361
+ sendPayload.attachments = attachments;
362
+ const result = await session.sendAndWait(sendPayload, SEND_TIMEOUT_MS);
360
363
  unsubDelta();
361
364
  const finalText = result?.data.content ?? accumulated;
362
365
  callback("", true);
@@ -412,7 +415,7 @@ async function processQueue() {
412
415
  let lastError;
413
416
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
414
417
  try {
415
- const response = await executeOnSession(taggedPrompt, msg.callback);
418
+ const response = await executeOnSession(taggedPrompt, msg.callback, msg.attachments);
416
419
  logConversation("assistant", response, sourceLabel(msg.source));
417
420
  msg.resolve();
418
421
  lastError = undefined;
@@ -507,10 +510,10 @@ export async function initOrchestrator(copilotClient) {
507
510
  console.error("[io] Eager session creation failed (will retry on first message):", err instanceof Error ? err.message : err);
508
511
  }
509
512
  }
510
- export async function sendToOrchestrator(prompt, source, callback) {
513
+ export async function sendToOrchestrator(prompt, source, callback, attachments) {
511
514
  logConversation("user", prompt, sourceLabel(source));
512
515
  return new Promise((resolve, reject) => {
513
- messageQueue.push({ prompt, source, callback, resolve, reject });
516
+ messageQueue.push({ prompt, source, callback, attachments, resolve, reject });
514
517
  processQueue();
515
518
  });
516
519
  }
package/dist/daemon.js CHANGED
@@ -110,8 +110,8 @@ export async function startDaemon() {
110
110
  await startApiServer();
111
111
  // Wire up Telegram handler
112
112
  if (config.telegramEnabled) {
113
- setTelegramHandler(async (text, chatId, messageId, callback) => {
114
- await sendToOrchestrator(text, { type: "telegram", chatId, messageId }, callback);
113
+ setTelegramHandler(async (text, chatId, messageId, callback, attachments) => {
114
+ await sendToOrchestrator(text, { type: "telegram", chatId, messageId }, callback, attachments);
115
115
  });
116
116
  createBot();
117
117
  await startBot();
@@ -3,12 +3,20 @@ import { dirname } from "path";
3
3
  import { IO_HOME } from "../paths.js";
4
4
  import { join } from "path";
5
5
  export const MCP_CONFIG_PATH = join(IO_HOME, "mcp.json");
6
+ // Mutable override for tests — mirrors the setDbPathForTests pattern.
7
+ let _configPath = MCP_CONFIG_PATH;
8
+ export function setMcpConfigPathForTests(path) {
9
+ _configPath = path;
10
+ }
11
+ export function resetMcpConfigPath() {
12
+ _configPath = MCP_CONFIG_PATH;
13
+ }
6
14
  export function loadMcpConfig() {
7
- if (!existsSync(MCP_CONFIG_PATH)) {
15
+ if (!existsSync(_configPath)) {
8
16
  return { servers: [] };
9
17
  }
10
18
  try {
11
- const raw = readFileSync(MCP_CONFIG_PATH, "utf-8");
19
+ const raw = readFileSync(_configPath, "utf-8");
12
20
  const parsed = JSON.parse(raw);
13
21
  if (!parsed.servers || !Array.isArray(parsed.servers)) {
14
22
  return { servers: [] };
@@ -20,10 +28,10 @@ export function loadMcpConfig() {
20
28
  }
21
29
  }
22
30
  export function saveMcpConfig(config) {
23
- const dir = dirname(MCP_CONFIG_PATH);
31
+ const dir = dirname(_configPath);
24
32
  if (!existsSync(dir)) {
25
33
  mkdirSync(dir, { recursive: true });
26
34
  }
27
- writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
35
+ writeFileSync(_configPath, JSON.stringify(config, null, 2), "utf-8");
28
36
  }
29
37
  //# sourceMappingURL=config.js.map
@@ -1,6 +1,7 @@
1
1
  import { getDb } from "./db.js";
2
2
  import { getDecisions } from "./squads.js";
3
3
  import { worktreeExists } from "./worktrees.js";
4
+ import { readSquadWikiPages } from "../wiki/fs.js";
4
5
  export function ensureInstanceTables() {
5
6
  const db = getDb();
6
7
  db.exec(`
@@ -108,7 +109,14 @@ export function deleteInstance(id) {
108
109
  */
109
110
  export function buildContextSnapshot(masterSquadSlug, limit = 30) {
110
111
  const decisions = getDecisions(masterSquadSlug, limit);
111
- return JSON.stringify(decisions.map((d) => ({ decision: d.decision, context: d.context, created_at: d.created_at })));
112
+ const wikiPages = readSquadWikiPages(masterSquadSlug);
113
+ const snapshot = {
114
+ decisions: decisions.map((d) => ({ decision: d.decision, context: d.context, created_at: d.created_at })),
115
+ };
116
+ if (wikiPages.length > 0) {
117
+ snapshot.wiki = wikiPages.map(p => ({ path: p.path, content: p.content }));
118
+ }
119
+ return JSON.stringify(snapshot);
112
120
  }
113
121
  /**
114
122
  * Reconcile instances on startup: detect orphaned worktrees and mark stale active instances.
@@ -234,18 +234,19 @@ describe("deleteInstance", () => {
234
234
  });
235
235
  // ── buildContextSnapshot ──────────────────────────────────────────────────────
236
236
  describe("buildContextSnapshot", () => {
237
- it("returns a JSON array of recent squad decisions", () => {
237
+ it("returns a JSON object with decisions array", () => {
238
238
  logDecision("test-squad", "use TypeScript everywhere", "consistency");
239
239
  logDecision("test-squad", "prefer functional style");
240
240
  const snapshot = buildContextSnapshot("test-squad");
241
241
  const parsed = JSON.parse(snapshot);
242
- assert.ok(Array.isArray(parsed));
243
- assert.equal(parsed.length, 2);
244
- assert.ok(parsed.some((d) => d.decision === "use TypeScript everywhere"));
242
+ assert.ok(Array.isArray(parsed.decisions));
243
+ assert.equal(parsed.decisions.length, 2);
244
+ assert.ok(parsed.decisions.some((d) => d.decision === "use TypeScript everywhere"));
245
245
  });
246
- it("returns an empty JSON array for a squad with no decisions", () => {
246
+ it("returns empty decisions array for a squad with no decisions", () => {
247
247
  const snapshot = buildContextSnapshot("test-squad");
248
- assert.deepEqual(JSON.parse(snapshot), []);
248
+ const parsed = JSON.parse(snapshot);
249
+ assert.deepEqual(parsed.decisions, []);
249
250
  });
250
251
  it("respects the limit parameter", () => {
251
252
  for (let i = 0; i < 10; i++) {
@@ -253,7 +254,25 @@ describe("buildContextSnapshot", () => {
253
254
  }
254
255
  const snapshot = buildContextSnapshot("test-squad", 5);
255
256
  const parsed = JSON.parse(snapshot);
256
- assert.equal(parsed.length, 5);
257
+ assert.equal(parsed.decisions.length, 5);
258
+ });
259
+ it("includes wiki pages when they exist", async () => {
260
+ const { writePage, deletePage } = await import("../wiki/fs.js");
261
+ const testSlug = `test-squad-snap-${Date.now()}`;
262
+ const pagePath = `pages/squads/${testSlug}/rules.md`;
263
+ try {
264
+ writePage(pagePath, "# Rules\nNo force push.");
265
+ logDecision(testSlug, "test decision");
266
+ const snapshot = buildContextSnapshot(testSlug);
267
+ const parsed = JSON.parse(snapshot);
268
+ assert.ok(parsed.wiki);
269
+ assert.equal(parsed.wiki.length, 1);
270
+ assert.equal(parsed.wiki[0].path, pagePath);
271
+ assert.ok(parsed.wiki[0].content.includes("No force push"));
272
+ }
273
+ finally {
274
+ deletePage(pagePath);
275
+ }
257
276
  });
258
277
  });
259
278
  // ── reconcileInstances ────────────────────────────────────────────────────────
@@ -2,8 +2,17 @@ import { Bot } from "grammy";
2
2
  import { config } from "../config.js";
3
3
  const TELEGRAM_MAX_LENGTH = 4096;
4
4
  const EDIT_DEBOUNCE_MS = 500;
5
+ const FILE_SIZE_LIMIT_BYTES = 5 * 1024 * 1024; // 5MB
5
6
  let bot;
6
7
  let messageHandler;
8
+ async function downloadTelegramFile(botInstance, fileId) {
9
+ const file = await botInstance.api.getFile(fileId);
10
+ const url = `https://api.telegram.org/file/bot${config.telegramBotToken}/${file.file_path}`;
11
+ const response = await fetch(url);
12
+ const buffer = Buffer.from(await response.arrayBuffer());
13
+ const mimeType = response.headers.get("content-type") ?? "application/octet-stream";
14
+ return { data: buffer.toString("base64"), mimeType, size: buffer.length };
15
+ }
7
16
  export function setMessageHandler(handler) {
8
17
  messageHandler = handler;
9
18
  }
@@ -82,6 +91,172 @@ export function createBot() {
82
91
  await editReply("An error occurred while processing your message.");
83
92
  }
84
93
  });
94
+ // ---------------------------------------------------------------------------
95
+ // Photo handler
96
+ // ---------------------------------------------------------------------------
97
+ bot.on("message:photo", async (ctx) => {
98
+ const userId = ctx.from?.id;
99
+ if (config.authorizedUserId && userId !== config.authorizedUserId)
100
+ return;
101
+ if (!messageHandler || !bot) {
102
+ console.error("[io] No message handler registered");
103
+ return;
104
+ }
105
+ // Telegram sends an array of sizes — last element is the highest resolution
106
+ const photos = ctx.message.photo;
107
+ const photo = photos[photos.length - 1];
108
+ const chatId = ctx.chat.id;
109
+ const messageId = ctx.message.message_id;
110
+ const caption = ctx.message.caption ?? "";
111
+ await ctx.replyWithChatAction("typing");
112
+ const ack = await ctx.reply("📎 Processing attachment…");
113
+ try {
114
+ const { data, mimeType, size } = await downloadTelegramFile(bot, photo.file_id);
115
+ if (size > FILE_SIZE_LIMIT_BYTES) {
116
+ await ctx.api.editMessageText(chatId, ack.message_id, "⚠️ File too large (max 5MB). Attachment not processed.");
117
+ return;
118
+ }
119
+ const attachment = { type: "blob", data, mimeType, displayName: "photo.jpg" };
120
+ const placeholder = await ctx.reply("…");
121
+ let accumulated = "";
122
+ let lastEditTime = 0;
123
+ let pendingEdit;
124
+ const editReply = async (content) => {
125
+ try {
126
+ const truncated = content.length > TELEGRAM_MAX_LENGTH
127
+ ? content.slice(0, TELEGRAM_MAX_LENGTH - 20) + "\n\n[…truncated]"
128
+ : content;
129
+ await ctx.api.editMessageText(chatId, placeholder.message_id, truncated);
130
+ }
131
+ catch (err) {
132
+ const message = err instanceof Error ? err.message : String(err);
133
+ if (!message.includes("message is not modified")) {
134
+ console.error("[io] Failed to edit message:", message);
135
+ }
136
+ }
137
+ };
138
+ await ctx.api.deleteMessage(chatId, ack.message_id);
139
+ await messageHandler(caption, chatId, messageId, (chunk, done) => {
140
+ accumulated += chunk;
141
+ if (done) {
142
+ if (pendingEdit) {
143
+ clearTimeout(pendingEdit);
144
+ pendingEdit = undefined;
145
+ }
146
+ return;
147
+ }
148
+ const now = Date.now();
149
+ const timeSinceLastEdit = now - lastEditTime;
150
+ if (timeSinceLastEdit >= EDIT_DEBOUNCE_MS) {
151
+ lastEditTime = now;
152
+ void editReply(accumulated);
153
+ }
154
+ else if (!pendingEdit) {
155
+ pendingEdit = setTimeout(() => {
156
+ pendingEdit = undefined;
157
+ lastEditTime = Date.now();
158
+ void editReply(accumulated);
159
+ }, EDIT_DEBOUNCE_MS - timeSinceLastEdit);
160
+ }
161
+ }, [attachment]);
162
+ if (pendingEdit)
163
+ clearTimeout(pendingEdit);
164
+ if (accumulated.length > 0)
165
+ await editReply(accumulated);
166
+ }
167
+ catch (err) {
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ console.error("[io] Error handling photo:", message);
170
+ await ctx.api.editMessageText(chatId, ack.message_id, "An error occurred while processing the attachment.");
171
+ }
172
+ });
173
+ // ---------------------------------------------------------------------------
174
+ // Document handler
175
+ // ---------------------------------------------------------------------------
176
+ bot.on("message:document", async (ctx) => {
177
+ const userId = ctx.from?.id;
178
+ if (config.authorizedUserId && userId !== config.authorizedUserId)
179
+ return;
180
+ if (!messageHandler || !bot) {
181
+ console.error("[io] No message handler registered");
182
+ return;
183
+ }
184
+ const doc = ctx.message.document;
185
+ const chatId = ctx.chat.id;
186
+ const messageId = ctx.message.message_id;
187
+ const caption = ctx.message.caption ?? "";
188
+ // Reject oversized files before downloading (file_size may be undefined for large files)
189
+ if (doc.file_size !== undefined && doc.file_size > FILE_SIZE_LIMIT_BYTES) {
190
+ await ctx.reply("⚠️ File too large (max 5MB). Attachment not processed.");
191
+ return;
192
+ }
193
+ await ctx.replyWithChatAction("typing");
194
+ const ack = await ctx.reply("📎 Processing attachment…");
195
+ try {
196
+ const { data, mimeType, size } = await downloadTelegramFile(bot, doc.file_id);
197
+ if (size > FILE_SIZE_LIMIT_BYTES) {
198
+ await ctx.api.editMessageText(chatId, ack.message_id, "⚠️ File too large (max 5MB). Attachment not processed.");
199
+ return;
200
+ }
201
+ const attachment = {
202
+ type: "blob",
203
+ data,
204
+ mimeType,
205
+ displayName: doc.file_name ?? "document",
206
+ };
207
+ const placeholder = await ctx.reply("…");
208
+ let accumulated = "";
209
+ let lastEditTime = 0;
210
+ let pendingEdit;
211
+ const editReply = async (content) => {
212
+ try {
213
+ const truncated = content.length > TELEGRAM_MAX_LENGTH
214
+ ? content.slice(0, TELEGRAM_MAX_LENGTH - 20) + "\n\n[…truncated]"
215
+ : content;
216
+ await ctx.api.editMessageText(chatId, placeholder.message_id, truncated);
217
+ }
218
+ catch (err) {
219
+ const message = err instanceof Error ? err.message : String(err);
220
+ if (!message.includes("message is not modified")) {
221
+ console.error("[io] Failed to edit message:", message);
222
+ }
223
+ }
224
+ };
225
+ await ctx.api.deleteMessage(chatId, ack.message_id);
226
+ await messageHandler(caption, chatId, messageId, (chunk, done) => {
227
+ accumulated += chunk;
228
+ if (done) {
229
+ if (pendingEdit) {
230
+ clearTimeout(pendingEdit);
231
+ pendingEdit = undefined;
232
+ }
233
+ return;
234
+ }
235
+ const now = Date.now();
236
+ const timeSinceLastEdit = now - lastEditTime;
237
+ if (timeSinceLastEdit >= EDIT_DEBOUNCE_MS) {
238
+ lastEditTime = now;
239
+ void editReply(accumulated);
240
+ }
241
+ else if (!pendingEdit) {
242
+ pendingEdit = setTimeout(() => {
243
+ pendingEdit = undefined;
244
+ lastEditTime = Date.now();
245
+ void editReply(accumulated);
246
+ }, EDIT_DEBOUNCE_MS - timeSinceLastEdit);
247
+ }
248
+ }, [attachment]);
249
+ if (pendingEdit)
250
+ clearTimeout(pendingEdit);
251
+ if (accumulated.length > 0)
252
+ await editReply(accumulated);
253
+ }
254
+ catch (err) {
255
+ const message = err instanceof Error ? err.message : String(err);
256
+ console.error("[io] Error handling document:", message);
257
+ await ctx.api.editMessageText(chatId, ack.message_id, "An error occurred while processing the attachment.");
258
+ }
259
+ });
85
260
  bot.catch((err) => {
86
261
  console.error("[io] Grammy bot error:", err.message);
87
262
  });
package/dist/wiki/fs.js CHANGED
@@ -149,4 +149,15 @@ export function writeLogFile(content) {
149
149
  export function getWikiDir() {
150
150
  return WIKI_DIR;
151
151
  }
152
+ /**
153
+ * Read all wiki pages for a squad by slug.
154
+ * Returns array of { path, content } for pages under pages/squads/{slug}/.
155
+ */
156
+ export function readSquadWikiPages(slug) {
157
+ const prefix = `pages/squads/${slug}/`;
158
+ return listPages()
159
+ .filter(p => p.startsWith(prefix))
160
+ .map(p => ({ path: p, content: readPage(p) ?? "" }))
161
+ .filter(entry => entry.content.length > 0);
162
+ }
152
163
  //# sourceMappingURL=fs.js.map
@@ -0,0 +1,54 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readSquadWikiPages, writePage, deletePage } from "./fs.js";
4
+ describe("readSquadWikiPages", () => {
5
+ it("returns empty array for non-existent squad", () => {
6
+ const pages = readSquadWikiPages("nonexistent-squad-xyz");
7
+ assert.ok(Array.isArray(pages));
8
+ assert.equal(pages.length, 0);
9
+ });
10
+ it("returns pages under pages/squads/{slug}/ prefix", () => {
11
+ const testSlug = `test-squad-${Date.now()}`;
12
+ const pagePath = `pages/squads/${testSlug}/workflow.md`;
13
+ try {
14
+ writePage(pagePath, "# Workflow Rules\nAlways use feature branches.");
15
+ const pages = readSquadWikiPages(testSlug);
16
+ assert.equal(pages.length, 1);
17
+ assert.equal(pages[0].path, pagePath);
18
+ assert.ok(pages[0].content.includes("feature branches"));
19
+ }
20
+ finally {
21
+ deletePage(pagePath);
22
+ }
23
+ });
24
+ it("filters out empty pages", () => {
25
+ const testSlug = `test-squad-empty-${Date.now()}`;
26
+ const pagePath = `pages/squads/${testSlug}/empty.md`;
27
+ try {
28
+ writePage(pagePath, "");
29
+ const pages = readSquadWikiPages(testSlug);
30
+ assert.equal(pages.length, 0);
31
+ }
32
+ finally {
33
+ deletePage(pagePath);
34
+ }
35
+ });
36
+ it("returns multiple pages for a squad", () => {
37
+ const testSlug = `test-squad-multi-${Date.now()}`;
38
+ const page1 = `pages/squads/${testSlug}/workflow.md`;
39
+ const page2 = `pages/squads/${testSlug}/coding-standards.md`;
40
+ try {
41
+ writePage(page1, "# Workflow\nUse PRs.");
42
+ writePage(page2, "# Standards\nESLint required.");
43
+ const pages = readSquadWikiPages(testSlug);
44
+ assert.equal(pages.length, 2);
45
+ const paths = pages.map(p => p.path).sort();
46
+ assert.deepEqual(paths, [page2, page1].sort());
47
+ }
48
+ finally {
49
+ deletePage(page1);
50
+ deletePage(page2);
51
+ }
52
+ });
53
+ });
54
+ //# sourceMappingURL=wiki-squad.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"