refactor: extract shared HTTP client for remote-questions adapters (#1212)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-18 11:58:43 -06:00 committed by GitHub
parent d57b117aea
commit f78ec37f1b
4 changed files with 103 additions and 55 deletions

View file

@ -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<any> {
const headers: Record<string, string> = { 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<any> {
return apiRequest(`${DISCORD_API}${path}`, method, body, {
authScheme: "Bot",
authToken: this.token,
errorLabel: "Discord API",
});
}
}

View file

@ -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<any> {
const {
authScheme,
authToken,
safeErrorLength = 200,
errorLabel = "HTTP",
contentType,
} = options;
const headers: Record<string, string> = {};
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();
}

View file

@ -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<string, unknown>): Promise<Record<string, unknown>> {
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<string, unknown>;
return apiRequest(`${SLACK_API}/${method}`, "POST", params, {
...opts,
contentType: "application/json; charset=utf-8",
});
}
}

View file

@ -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<string, unknown>): Promise<any> {
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" },
);
}
}