From c5a8d651e2e2e85d0d2e64d90001b4f5a01954e7 Mon Sep 17 00:00:00 2001 From: shizhigu Date: Thu, 30 Apr 2026 21:32:26 -0500 Subject: [PATCH] fix slack router send fan-out --- apps/server-v2/src/managed.test.ts | 107 ++++++++++++++++++ apps/server-v2/src/routes/managed.ts | 26 ++++- .../src/services/router-product-service.ts | 10 +- 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/apps/server-v2/src/managed.test.ts b/apps/server-v2/src/managed.test.ts index 6e27bbf2a..0f9998709 100644 --- a/apps/server-v2/src/managed.test.ts +++ b/apps/server-v2/src/managed.test.ts @@ -264,3 +264,110 @@ test("managed resource routes cover MCPs, plugins, skills, shares, export/import routerSendServer.stop(true); } }); + +test("router send requires an explicit Slack peer target", async () => { + const { app, dependencies, root } = createTestApp("openwork-server-v2-slack-direct-send"); + const workspaceRoot = path.join(root, "workspace-slack-send"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + + const createResponse = await app.request("http://openwork.local/workspaces/local", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folderPath: workspaceRoot, name: "Slack Send", preset: "starter" }), + }); + const workspaceId = (await createResponse.json()).data.id as string; + + let proxyCalls = 0; + const observedPayloads: Record[] = []; + const routerSendServer = Bun.serve({ + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/send" && request.method === "POST") { + proxyCalls += 1; + const payload = await request.json() as Record; + observedPayloads.push(payload); + return Response.json({ + attempted: 1, + channel: payload.channel, + ok: true, + peerId: payload.peerId, + sent: 1, + }); + } + return new Response("not found", { status: 404 }); + }, + hostname: "127.0.0.1", + port: 0, + }); + + try { + dependencies.services.runtime.getRouterHealth = () => ({ + baseUrl: `http://127.0.0.1:${routerSendServer.port}`, + binaryPath: null, + diagnostics: { combined: [], stderr: [], stdout: [], totalLines: 0, truncated: false }, + enablement: { enabled: true, enabledBindingCount: 0, enabledIdentityCount: 0, forced: false, reason: "test" }, + healthUrl: `http://127.0.0.1:${routerSendServer.port}`, + lastError: null, + lastExit: null, + lastReadyAt: null, + lastStartedAt: null, + manifest: null, + materialization: null, + pid: null, + running: true, + source: "development", + status: "running", + version: "test", + }); + dependencies.services.runtime.applyRouterConfig = async () => dependencies.services.runtime.getRouterHealth(); + + const unsafeSlackSend = await app.request(`http://openwork.local/workspace/${workspaceId}/opencode-router/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel: "slack", directory: workspaceRoot, text: "hello" }), + }); + const unsafeBody = await unsafeSlackSend.json(); + expect(unsafeSlackSend.status).toBe(400); + expect(unsafeBody.error.code).toBe("invalid_request"); + expect(unsafeBody.error.message).toContain("Slack sends require a peerId"); + expect(proxyCalls).toBe(0); + + const blankPeerSlackSend = await app.request(`http://openwork.local/workspace/${workspaceId}/opencode-router/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel: "slack", directory: workspaceRoot, peerId: " ", text: "hello" }), + }); + const blankPeerBody = await blankPeerSlackSend.json(); + expect(blankPeerSlackSend.status).toBe(400); + expect(blankPeerBody.error.code).toBe("invalid_request"); + expect(proxyCalls).toBe(0); + + const telegramDirectorySend = await app.request(`http://openwork.local/workspace/${workspaceId}/opencode-router/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel: "telegram", directory: workspaceRoot, text: "hello" }), + }); + const telegramBody = await telegramDirectorySend.json(); + expect(telegramDirectorySend.status).toBe(200); + expect(telegramBody.sent).toBe(1); + expect(proxyCalls).toBe(1); + const telegramPayload = observedPayloads[0] ?? {}; + expect(telegramPayload.channel).toBe("telegram"); + expect("peerId" in telegramPayload).toBe(false); + + const directSlackSend = await app.request(`http://openwork.local/workspace/${workspaceId}/opencode-router/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channel: "slack", directory: workspaceRoot, peerId: "C123", text: "hello" }), + }); + const directBody = await directSlackSend.json(); + expect(directSlackSend.status).toBe(200); + expect(directBody.sent).toBe(1); + expect(proxyCalls).toBe(2); + const directPayload = observedPayloads[1] ?? {}; + expect(directPayload.channel).toBe("slack"); + expect(directPayload.peerId).toBe("C123"); + } finally { + routerSendServer.stop(true); + } +}); diff --git a/apps/server-v2/src/routes/managed.ts b/apps/server-v2/src/routes/managed.ts index 2409abe6b..e32384479 100644 --- a/apps/server-v2/src/routes/managed.ts +++ b/apps/server-v2/src/routes/managed.ts @@ -1,7 +1,8 @@ import type { Context, Hono } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; import { describeRoute } from "hono-openapi"; import { getRequestContext, type AppBindings } from "../context/request-context.js"; -import { buildSuccessResponse, RouteError } from "../http.js"; +import { buildErrorResponse, buildSuccessResponse, RouteError } from "../http.js"; import { jsonResponse, withCommonErrorResponses } from "../openapi.js"; import { cloudSigninResponseSchema, @@ -72,6 +73,27 @@ function addCompatibilityRoute( if (method === "DELETE") app.delete(path, handler); } +async function sendRouterMessage(c: Context) { + const requestContext = getRequestContext(c); + try { + const body = await parseJsonBody(routerSendWriteSchema, c.req.raw); + return c.json(await requestContext.services.router.sendMessage(body)); + } catch (error) { + if (error instanceof RouteError) { + return c.json( + buildErrorResponse({ + requestId: requestContext.requestId, + code: error.code, + message: error.message, + details: error.details, + }), + error.status as ContentfulStatusCode, + ); + } + throw error; + } +} + export function registerManagedRoutes(app: Hono) { for (const kind of ["mcps", "plugins", "providerConfigs", "skills"] as const) { app.get( @@ -745,7 +767,7 @@ export function registerManagedRoutes(app: Hono) { const directory = body.directory?.trim() || workspace?.backend.local?.dataDir || ""; return c.json(await requestContext.services.router.setBinding({ channel: body.channel, directory, identityId: body.identityId, peerId: body.peerId })); }); - addCompatibilityRoute(app, "POST", `${basePath}/send`, async (c) => c.json(await getRequestContext(c).services.router.sendMessage(await parseJsonBody(routerSendWriteSchema, c.req.raw)))); + addCompatibilityRoute(app, "POST", `${basePath}/send`, sendRouterMessage); } addCompatibilityRoute(app, "GET", "/workspace/:workspaceId/export", async (c) => { diff --git a/apps/server-v2/src/services/router-product-service.ts b/apps/server-v2/src/services/router-product-service.ts index 01328d69e..bf51ff475 100644 --- a/apps/server-v2/src/services/router-product-service.ts +++ b/apps/server-v2/src/services/router-product-service.ts @@ -265,12 +265,20 @@ export function createRouterProductService(input: { peerId?: string; text: string; }) { + const peerId = normalizeString(inputValue.peerId); + if (inputValue.channel === "slack" && !peerId) { + throw new RouteError( + 400, + "invalid_request", + "Slack sends require a peerId. Pick one channel or thread target to avoid sending to every bound conversation.", + ); + } const payload = { ...(inputValue.autoBind ? { autoBind: true } : {}), channel: inputValue.channel, ...(normalizeString(inputValue.directory) ? { directory: normalizeString(inputValue.directory) } : {}), ...(normalizeString(inputValue.identityId) ? { identityId: normalizeString(inputValue.identityId) } : {}), - ...(normalizeString(inputValue.peerId) ? { peerId: normalizeString(inputValue.peerId) } : {}), + ...(peerId ? { peerId } : {}), text: inputValue.text, }; return await proxyRouter>("/send", { body: payload, method: "POST" });