diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index b0ea7fb46..f41e052c7 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -2,8 +2,9 @@ * Remote Questions — Discord adapter */ -import { PER_REQUEST_TIMEOUT_MS, type ChannelAdapter, type RemotePrompt, type RemoteDispatchResult, type RemoteAnswer, type RemotePromptRef } from "./types.js"; +import { type ChannelAdapter, type RemotePrompt, type RemoteDispatchResult, type RemoteAnswer, type RemotePromptRef } from "./types.js"; import { formatForDiscord, parseDiscordResponse, DISCORD_NUMBER_EMOJIS } from "./format.js"; +import { apiRequest } from "./http-client.js"; const DISCORD_API = "https://discord.com/api/v10"; @@ -137,23 +138,11 @@ export class DiscordAdapter implements ChannelAdapter { return parseDiscordResponse([], String(replies[0].content), prompt.questions); } - private async discordApi(method: string, path: string, body?: unknown): Promise { - const headers: Record = { Authorization: `Bot ${this.token}` }; - const init: RequestInit = { method, headers }; - if (body) { - headers["Content-Type"] = "application/json"; - init.body = JSON.stringify(body); - } - - init.signal = AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS); - const response = await fetch(`${DISCORD_API}${path}`, init); - if (response.status === 204) return {}; - if (!response.ok) { - const text = await response.text().catch(() => ""); - // Limit error body length to avoid leaking verbose Discord error responses - const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text; - throw new Error(`Discord API HTTP ${response.status}: ${safeText}`); - } - return response.json(); + private async discordApi(method: "GET" | "POST" | "PUT" | "DELETE", path: string, body?: unknown): Promise { + return apiRequest(`${DISCORD_API}${path}`, method, body, { + authScheme: "Bot", + authToken: this.token, + errorLabel: "Discord API", + }); } } diff --git a/src/resources/extensions/remote-questions/http-client.ts b/src/resources/extensions/remote-questions/http-client.ts new file mode 100644 index 000000000..ae3028409 --- /dev/null +++ b/src/resources/extensions/remote-questions/http-client.ts @@ -0,0 +1,76 @@ +/** + * Remote Questions — shared HTTP client + * + * Centralizes timeout, error handling, and JSON serialization logic + * used by all channel adapters (Discord, Slack, Telegram). + */ + +import { PER_REQUEST_TIMEOUT_MS } from "./types.js"; + +export interface ApiRequestOptions { + /** Authorization header scheme. Omit to skip the Authorization header entirely. */ + authScheme?: "Bearer" | "Bot"; + /** Token for the Authorization header. Ignored when authScheme is omitted. */ + authToken?: string; + /** Max chars of error body to include in thrown Error. Default 200. */ + safeErrorLength?: number; + /** Label used in error messages (e.g. "Discord API", "Slack API"). Default "HTTP". */ + errorLabel?: string; + /** Content-Type override. Default "application/json" when body is present. */ + contentType?: string; +} + +/** + * Makes an HTTP request with standardized timeout, error handling, and JSON + * serialization. + * + * - Sets `AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS)` on every request. + * - Serializes `body` as JSON and sets Content-Type when provided. + * - Returns `{}` for 204 No Content responses. + * - Truncates error response bodies to `safeErrorLength` chars (default 200). + */ +export async function apiRequest( + url: string, + method: "GET" | "POST" | "PUT" | "DELETE", + body?: unknown, + options: ApiRequestOptions = {}, +): Promise { + const { + authScheme, + authToken, + safeErrorLength = 200, + errorLabel = "HTTP", + contentType, + } = options; + + const headers: Record = {}; + if (authScheme && authToken) { + headers["Authorization"] = `${authScheme} ${authToken}`; + } + + const init: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS), + }; + + if (body !== undefined) { + headers["Content-Type"] = contentType ?? "application/json"; + init.body = JSON.stringify(body); + } + + const response = await fetch(url, init); + + if (response.status === 204) return {}; + + if (!response.ok) { + const text = await response.text().catch(() => ""); + const safeText = + text.length > safeErrorLength + ? text.slice(0, safeErrorLength) + "\u2026" + : text; + throw new Error(`${errorLabel} HTTP ${response.status}: ${safeText}`); + } + + return response.json(); +} diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts index c9e8cb811..f55f7e811 100644 --- a/src/resources/extensions/remote-questions/slack-adapter.ts +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -2,8 +2,9 @@ * Remote Questions — Slack adapter */ -import { PER_REQUEST_TIMEOUT_MS, type ChannelAdapter, type RemotePrompt, type RemoteDispatchResult, type RemoteAnswer, type RemotePromptRef } from "./types.js"; +import { type ChannelAdapter, type RemotePrompt, type RemoteDispatchResult, type RemoteAnswer, type RemotePromptRef } from "./types.js"; import { formatForSlack, parseSlackReply, parseSlackReactionResponse, SLACK_NUMBER_REACTION_NAMES } from "./format.js"; +import { apiRequest } from "./http-client.js"; const SLACK_API = "https://slack.com/api"; const SLACK_ACK_REACTION = "white_check_mark"; @@ -122,26 +123,19 @@ export class SlackAdapter implements ChannelAdapter { } private async slackApi(method: string, params: Record): Promise> { - const url = `${SLACK_API}/${method}`; const isGet = method === "conversations.replies" || method === "auth.test" || method === "reactions.get"; + const opts = { authScheme: "Bearer" as const, authToken: this.token, errorLabel: "Slack API" }; - let response: Response; if (isGet) { - const qs = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))).toString(); - response = await fetch(`${url}?${qs}`, { method: "GET", headers: { Authorization: `Bearer ${this.token}` }, signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS) }); - } else { - response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${this.token}`, - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify(params), - signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS), - }); + const qs = new URLSearchParams( + Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])), + ).toString(); + return apiRequest(`${SLACK_API}/${method}?${qs}`, "GET", undefined, opts); } - if (!response.ok) throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`); - return (await response.json()) as Record; + return apiRequest(`${SLACK_API}/${method}`, "POST", params, { + ...opts, + contentType: "application/json; charset=utf-8", + }); } } diff --git a/src/resources/extensions/remote-questions/telegram-adapter.ts b/src/resources/extensions/remote-questions/telegram-adapter.ts index 5108895a8..907909812 100644 --- a/src/resources/extensions/remote-questions/telegram-adapter.ts +++ b/src/resources/extensions/remote-questions/telegram-adapter.ts @@ -2,8 +2,9 @@ * Remote Questions — Telegram adapter */ -import { PER_REQUEST_TIMEOUT_MS, type ChannelAdapter, type RemotePrompt, type RemoteDispatchResult, type RemoteAnswer, type RemotePromptRef } from "./types.js"; +import { type ChannelAdapter, type RemotePrompt, type RemoteDispatchResult, type RemoteAnswer, type RemotePromptRef } from "./types.js"; import { formatForTelegram, parseTelegramResponse } from "./format.js"; +import { apiRequest } from "./http-client.js"; const TELEGRAM_API = "https://api.telegram.org"; @@ -138,23 +139,11 @@ export class TelegramAdapter implements ChannelAdapter { } private async telegramApi(method: string, params?: Record): Promise { - const url = `${TELEGRAM_API}/bot${this.token}/${method}`; - const init: RequestInit = { - method: "POST", - headers: { "Content-Type": "application/json" }, - signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS), - }; - - if (params) { - init.body = JSON.stringify(params); - } - - const response = await fetch(url, init); - if (!response.ok) { - const text = await response.text().catch(() => ""); - const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text; - throw new Error(`Telegram API HTTP ${response.status}: ${safeText}`); - } - return response.json(); + return apiRequest( + `${TELEGRAM_API}/bot${this.token}/${method}`, + "POST", + params, + { errorLabel: "Telegram API" }, + ); } }