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:
parent
d57b117aea
commit
f78ec37f1b
4 changed files with 103 additions and 55 deletions
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
src/resources/extensions/remote-questions/http-client.ts
Normal file
76
src/resources/extensions/remote-questions/http-client.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue