openclaw/src/discord/rate-limited-api.ts

304 lines
7.9 KiB
TypeScript

/**
* Rate-limited Discord API wrapper
*
* Integrates the enhanced rate limiting system with Discord API calls
* to prevent rate limit errors proactively while maintaining throughput.
*
* @module discord/rate-limited-api
*/
import type { RequestClient } from "@buape/carbon";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
RateLimiter,
createProviderRateLimiterOptions,
rateLimiterRegistry,
type RateLimitResult,
} from "../infra/rate-limiter.js";
const log = createSubsystemLogger("discord/rate-limited");
/**
* Discord rate limits (from Discord API documentation)
* - Global: 50 requests per second
* - Per-route: varies by endpoint
* - Per-guild: 10 requests per 10 seconds
* - DM: 5 requests per second per recipient
*/
const DISCORD_GLOBAL_RATE_LIMIT = {
requestsPerSecond: 50,
burstSize: 100, // Allow bursts
};
const DISCORD_DM_RATE_LIMIT = {
requestsPerSecond: 5,
burstSize: 10,
};
const DISCORD_GUILD_RATE_LIMIT = {
requestsPerSecond: 1, // 10 per 10s
burstSize: 10,
};
export type DiscordRateLimitedApiOptions = {
accountId: string;
rest: RequestClient;
/** Enable verbose logging */
verbose?: boolean;
/** Override default rate limits */
customLimits?: {
global?: { requestsPerSecond: number; burstSize?: number };
dm?: { requestsPerSecond: number; burstSize?: number };
guild?: { requestsPerSecond: number; burstSize?: number };
};
};
/**
* Rate-limited wrapper for Discord API client
*/
export class DiscordRateLimitedApi {
private readonly accountId: string;
private readonly rest: RequestClient;
private readonly verbose: boolean;
private readonly globalLimiter: RateLimiter;
constructor(options: DiscordRateLimitedApiOptions) {
this.accountId = options.accountId;
this.rest = options.rest;
this.verbose = options.verbose ?? false;
// Create global rate limiter
const globalLimits = options.customLimits?.global ?? DISCORD_GLOBAL_RATE_LIMIT;
this.globalLimiter = rateLimiterRegistry.getOrCreate(
`discord:${options.accountId}:global`,
createProviderRateLimiterOptions({
provider: "discord",
accountId: `${options.accountId}:global`,
requestsPerSecond: globalLimits.requestsPerSecond,
burstSize: globalLimits.burstSize,
}),
);
}
/**
* Execute a Discord API request with rate limiting
* @param fn The API call to execute
* @param context Context for logging and rate limit selection
*/
async executeWithRateLimit<T>(
fn: () => Promise<T>,
context: {
endpoint: string;
channelId?: string;
guildId?: string;
userId?: string;
},
): Promise<T> {
const limiter = this.selectLimiter(context);
// Try to acquire rate limit token
const result = limiter.tryAcquire();
if (!result.allowed) {
if (this.verbose) {
log.warn(
`[discord:${this.accountId}] Rate limit hit for ${context.endpoint}. ` +
`Circuit: ${result.circuitState}, Retry after: ${result.retryAfter}ms`,
);
}
// Wait for rate limit to clear
const acquired = await limiter.waitAndAcquire();
if (!acquired) {
throw new Error(
`Discord rate limit timeout for ${context.endpoint} (circuit: ${result.circuitState})`,
);
}
}
try {
const response = await fn();
limiter.recordSuccess();
return response;
} catch (err) {
limiter.recordFailure();
throw err;
}
}
/**
* Select appropriate rate limiter based on context
*/
private selectLimiter(context: {
endpoint: string;
channelId?: string;
guildId?: string;
userId?: string;
}): RateLimiter {
// DM-specific limiter (per user)
if (context.userId && !context.guildId) {
const key = `discord:${this.accountId}:dm:${context.userId}`;
return rateLimiterRegistry.getOrCreate(
key,
createProviderRateLimiterOptions({
provider: "discord",
accountId: `${this.accountId}:dm:${context.userId}`,
requestsPerSecond: DISCORD_DM_RATE_LIMIT.requestsPerSecond,
burstSize: DISCORD_DM_RATE_LIMIT.burstSize,
}),
);
}
// Guild-specific limiter
if (context.guildId) {
const key = `discord:${this.accountId}:guild:${context.guildId}`;
return rateLimiterRegistry.getOrCreate(
key,
createProviderRateLimiterOptions({
provider: "discord",
accountId: `${this.accountId}:guild:${context.guildId}`,
requestsPerSecond: DISCORD_GUILD_RATE_LIMIT.requestsPerSecond,
burstSize: DISCORD_GUILD_RATE_LIMIT.burstSize,
}),
);
}
// Fall back to global limiter
return this.globalLimiter;
}
/**
* Get current rate limiting state
*/
getState(): {
accountId: string;
global: ReturnType<RateLimiter["getState"]>;
limiters: Map<string, RateLimiter>;
} {
return {
accountId: this.accountId,
global: this.globalLimiter.getState(),
limiters: rateLimiterRegistry.getAll(),
};
}
/**
* Get metrics for all Discord rate limiters
*/
getMetrics(): ReturnType<typeof rateLimiterRegistry.getAggregatedMetrics> {
return rateLimiterRegistry.getAggregatedMetrics();
}
}
/**
* Helper to wrap Discord REST API calls with rate limiting
*/
export function createRateLimitedDiscordClient(options: DiscordRateLimitedApiOptions) {
const api = new DiscordRateLimitedApi(options);
return {
/**
* Send a message to a channel
*/
async sendMessage(params: {
channelId: string;
content: string;
guildId?: string;
}): Promise<unknown> {
return api.executeWithRateLimit(
() =>
options.rest.post(
`/channels/${params.channelId}/messages` as `/channels/${string}/messages`,
{
body: { content: params.content },
},
),
{
endpoint: "createMessage",
channelId: params.channelId,
guildId: params.guildId,
},
);
},
/**
* Send a DM to a user
*/
async sendDirectMessage(params: { userId: string; content: string }): Promise<unknown> {
// First create DM channel
const dmChannel = await api.executeWithRateLimit(
() =>
options.rest.post("/users/@me/channels" as const, {
body: { recipient_id: params.userId },
}),
{
endpoint: "createDM",
userId: params.userId,
},
);
const channelId = (dmChannel as { id?: string }).id;
if (!channelId) {
throw new Error("Failed to create DM channel");
}
// Send message
return api.executeWithRateLimit(
() =>
options.rest.post(`/channels/${channelId}/messages` as `/channels/${string}/messages`, {
body: { content: params.content },
}),
{
endpoint: "createMessage",
channelId,
userId: params.userId,
},
);
},
/**
* Add a reaction to a message
*/
async addReaction(params: {
channelId: string;
messageId: string;
emoji: string;
guildId?: string;
}): Promise<void> {
await api.executeWithRateLimit(
() =>
options.rest.put(
`/channels/${params.channelId}/messages/${params.messageId}/reactions/${params.emoji}/@me` as `/channels/${string}/messages/${string}/reactions/${string}/@me`,
),
{
endpoint: "addReaction",
channelId: params.channelId,
guildId: params.guildId,
},
);
},
/**
* Get raw API instance (without rate limiting)
* Use with caution - bypasses rate limiting
*/
getRawRest(): RequestClient {
return options.rest;
},
/**
* Get current rate limiting state
*/
getState() {
return api.getState();
},
/**
* Get rate limiting metrics
*/
getMetrics() {
return api.getMetrics();
},
};
}