import {
Client,
GatewayIntentBits,
type TextChannel,
type Message,
Events,
} from "discord.js";
import type {
ChatProvider,
ChatResponse,
SendMessageOptions,
} from "../types.js";
import {
ChatConnectionError,
ChatNotConnectedError,
SendMessageError,
ChatResponseTimeoutError,
} from "../errors.js";
import type { DiscordConfig } from "./config.js";
export class DiscordProvider implements ChatProvider {
readonly name = "discord";
private client: Client | null = null;
private channel: TextChannel | null = null;
constructor(private readonly config: DiscordConfig) {}
async connect(): Promise<void> {
try {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
await this.client.login(this.config.apiKey);
// Fetch the target channel
const channel = await this.client.channels.fetch(this.config.channelId);
if (!channel?.isTextBased()) {
throw new Error("Channel is not a text channel");
}
this.channel = channel as TextChannel;
} catch (error) {
throw new ChatConnectionError(error instanceof Error ? error : undefined);
}
}
async disconnect(): Promise<void> {
if (this.client) {
this.client.destroy();
this.client = null;
this.channel = null;
}
}
isConnected(): boolean {
return this.client !== null && this.client.isReady();
}
async sendMessage(options: SendMessageOptions): Promise<ChatResponse> {
if (!this.isConnected() || !this.channel) {
throw new ChatNotConnectedError();
}
try {
const sentMessage = await this.channel.send(options.message);
// If no response required, return empty response
if (!options.requiresResponse) {
return { message: "", username: "" };
}
return await this.waitForResponse(sentMessage, options.timeoutMs);
} catch (error) {
if (error instanceof ChatResponseTimeoutError) {
throw error;
}
throw new SendMessageError(error instanceof Error ? error : undefined);
}
}
// Check if the message is a valid response
private isValidResponse(message: Message, originalMessage: Message): boolean {
if (message.channelId !== originalMessage.channelId) return false;
if (message.author.bot) return false;
if (message.id === originalMessage.id) return false;
if (
!this.isReplyTo(message, originalMessage) &&
!this.isMentioned(message)
) {
return false;
}
return true;
}
// Check if the message is a reply to the target message
private isReplyTo(message: Message, targetMessage: Message): boolean {
return message.reference?.messageId === targetMessage.id;
}
// Check if the bot is mentioned in the message
private isMentioned(message: Message): boolean {
return this.client?.user
? message.mentions.users.has(this.client.user.id)
: false;
}
// Format the response message
private formatResponseMessage(content: string): string {
// Remove mentions (<@userId> or <@!userId>)
return content.replace(/<@!?\d+>/g, "").trim();
}
private async waitForResponse(
originalMessage: Message,
timeoutMs: number
): Promise<ChatResponse> {
let messageHandler: ((message: Message) => void) | null = null;
let timeoutId: NodeJS.Timeout | null = null;
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
if (messageHandler) {
this.client?.off(Events.MessageCreate, messageHandler);
}
};
const getMessagePromise = new Promise<ChatResponse>((resolve, reject) => {
if (!this.client) {
return reject(new ChatNotConnectedError());
}
messageHandler = (message: Message) => {
if (!this.isValidResponse(message, originalMessage)) return;
resolve({
message: this.formatResponseMessage(message.content),
username: message.author.username,
});
};
this.client.on(Events.MessageCreate, messageHandler);
});
const timeoutPromise = new Promise<ChatResponse>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new ChatResponseTimeoutError(timeoutMs));
}, timeoutMs);
});
try {
// Return a message if the user responds, or reject if the timeout occurs.
return await Promise.race([getMessagePromise, timeoutPromise]);
} finally {
cleanup();
}
}
}