Skip to content
Merged
77 changes: 76 additions & 1 deletion packages/adapter-discord/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { generateKeyPairSync, sign } from "node:crypto";
import { ValidationError } from "@chat-adapter/shared";
import type { ChatInstance, Logger } from "chat";
import { InteractionType } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDiscordAdapter, DiscordAdapter } from "./index";
import { DiscordFormatConverter } from "./markdown";

Expand Down Expand Up @@ -107,6 +107,81 @@ describe("createDiscordAdapter", () => {
});
});

// ============================================================================
// Constructor env var resolution
// ============================================================================

describe("constructor env var resolution", () => {
const savedEnv = { ...process.env };

beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith("DISCORD_")) {
delete process.env[key];
}
}
});

afterEach(() => {
process.env = { ...savedEnv };
});

it("should throw when botToken is missing and env var not set", () => {
expect(() => new DiscordAdapter({})).toThrow("botToken is required");
});

it("should throw when publicKey is missing and env var not set", () => {
expect(() => new DiscordAdapter({ botToken: "test" })).toThrow(
"publicKey is required"
);
});

it("should throw when applicationId is missing and env var not set", () => {
expect(
() => new DiscordAdapter({ botToken: "test", publicKey: testPublicKey })
).toThrow("applicationId is required");
});

it("should resolve all fields from env vars", () => {
process.env.DISCORD_BOT_TOKEN = "env-token";
process.env.DISCORD_PUBLIC_KEY = testPublicKey;
process.env.DISCORD_APPLICATION_ID = "env-app-id";
const adapter = new DiscordAdapter();
expect(adapter).toBeInstanceOf(DiscordAdapter);
expect(adapter.userName).toBe("bot");
});

it("should resolve mentionRoleIds from DISCORD_MENTION_ROLE_IDS env var", () => {
process.env.DISCORD_BOT_TOKEN = "env-token";
process.env.DISCORD_PUBLIC_KEY = testPublicKey;
process.env.DISCORD_APPLICATION_ID = "env-app-id";
process.env.DISCORD_MENTION_ROLE_IDS = "role1, role2, role3";
const adapter = new DiscordAdapter();
expect(adapter).toBeInstanceOf(DiscordAdapter);
});

it("should default logger when not provided", () => {
process.env.DISCORD_BOT_TOKEN = "env-token";
process.env.DISCORD_PUBLIC_KEY = testPublicKey;
process.env.DISCORD_APPLICATION_ID = "env-app-id";
const adapter = new DiscordAdapter();
expect(adapter).toBeInstanceOf(DiscordAdapter);
});

it("should prefer config values over env vars", () => {
process.env.DISCORD_BOT_TOKEN = "env-token";
process.env.DISCORD_PUBLIC_KEY = testPublicKey;
process.env.DISCORD_APPLICATION_ID = "env-app-id";
const adapter = new DiscordAdapter({
botToken: "config-token",
publicKey: testPublicKey,
applicationId: "config-app-id",
userName: "mybot",
});
expect(adapter.userName).toBe("mybot");
});
});

// ============================================================================
// Thread ID Encoding/Decoding Tests
// ============================================================================
Expand Down
91 changes: 37 additions & 54 deletions packages/adapter-discord/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,40 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
>();
private static readonly THREAD_PARENT_CACHE_TTL = 5 * 60 * 1000;

constructor(
config: DiscordAdapterConfig & { logger: Logger; userName?: string }
) {
this.botToken = config.botToken;
this.publicKey = config.publicKey.trim().toLowerCase();
this.applicationId = config.applicationId;
this.mentionRoleIds = config.mentionRoleIds ?? [];
this.botUserId = config.applicationId; // Discord app ID is the bot's user ID
this.logger = config.logger;
constructor(config: DiscordAdapterConfig = {}) {
const botToken = config.botToken ?? process.env.DISCORD_BOT_TOKEN;
if (!botToken) {
throw new ValidationError(
"discord",
"botToken is required. Set DISCORD_BOT_TOKEN or provide it in config."
);
}
const publicKey = config.publicKey ?? process.env.DISCORD_PUBLIC_KEY;
if (!publicKey) {
throw new ValidationError(
"discord",
"publicKey is required. Set DISCORD_PUBLIC_KEY or provide it in config."
);
}
const applicationId =
config.applicationId ?? process.env.DISCORD_APPLICATION_ID;
if (!applicationId) {
throw new ValidationError(
"discord",
"applicationId is required. Set DISCORD_APPLICATION_ID or provide it in config."
);
}

this.botToken = botToken;
this.publicKey = publicKey.trim().toLowerCase();
this.applicationId = applicationId;
this.mentionRoleIds =
config.mentionRoleIds ??
(process.env.DISCORD_MENTION_ROLE_IDS
? process.env.DISCORD_MENTION_ROLE_IDS.split(",").map((id) => id.trim())
: []);
this.botUserId = applicationId; // Discord app ID is the bot's user ID
this.logger = config.logger ?? new ConsoleLogger("info").child("discord");
this.userName = config.userName ?? "bot";

// Validate public key format
Expand All @@ -122,11 +147,7 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {

async initialize(chat: ChatInstance): Promise<void> {
this.chat = chat;
this.logger.info("Discord adapter initialized", {
applicationId: this.applicationId,
// Log full public key for debugging - it's public, not secret
publicKey: this.publicKey,
});
this.logger.info("Discord adapter initialized");
}

/**
Expand Down Expand Up @@ -2444,47 +2465,9 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
* Create a Discord adapter instance.
*/
export function createDiscordAdapter(
config?: Partial<DiscordAdapterConfig & { logger: Logger; userName?: string }>
config?: DiscordAdapterConfig
): DiscordAdapter {
const botToken = config?.botToken ?? process.env.DISCORD_BOT_TOKEN;
if (!botToken) {
throw new ValidationError(
"discord",
"botToken is required. Set DISCORD_BOT_TOKEN or provide it in config."
);
}
const publicKey = config?.publicKey ?? process.env.DISCORD_PUBLIC_KEY;
if (!publicKey) {
throw new ValidationError(
"discord",
"publicKey is required. Set DISCORD_PUBLIC_KEY or provide it in config."
);
}
const applicationId =
config?.applicationId ?? process.env.DISCORD_APPLICATION_ID;
if (!applicationId) {
throw new ValidationError(
"discord",
"applicationId is required. Set DISCORD_APPLICATION_ID or provide it in config."
);
}
const mentionRoleIds =
config?.mentionRoleIds ??
(process.env.DISCORD_MENTION_ROLE_IDS
? process.env.DISCORD_MENTION_ROLE_IDS.split(",").map((id) => id.trim())
: undefined);
const resolved: DiscordAdapterConfig & {
logger: Logger;
userName?: string;
} = {
botToken,
publicKey,
applicationId,
mentionRoleIds,
logger: config?.logger ?? new ConsoleLogger("info").child("discord"),
userName: config?.userName,
};
return new DiscordAdapter(resolved);
return new DiscordAdapter(config ?? {});
}

// Re-export card converter for advanced use
Expand Down
19 changes: 12 additions & 7 deletions packages/adapter-discord/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Discord adapter types.
*/

import type { Logger } from "chat";
import type {
APIEmbed,
APIMessage,
Expand All @@ -14,14 +15,18 @@ import type {
* Discord adapter configuration.
*/
export interface DiscordAdapterConfig {
/** Discord application ID */
applicationId: string;
/** Discord bot token */
botToken: string;
/** Role IDs that should trigger mention handlers (in addition to direct user mentions) */
/** Discord application ID. Defaults to DISCORD_APPLICATION_ID env var. */
applicationId?: string;
/** Discord bot token. Defaults to DISCORD_BOT_TOKEN env var. */
botToken?: string;
/** Logger instance for error reporting. Defaults to ConsoleLogger. */
logger?: Logger;
/** Role IDs that should trigger mention handlers (in addition to direct user mentions). Defaults to DISCORD_MENTION_ROLE_IDS env var (comma-separated). */
mentionRoleIds?: string[];
/** Discord application public key for webhook signature verification */
publicKey: string;
/** Discord application public key for webhook signature verification. Defaults to DISCORD_PUBLIC_KEY env var. */
publicKey?: string;
/** Override bot username (optional) */
userName?: string;
}

/**
Expand Down
79 changes: 78 additions & 1 deletion packages/adapter-gchat/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AdapterRateLimitError } from "@chat-adapter/shared";
import type { ChatInstance, Lock, Logger, StateAdapter } from "chat";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createGoogleChatAdapter,
GoogleChatAdapter,
Expand Down Expand Up @@ -245,6 +245,20 @@ describe("GoogleChatAdapter", () => {
});

describe("constructor / initialization", () => {
const savedEnv = { ...process.env };

beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith("GOOGLE_CHAT_")) {
delete process.env[key];
}
}
});

afterEach(() => {
process.env = { ...savedEnv };
});

it("should use provided userName", () => {
const adapter = createGoogleChatAdapter({
credentials: TEST_CREDENTIALS,
Expand Down Expand Up @@ -278,6 +292,13 @@ describe("GoogleChatAdapter", () => {
expect(adapter.name).toBe("gchat");
});

it("should default logger when not provided", () => {
const adapter = new GoogleChatAdapter({
useApplicationDefaultCredentials: true,
});
expect(adapter).toBeInstanceOf(GoogleChatAdapter);
});

it("should restore bot user ID from state on initialize", async () => {
const adapter = createGoogleChatAdapter({
credentials: TEST_CREDENTIALS,
Expand Down Expand Up @@ -309,6 +330,62 @@ describe("GoogleChatAdapter", () => {
});
});

describe("constructor env var resolution", () => {
const savedEnv = { ...process.env };

beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith("GOOGLE_CHAT_")) {
delete process.env[key];
}
}
});

afterEach(() => {
process.env = { ...savedEnv };
});

it("should throw when no auth is configured and no env vars set", () => {
expect(() => new GoogleChatAdapter()).toThrow(
"Authentication is required"
);
});

it("should resolve credentials from GOOGLE_CHAT_CREDENTIALS env var", () => {
process.env.GOOGLE_CHAT_CREDENTIALS = JSON.stringify(TEST_CREDENTIALS);
const adapter = new GoogleChatAdapter();
expect(adapter).toBeInstanceOf(GoogleChatAdapter);
});

it("should resolve ADC from GOOGLE_CHAT_USE_ADC env var", () => {
process.env.GOOGLE_CHAT_USE_ADC = "true";
const adapter = new GoogleChatAdapter();
expect(adapter).toBeInstanceOf(GoogleChatAdapter);
});

it("should resolve pubsubTopic from GOOGLE_CHAT_PUBSUB_TOPIC env var", () => {
process.env.GOOGLE_CHAT_CREDENTIALS = JSON.stringify(TEST_CREDENTIALS);
process.env.GOOGLE_CHAT_PUBSUB_TOPIC = "projects/test/topics/test";
const adapter = new GoogleChatAdapter();
expect(adapter).toBeInstanceOf(GoogleChatAdapter);
});

it("should resolve impersonateUser from GOOGLE_CHAT_IMPERSONATE_USER env var", () => {
process.env.GOOGLE_CHAT_CREDENTIALS = JSON.stringify(TEST_CREDENTIALS);
process.env.GOOGLE_CHAT_IMPERSONATE_USER = "user@example.com";
const adapter = new GoogleChatAdapter();
expect(adapter).toBeInstanceOf(GoogleChatAdapter);
});

it("should prefer config credentials over env vars", () => {
process.env.GOOGLE_CHAT_USE_ADC = "true";
const adapter = new GoogleChatAdapter({
credentials: TEST_CREDENTIALS,
});
expect(adapter).toBeInstanceOf(GoogleChatAdapter);
});
});

describe("isDM", () => {
it("should return true for DM thread IDs", () => {
const adapter = createGoogleChatAdapter({
Expand Down
Loading