diff --git a/packages/adapter-discord/src/index.test.ts b/packages/adapter-discord/src/index.test.ts index 1efba52f..9614fbe9 100644 --- a/packages/adapter-discord/src/index.test.ts +++ b/packages/adapter-discord/src/index.test.ts @@ -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"; @@ -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 // ============================================================================ diff --git a/packages/adapter-discord/src/index.ts b/packages/adapter-discord/src/index.ts index a9e9fa24..1b6f84a4 100644 --- a/packages/adapter-discord/src/index.ts +++ b/packages/adapter-discord/src/index.ts @@ -100,15 +100,40 @@ export class DiscordAdapter implements Adapter { >(); 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 @@ -122,11 +147,7 @@ export class DiscordAdapter implements Adapter { async initialize(chat: ChatInstance): Promise { 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"); } /** @@ -2444,47 +2465,9 @@ export class DiscordAdapter implements Adapter { * Create a Discord adapter instance. */ export function createDiscordAdapter( - config?: Partial + 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 diff --git a/packages/adapter-discord/src/types.ts b/packages/adapter-discord/src/types.ts index d752bc66..caaf66c2 100644 --- a/packages/adapter-discord/src/types.ts +++ b/packages/adapter-discord/src/types.ts @@ -2,6 +2,7 @@ * Discord adapter types. */ +import type { Logger } from "chat"; import type { APIEmbed, APIMessage, @@ -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; } /** diff --git a/packages/adapter-gchat/src/index.test.ts b/packages/adapter-gchat/src/index.test.ts index bf832f7f..b6e937ff 100644 --- a/packages/adapter-gchat/src/index.test.ts +++ b/packages/adapter-gchat/src/index.test.ts @@ -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, @@ -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, @@ -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, @@ -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({ diff --git a/packages/adapter-gchat/src/index.ts b/packages/adapter-gchat/src/index.ts index c8e06f1a..db1df548 100644 --- a/packages/adapter-gchat/src/index.ts +++ b/packages/adapter-gchat/src/index.ts @@ -80,14 +80,16 @@ export interface GoogleChatAdapterBaseConfig { * User email to impersonate for Workspace Events API calls. * Required when using domain-wide delegation. * This user must have access to the Chat spaces you want to subscribe to. + * Defaults to GOOGLE_CHAT_IMPERSONATE_USER env var. */ impersonateUser?: string; - /** Logger instance for error reporting */ - logger: Logger; + /** Logger instance for error reporting. Defaults to ConsoleLogger. */ + logger?: Logger; /** * Pub/Sub topic for receiving all messages via Workspace Events. * When set, the adapter will automatically create subscriptions when added to a space. * Format: "projects/my-project/topics/my-topic" + * Defaults to GOOGLE_CHAT_PUBSUB_TOPIC env var. */ pubsubTopic?: string; /** Override bot username (optional) */ @@ -98,7 +100,7 @@ export interface GoogleChatAdapterBaseConfig { export interface GoogleChatAdapterServiceAccountConfig extends GoogleChatAdapterBaseConfig { auth?: never; - /** Service account credentials JSON */ + /** Service account credentials JSON. Defaults to GOOGLE_CHAT_CREDENTIALS env var (JSON). */ credentials: ServiceAccountCredentials; useApplicationDefaultCredentials?: never; } @@ -115,6 +117,7 @@ export interface GoogleChatAdapterADCConfig * - Workload Identity Federation (external_account JSON) * - GCE/Cloud Run/Cloud Functions default service account * - gcloud auth application-default login (local development) + * Defaults to GOOGLE_CHAT_USE_ADC env var. */ useApplicationDefaultCredentials: true; } @@ -128,10 +131,19 @@ export interface GoogleChatAdapterCustomAuthConfig useApplicationDefaultCredentials?: never; } +/** Config with no auth fields - will auto-detect from env vars */ +export interface GoogleChatAdapterAutoConfig + extends GoogleChatAdapterBaseConfig { + auth?: never; + credentials?: never; + useApplicationDefaultCredentials?: never; +} + export type GoogleChatAdapterConfig = | GoogleChatAdapterServiceAccountConfig | GoogleChatAdapterADCConfig - | GoogleChatAdapterCustomAuthConfig; + | GoogleChatAdapterCustomAuthConfig + | GoogleChatAdapterAutoConfig; // Re-export GoogleChatThreadId from thread-utils export type { GoogleChatThreadId } from "./thread-utils"; @@ -268,13 +280,17 @@ export class GoogleChatAdapter implements Adapter { /** User info cache for display name lookups - initialized later in initialize() */ private userInfoCache: UserInfoCache; - constructor(config: GoogleChatAdapterConfig) { - this.logger = config.logger; + constructor( + config: GoogleChatAdapterConfig = {} as GoogleChatAdapterAutoConfig + ) { + this.logger = config.logger ?? new ConsoleLogger("info").child("gchat"); this.userName = config.userName || "bot"; // Initialize with null state - will be updated in initialize() this.userInfoCache = new UserInfoCache(null, this.logger); - this.pubsubTopic = config.pubsubTopic; - this.impersonateUser = config.impersonateUser; + this.pubsubTopic = + config.pubsubTopic ?? process.env.GOOGLE_CHAT_PUBSUB_TOPIC; + this.impersonateUser = + config.impersonateUser ?? process.env.GOOGLE_CHAT_IMPERSONATE_USER; this.endpointUrl = config.endpointUrl; let authClient: Parameters[0]["auth"]; @@ -311,10 +327,25 @@ export class GoogleChatAdapter implements Adapter { // Custom auth client provided directly (e.g., Vercel OIDC) this.customAuth = config.auth; authClient = config.auth; + } else if (process.env.GOOGLE_CHAT_CREDENTIALS) { + // Auto-detect from env vars: service account credentials + const credentialsJson = JSON.parse( + process.env.GOOGLE_CHAT_CREDENTIALS + ) as ServiceAccountCredentials; + this.credentials = credentialsJson; + authClient = new auth.JWT({ + email: credentialsJson.client_email, + key: credentialsJson.private_key, + scopes, + }); + } else if (process.env.GOOGLE_CHAT_USE_ADC === "true") { + // Auto-detect from env vars: ADC + this.useADC = true; + authClient = new auth.GoogleAuth({ scopes }); } else { throw new ValidationError( "gchat", - "GoogleChatAdapter requires one of: credentials, useApplicationDefaultCredentials, or auth" + "Authentication is required. Set GOOGLE_CHAT_CREDENTIALS or GOOGLE_CHAT_USE_ADC=true, or provide credentials/auth in config." ); } @@ -385,7 +416,6 @@ export class GoogleChatAdapter implements Adapter { this.logger.info("onThreadSubscribe called", { threadId, hasPubsubTopic: !!this.pubsubTopic, - pubsubTopic: this.pubsubTopic, }); if (!this.pubsubTopic) { @@ -513,7 +543,6 @@ export class GoogleChatAdapter implements Adapter { this.logger.info("Creating Workspace Events subscription", { spaceName, - pubsubTopic, }); const result = await createSpaceSubscription( @@ -2501,80 +2530,10 @@ export class GoogleChatAdapter implements Adapter { } } -export function createGoogleChatAdapter(config?: { - auth?: Parameters[0]["auth"]; - credentials?: ServiceAccountCredentials; - endpointUrl?: string; - impersonateUser?: string; - logger?: Logger; - pubsubTopic?: string; - useApplicationDefaultCredentials?: boolean; - userName?: string; -}): GoogleChatAdapter { - const logger = config?.logger ?? new ConsoleLogger("info").child("gchat"); - - // Auto-detect auth mode. Only fall back to env vars for auth fields when - // the caller hasn't provided ANY auth field, so we don't mix auth modes. - const hasAuthConfig = !!( - config?.auth || - config?.credentials || - config?.useApplicationDefaultCredentials - ); - - if (config?.auth) { - return new GoogleChatAdapter({ - auth: config.auth, - endpointUrl: config.endpointUrl, - impersonateUser: - config.impersonateUser ?? process.env.GOOGLE_CHAT_IMPERSONATE_USER, - logger, - pubsubTopic: config.pubsubTopic ?? process.env.GOOGLE_CHAT_PUBSUB_TOPIC, - userName: config.userName, - }); - } - - // Service account credentials from config or env - let credentialsJson = config?.credentials; - if ( - !(credentialsJson || hasAuthConfig) && - process.env.GOOGLE_CHAT_CREDENTIALS - ) { - credentialsJson = JSON.parse( - process.env.GOOGLE_CHAT_CREDENTIALS - ) as ServiceAccountCredentials; - } - if (credentialsJson) { - return new GoogleChatAdapter({ - credentials: credentialsJson, - endpointUrl: config?.endpointUrl, - impersonateUser: - config?.impersonateUser ?? process.env.GOOGLE_CHAT_IMPERSONATE_USER, - logger, - pubsubTopic: config?.pubsubTopic ?? process.env.GOOGLE_CHAT_PUBSUB_TOPIC, - userName: config?.userName, - }); - } - - // Application Default Credentials - if ( - config?.useApplicationDefaultCredentials || - (!hasAuthConfig && process.env.GOOGLE_CHAT_USE_ADC === "true") - ) { - return new GoogleChatAdapter({ - useApplicationDefaultCredentials: true, - endpointUrl: config?.endpointUrl, - impersonateUser: - config?.impersonateUser ?? process.env.GOOGLE_CHAT_IMPERSONATE_USER, - logger, - pubsubTopic: config?.pubsubTopic ?? process.env.GOOGLE_CHAT_PUBSUB_TOPIC, - userName: config?.userName, - }); - } - - throw new ValidationError( - "gchat", - "Authentication is required. Set GOOGLE_CHAT_CREDENTIALS or GOOGLE_CHAT_USE_ADC=true, or provide credentials/auth in config." - ); +export function createGoogleChatAdapter( + config?: GoogleChatAdapterConfig +): GoogleChatAdapter { + return new GoogleChatAdapter(config); } // Re-export card converter for advanced use diff --git a/packages/adapter-github/src/index.test.ts b/packages/adapter-github/src/index.test.ts index f8442799..d80f0a07 100644 --- a/packages/adapter-github/src/index.test.ts +++ b/packages/adapter-github/src/index.test.ts @@ -229,7 +229,7 @@ describe("GitHubAdapter", () => { userName: "bot", logger: mockLogger, } as never) - ).toThrow("GitHubAdapter requires either token or appId/privateKey"); + ).toThrow("Authentication is required"); }); it("should set botUserId when provided in config", () => { diff --git a/packages/adapter-github/src/index.ts b/packages/adapter-github/src/index.ts index fa857ac1..f8f0b24c 100644 --- a/packages/adapter-github/src/index.ts +++ b/packages/adapter-github/src/index.ts @@ -23,6 +23,7 @@ import { ConsoleLogger, convertEmojiPlaceholders, Message } from "chat"; import { cardToGitHubMarkdown } from "./cards"; import { GitHubFormatConverter } from "./markdown"; import type { + GitHubAdapterAutoConfig, GitHubAdapterConfig, GitHubIssueComment, GitHubRawMessage, @@ -125,17 +126,39 @@ export class GitHubAdapter return this.appCredentials !== null && this.octokit === null; } - constructor(config: GitHubAdapterConfig) { - this.webhookSecret = config.webhookSecret; - this.logger = config.logger; - this.userName = config.userName; + constructor(config: GitHubAdapterConfig = {} as GitHubAdapterAutoConfig) { + const webhookSecret = + config.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET; + if (!webhookSecret) { + throw new ValidationError( + "github", + "webhookSecret is required. Set GITHUB_WEBHOOK_SECRET or provide it in config." + ); + } + this.webhookSecret = webhookSecret; + this.logger = config.logger ?? new ConsoleLogger("info").child("github"); + this.userName = + config.userName ?? process.env.GITHUB_BOT_USERNAME ?? "github-bot"; this._botUserId = config.botUserId ?? null; - // Create Octokit instance based on auth method + // Create Octokit instance based on auth method. + // Only fall back to env vars when NO auth field was explicitly provided, + // so we don't mix auth modes. + const hasExplicitAuth = !!( + ("token" in config && config.token) || + ("appId" in config && config.appId) || + ("privateKey" in config && config.privateKey) + ); + if ("token" in config && config.token) { // PAT mode - single Octokit instance this.octokit = new Octokit({ auth: config.token }); - } else if ("appId" in config && config.appId) { + } else if ( + "appId" in config && + config.appId && + "privateKey" in config && + config.privateKey + ) { if ("installationId" in config && config.installationId) { // Single-tenant app mode - fixed installation this.octokit = new Octokit({ @@ -156,10 +179,42 @@ export class GitHubAdapter "GitHub adapter initialized in multi-tenant mode (installation ID will be extracted from webhooks)" ); } - } else { - throw new Error( - "GitHubAdapter requires either token or appId/privateKey" + } else if (hasExplicitAuth) { + // Some auth fields were provided but not enough to configure + throw new ValidationError( + "github", + "Authentication is required. Set GITHUB_TOKEN or GITHUB_APP_ID/GITHUB_PRIVATE_KEY, or provide token/appId+privateKey in config." ); + } else { + // Auto-detect from env vars + const token = process.env.GITHUB_TOKEN; + if (token) { + this.octokit = new Octokit({ auth: token }); + } else { + const appId = process.env.GITHUB_APP_ID; + const privateKey = process.env.GITHUB_PRIVATE_KEY; + if (appId && privateKey) { + const installationIdRaw = process.env.GITHUB_INSTALLATION_ID + ? Number.parseInt(process.env.GITHUB_INSTALLATION_ID, 10) + : undefined; + if (installationIdRaw) { + this.octokit = new Octokit({ + authStrategy: createAppAuth, + auth: { appId, privateKey, installationId: installationIdRaw }, + }); + } else { + this.appCredentials = { appId, privateKey }; + this.logger.info( + "GitHub adapter initialized in multi-tenant mode (installation ID will be extracted from webhooks)" + ); + } + } else { + throw new ValidationError( + "github", + "Authentication is required. Set GITHUB_TOKEN or GITHUB_APP_ID/GITHUB_PRIVATE_KEY, or provide token/appId+privateKey in config." + ); + } + } } } @@ -1225,84 +1280,8 @@ export class GitHubAdapter * }); * ``` */ -export function createGitHubAdapter(config?: { - appId?: string; - botUserId?: number; - installationId?: number; - logger?: Logger; - privateKey?: string; - token?: string; - userName?: string; - webhookSecret?: string; -}): GitHubAdapter { - const logger = config?.logger ?? new ConsoleLogger("info").child("github"); - const webhookSecret = - config?.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET; - if (!webhookSecret) { - throw new ValidationError( - "github", - "webhookSecret is required. Set GITHUB_WEBHOOK_SECRET or provide it in config." - ); - } - const userName = - config?.userName ?? process.env.GITHUB_BOT_USERNAME ?? "github-bot"; - - // Auto-detect auth mode. Only fall back to env vars for auth fields when - // the caller hasn't provided ANY auth field, so we don't mix auth modes. - const hasAuthConfig = !!( - config?.token || - config?.appId || - config?.privateKey - ); - - const token = - config?.token ?? (hasAuthConfig ? undefined : process.env.GITHUB_TOKEN); - if (token) { - return new GitHubAdapter({ - token, - webhookSecret, - userName, - botUserId: config?.botUserId, - logger, - }); - } - - const appId = - config?.appId ?? (hasAuthConfig ? undefined : process.env.GITHUB_APP_ID); - const privateKey = - config?.privateKey ?? - (hasAuthConfig ? undefined : process.env.GITHUB_PRIVATE_KEY); - if (appId && privateKey) { - const installationIdRaw = - config?.installationId ?? - (process.env.GITHUB_INSTALLATION_ID - ? Number.parseInt(process.env.GITHUB_INSTALLATION_ID, 10) - : undefined); - if (installationIdRaw) { - // Single-tenant app mode - return new GitHubAdapter({ - appId, - privateKey, - installationId: installationIdRaw, - webhookSecret, - userName, - botUserId: config?.botUserId, - logger, - }); - } - // Multi-tenant app mode - return new GitHubAdapter({ - appId, - privateKey, - webhookSecret, - userName, - botUserId: config?.botUserId, - logger, - }); - } - - throw new ValidationError( - "github", - "Authentication is required. Set GITHUB_TOKEN or GITHUB_APP_ID/GITHUB_PRIVATE_KEY, or provide token/appId+privateKey in config." - ); +export function createGitHubAdapter( + config?: GitHubAdapterConfig +): GitHubAdapter { + return new GitHubAdapter(config); } diff --git a/packages/adapter-github/src/types.ts b/packages/adapter-github/src/types.ts index d9c7a738..5c90716f 100644 --- a/packages/adapter-github/src/types.ts +++ b/packages/adapter-github/src/types.ts @@ -17,18 +17,20 @@ interface GitHubAdapterBaseConfig { * Used for self-message detection. If not provided, will be fetched on first API call. */ botUserId?: number; - /** Logger instance for error reporting */ - logger: Logger; + /** Logger instance for error reporting. Defaults to ConsoleLogger. */ + logger?: Logger; /** * Bot username (e.g., "my-bot" or "my-bot[bot]" for GitHub Apps). * Used for @-mention detection. + * Defaults to GITHUB_BOT_USERNAME env var or "github-bot". */ - userName: string; + userName?: string; /** * Webhook secret for HMAC-SHA256 verification. * Set this in your GitHub webhook settings. + * Defaults to GITHUB_WEBHOOK_SECRET env var. */ - webhookSecret: string; + webhookSecret?: string; } /** @@ -73,13 +75,24 @@ export interface GitHubAdapterMultiTenantAppConfig token?: never; } +/** + * Configuration with no auth fields - will auto-detect from env vars. + */ +export interface GitHubAdapterAutoConfig extends GitHubAdapterBaseConfig { + appId?: never; + installationId?: never; + privateKey?: never; + token?: never; +} + /** * GitHub adapter configuration - PAT, single-tenant App, or multi-tenant App. */ export type GitHubAdapterConfig = | GitHubAdapterPATConfig | GitHubAdapterAppConfig - | GitHubAdapterMultiTenantAppConfig; + | GitHubAdapterMultiTenantAppConfig + | GitHubAdapterAutoConfig; // ============================================================================= // Thread ID diff --git a/packages/adapter-linear/src/index.test.ts b/packages/adapter-linear/src/index.test.ts index 25b2f0f2..48be8720 100644 --- a/packages/adapter-linear/src/index.test.ts +++ b/packages/adapter-linear/src/index.test.ts @@ -421,9 +421,7 @@ describe("constructor", () => { userName: "my-bot", logger: createMockLogger(), } as never) - ).toThrow( - "LinearAdapter requires either apiKey, accessToken, or clientId/clientSecret" - ); + ).toThrow("Authentication is required"); }); it("should have undefined botUserId before initialization", () => { diff --git a/packages/adapter-linear/src/index.ts b/packages/adapter-linear/src/index.ts index 18657be3..98ac9490 100644 --- a/packages/adapter-linear/src/index.ts +++ b/packages/adapter-linear/src/index.ts @@ -21,6 +21,7 @@ import { cardToLinearMarkdown } from "./cards"; import { LinearFormatConverter } from "./markdown"; import type { CommentWebhookPayload, + LinearAdapterAutoConfig, LinearAdapterConfig, LinearCommentData, LinearRawMessage, @@ -111,10 +112,19 @@ export class LinearAdapter return this._botUserId ?? undefined; } - constructor(config: LinearAdapterConfig) { - this.webhookSecret = config.webhookSecret; - this.logger = config.logger; - this.userName = config.userName; + constructor(config: LinearAdapterConfig = {} as LinearAdapterAutoConfig) { + const webhookSecret = + config.webhookSecret ?? process.env.LINEAR_WEBHOOK_SECRET; + if (!webhookSecret) { + throw new ValidationError( + "linear", + "webhookSecret is required. Set LINEAR_WEBHOOK_SECRET or provide it in config." + ); + } + this.webhookSecret = webhookSecret; + this.logger = config.logger ?? new ConsoleLogger("info").child("linear"); + this.userName = + config.userName ?? process.env.LINEAR_BOT_USERNAME ?? "linear-bot"; // Create LinearClient based on auth method // @see https://linear.app/developers/sdk @@ -131,9 +141,27 @@ export class LinearAdapter clientSecret: config.clientSecret, }; } else { - throw new Error( - "LinearAdapter requires either apiKey, accessToken, or clientId/clientSecret" - ); + // Auto-detect from env vars + const apiKey = process.env.LINEAR_API_KEY; + if (apiKey) { + this.linearClient = new LinearClient({ apiKey }); + } else { + const accessToken = process.env.LINEAR_ACCESS_TOKEN; + if (accessToken) { + this.linearClient = new LinearClient({ accessToken }); + } else { + const clientId = process.env.LINEAR_CLIENT_ID; + const clientSecret = process.env.LINEAR_CLIENT_SECRET; + if (clientId && clientSecret) { + this.clientCredentials = { clientId, clientSecret }; + } else { + throw new ValidationError( + "linear", + "Authentication is required. Set LINEAR_API_KEY, LINEAR_ACCESS_TOKEN, or LINEAR_CLIENT_ID/LINEAR_CLIENT_SECRET, or provide auth in config." + ); + } + } + } } } @@ -902,77 +930,8 @@ export class LinearAdapter * }); * ``` */ -export function createLinearAdapter(config?: { - accessToken?: string; - apiKey?: string; - clientId?: string; - clientSecret?: string; - logger?: Logger; - userName?: string; - webhookSecret?: string; -}): LinearAdapter { - const logger = config?.logger ?? new ConsoleLogger("info").child("linear"); - const webhookSecret = - config?.webhookSecret ?? process.env.LINEAR_WEBHOOK_SECRET; - if (!webhookSecret) { - throw new ValidationError( - "linear", - "webhookSecret is required. Set LINEAR_WEBHOOK_SECRET or provide it in config." - ); - } - const userName = - config?.userName ?? process.env.LINEAR_BOT_USERNAME ?? "linear-bot"; - - // Auto-detect auth mode. Only fall back to env vars for auth fields when - // the caller hasn't provided ANY auth field, so we don't mix auth modes. - const hasAuthConfig = !!( - config?.apiKey || - config?.accessToken || - config?.clientId || - config?.clientSecret - ); - - const apiKey = - config?.apiKey ?? (hasAuthConfig ? undefined : process.env.LINEAR_API_KEY); - if (apiKey) { - return new LinearAdapter({ - apiKey, - webhookSecret, - userName, - logger, - }); - } - - const accessToken = - config?.accessToken ?? - (hasAuthConfig ? undefined : process.env.LINEAR_ACCESS_TOKEN); - if (accessToken) { - return new LinearAdapter({ - accessToken, - webhookSecret, - userName, - logger, - }); - } - - const clientId = - config?.clientId ?? - (hasAuthConfig ? undefined : process.env.LINEAR_CLIENT_ID); - const clientSecret = - config?.clientSecret ?? - (hasAuthConfig ? undefined : process.env.LINEAR_CLIENT_SECRET); - if (clientId && clientSecret) { - return new LinearAdapter({ - clientId, - clientSecret, - webhookSecret, - userName, - logger, - }); - } - - throw new ValidationError( - "linear", - "Authentication is required. Set LINEAR_API_KEY, LINEAR_ACCESS_TOKEN, or LINEAR_CLIENT_ID/LINEAR_CLIENT_SECRET, or provide auth in config." - ); +export function createLinearAdapter( + config?: LinearAdapterConfig +): LinearAdapter { + return new LinearAdapter(config); } diff --git a/packages/adapter-linear/src/types.ts b/packages/adapter-linear/src/types.ts index 45b60fbb..af13f39a 100644 --- a/packages/adapter-linear/src/types.ts +++ b/packages/adapter-linear/src/types.ts @@ -15,19 +15,21 @@ import type { Logger } from "chat"; * Base configuration options shared by all auth methods. */ interface LinearAdapterBaseConfig { - /** Logger instance for error reporting */ - logger: Logger; + /** Logger instance for error reporting. Defaults to ConsoleLogger. */ + logger?: Logger; /** * Bot display name used for @-mention detection. * For API key auth, this is typically the user's display name. * For OAuth app auth with actor=app, this is the app name. + * Defaults to LINEAR_BOT_USERNAME env var or "linear-bot". */ - userName: string; + userName?: string; /** * Webhook signing secret for HMAC-SHA256 verification. * Found on the webhook detail page in Linear settings. + * Defaults to LINEAR_WEBHOOK_SECRET env var. */ - webhookSecret: string; + webhookSecret?: string; } /** @@ -38,8 +40,10 @@ interface LinearAdapterBaseConfig { */ export interface LinearAdapterAPIKeyConfig extends LinearAdapterBaseConfig { accessToken?: never; - /** Personal API key from Linear Settings > Security & Access */ + /** Personal API key from Linear Settings > Security & Access. Defaults to LINEAR_API_KEY env var. */ apiKey: string; + clientId?: never; + clientSecret?: never; } /** @@ -49,7 +53,7 @@ export interface LinearAdapterAPIKeyConfig extends LinearAdapterBaseConfig { * @see https://linear.app/developers/oauth-2-0-authentication */ export interface LinearAdapterOAuthConfig extends LinearAdapterBaseConfig { - /** OAuth access token obtained through the OAuth flow */ + /** OAuth access token obtained through the OAuth flow. Defaults to LINEAR_ACCESS_TOKEN env var. */ accessToken: string; apiKey?: never; clientId?: never; @@ -68,19 +72,30 @@ export interface LinearAdapterOAuthConfig extends LinearAdapterBaseConfig { export interface LinearAdapterAppConfig extends LinearAdapterBaseConfig { accessToken?: never; apiKey?: never; - /** OAuth application client ID */ + /** OAuth application client ID. Defaults to LINEAR_CLIENT_ID env var. */ clientId: string; - /** OAuth application client secret */ + /** OAuth application client secret. Defaults to LINEAR_CLIENT_SECRET env var. */ clientSecret: string; } +/** + * Configuration with no auth fields - will auto-detect from env vars. + */ +export interface LinearAdapterAutoConfig extends LinearAdapterBaseConfig { + accessToken?: never; + apiKey?: never; + clientId?: never; + clientSecret?: never; +} + /** * Linear adapter configuration - API Key, OAuth token, or OAuth App (client credentials). */ export type LinearAdapterConfig = | LinearAdapterAPIKeyConfig | LinearAdapterOAuthConfig - | LinearAdapterAppConfig; + | LinearAdapterAppConfig + | LinearAdapterAutoConfig; // ============================================================================= // Thread ID diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 95a3c56a..3bb9878f 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -10,7 +10,7 @@ import type { Logger, StateAdapter, } from "chat"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SlackInstallation } from "./index"; import { createSlackAdapter, SlackAdapter } from "./index"; @@ -102,6 +102,58 @@ describe("createSlackAdapter", () => { }); }); +// ============================================================================ +// 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("SLACK_")) { + delete process.env[key]; + } + } + }); + + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("should throw when signingSecret is missing and env var not set", () => { + expect(() => new SlackAdapter({})).toThrow("signingSecret is required"); + }); + + it("should resolve signingSecret from SLACK_SIGNING_SECRET env var", () => { + process.env.SLACK_SIGNING_SECRET = "env-signing-secret"; + const adapter = new SlackAdapter(); + expect(adapter).toBeInstanceOf(SlackAdapter); + }); + + it("should resolve botToken from SLACK_BOT_TOKEN in zero-config mode", () => { + process.env.SLACK_SIGNING_SECRET = "env-signing-secret"; + process.env.SLACK_BOT_TOKEN = "xoxb-env-token"; + const adapter = new SlackAdapter(); + expect(adapter).toBeInstanceOf(SlackAdapter); + }); + + it("should default logger when not provided", () => { + process.env.SLACK_SIGNING_SECRET = "env-signing-secret"; + const adapter = new SlackAdapter(); + expect(adapter).toBeInstanceOf(SlackAdapter); + }); + + it("should prefer config values over env vars", () => { + process.env.SLACK_SIGNING_SECRET = "env-secret"; + const adapter = new SlackAdapter({ + signingSecret: "config-secret", + logger: mockLogger, + }); + expect(adapter).toBeInstanceOf(SlackAdapter); + }); +}); + // ============================================================================ // Thread ID Encoding/Decoding Tests // ============================================================================ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 39e1f55b..c8ba8446 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -81,10 +81,10 @@ export interface SlackAdapterConfig { * Defaults to `slack:installation`. The full key will be `{prefix}:{teamId}`. */ installationKeyPrefix?: string; - /** Logger instance for error reporting */ - logger: Logger; - /** Signing secret for webhook verification */ - signingSecret: string; + /** Logger instance for error reporting. Defaults to ConsoleLogger. */ + logger?: Logger; + /** Signing secret for webhook verification. Defaults to SLACK_SIGNING_SECRET env var. */ + signingSecret?: string; /** Override bot username (optional) */ userName?: string; } @@ -333,21 +333,49 @@ export class SlackAdapter implements Adapter { return this._botUserId || undefined; } - constructor(config: SlackAdapterConfig) { - this.client = new WebClient(config.botToken); - this.signingSecret = config.signingSecret; - this.defaultBotToken = config.botToken; - this.logger = config.logger; + constructor(config: SlackAdapterConfig = {}) { + const signingSecret = + config.signingSecret ?? process.env.SLACK_SIGNING_SECRET; + if (!signingSecret) { + throw new ValidationError( + "slack", + "signingSecret is required. Set SLACK_SIGNING_SECRET or provide it in config." + ); + } + + // Auth fields (botToken, clientId, clientSecret) are modal: botToken's + // presence selects single-workspace mode, its absence selects multi-workspace + // (per-team token lookup via installations). Only fall back to env vars + // in zero-config mode (no config fields provided at all). + const zeroConfig = !( + config.signingSecret || + config.botToken || + config.clientId || + config.clientSecret + ); + + const botToken = + config.botToken ?? (zeroConfig ? process.env.SLACK_BOT_TOKEN : undefined); + + this.client = new WebClient(botToken); + this.signingSecret = signingSecret; + this.defaultBotToken = botToken; + this.logger = config.logger ?? new ConsoleLogger("info").child("slack"); this.userName = config.userName || "bot"; this._botUserId = config.botUserId || null; - this.clientId = config.clientId; - this.clientSecret = config.clientSecret; + this.clientId = + config.clientId ?? (zeroConfig ? process.env.SLACK_CLIENT_ID : undefined); + this.clientSecret = + config.clientSecret ?? + (zeroConfig ? process.env.SLACK_CLIENT_SECRET : undefined); this.installationKeyPrefix = config.installationKeyPrefix ?? "slack:installation"; - if (config.encryptionKey) { - this.encryptionKey = decodeKey(config.encryptionKey); + const encryptionKey = + config.encryptionKey ?? process.env.SLACK_ENCRYPTION_KEY; + if (encryptionKey) { + this.encryptionKey = decodeKey(encryptionKey); } } @@ -3123,41 +3151,8 @@ export class SlackAdapter implements Adapter { } } -export function createSlackAdapter( - config?: Partial -): SlackAdapter { - const signingSecret = - config?.signingSecret ?? process.env.SLACK_SIGNING_SECRET; - if (!signingSecret) { - throw new ValidationError( - "slack", - "signingSecret is required. Set SLACK_SIGNING_SECRET or provide it in config." - ); - } - // Auth fields (botToken, clientId, clientSecret) are modal: botToken's - // presence selects single-workspace mode, its absence selects multi-workspace - // (per-team token lookup via installations). Only fall back to env vars - // in zero-config mode (no config provided at all). - const zeroConfig = !config; - - const resolved: SlackAdapterConfig = { - signingSecret, - botToken: - config?.botToken ?? - (zeroConfig ? process.env.SLACK_BOT_TOKEN : undefined), - clientId: - config?.clientId ?? - (zeroConfig ? process.env.SLACK_CLIENT_ID : undefined), - clientSecret: - config?.clientSecret ?? - (zeroConfig ? process.env.SLACK_CLIENT_SECRET : undefined), - encryptionKey: config?.encryptionKey ?? process.env.SLACK_ENCRYPTION_KEY, - installationKeyPrefix: config?.installationKeyPrefix, - logger: config?.logger ?? new ConsoleLogger("info").child("slack"), - userName: config?.userName, - botUserId: config?.botUserId, - }; - return new SlackAdapter(resolved); +export function createSlackAdapter(config?: SlackAdapterConfig): SlackAdapter { + return new SlackAdapter(config ?? {}); } // Re-export card converter for advanced use diff --git a/packages/adapter-teams/src/index.test.ts b/packages/adapter-teams/src/index.test.ts index c669071d..006bd260 100644 --- a/packages/adapter-teams/src/index.test.ts +++ b/packages/adapter-teams/src/index.test.ts @@ -10,7 +10,7 @@ import { } from "@chat-adapter/shared"; import type { Logger } from "chat"; import { NotImplementedError } from "chat"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTeamsAdapter, TeamsAdapter } from "./index"; const TEAMS_PREFIX_PATTERN = /^teams:/; @@ -82,6 +82,20 @@ describe("ESM compatibility", () => { }); describe("TeamsAdapter", () => { + const savedEnv = { ...process.env }; + + beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith("TEAMS_")) { + delete process.env[key]; + } + } + }); + + afterEach(() => { + process.env = { ...savedEnv }; + }); + it("should export createTeamsAdapter function", () => { expect(typeof createTeamsAdapter).toBe("function"); }); @@ -236,63 +250,72 @@ describe("TeamsAdapter", () => { }); // ========================================================================== - // createTeamsAdapter Factory Tests + // Constructor env var resolution // ========================================================================== - describe("createTeamsAdapter factory", () => { + describe("constructor env var resolution", () => { it("should throw when appId is missing and env var not set", () => { - const origAppId = process.env.TEAMS_APP_ID; - const origAppPwd = process.env.TEAMS_APP_PASSWORD; - // biome-ignore lint/performance/noDelete: env var removal requires delete - delete process.env.TEAMS_APP_ID; - // biome-ignore lint/performance/noDelete: env var removal requires delete - delete process.env.TEAMS_APP_PASSWORD; - try { - expect(() => createTeamsAdapter({})).toThrow(ValidationError); - } finally { - if (origAppId !== undefined) { - process.env.TEAMS_APP_ID = origAppId; - } - if (origAppPwd !== undefined) { - process.env.TEAMS_APP_PASSWORD = origAppPwd; - } - } + expect(() => new TeamsAdapter({})).toThrow("appId is required"); }); it("should throw when appPassword is missing and env var not set", () => { - const origAppPwd = process.env.TEAMS_APP_PASSWORD; - // biome-ignore lint/performance/noDelete: env var removal requires delete - delete process.env.TEAMS_APP_PASSWORD; - try { - expect(() => - createTeamsAdapter({ appId: "test-id", logger: mockLogger }) - ).toThrow(ValidationError); - } finally { - if (origAppPwd !== undefined) { - process.env.TEAMS_APP_PASSWORD = origAppPwd; - } - } + expect(() => new TeamsAdapter({ appId: "test" })).toThrow( + "One of appPassword, certificate, or federated must be provided" + ); }); - it("should pick up appTenantId from env", () => { - const origTenant = process.env.TEAMS_APP_TENANT_ID; + it("should resolve appId from TEAMS_APP_ID env var", () => { + process.env.TEAMS_APP_ID = "env-app-id"; + process.env.TEAMS_APP_PASSWORD = "env-password"; + const adapter = new TeamsAdapter(); + expect(adapter).toBeInstanceOf(TeamsAdapter); + }); + + it("should resolve appPassword from TEAMS_APP_PASSWORD env var", () => { + process.env.TEAMS_APP_PASSWORD = "env-password"; + const adapter = new TeamsAdapter({ appId: "test" }); + expect(adapter).toBeInstanceOf(TeamsAdapter); + }); + + it("should resolve appTenantId from TEAMS_APP_TENANT_ID env var", () => { process.env.TEAMS_APP_TENANT_ID = "env-tenant"; - try { - // Should not throw - means it's initializing graph client with the tenant - const adapter = createTeamsAdapter({ - appId: "test", - appPassword: "test", - logger: mockLogger, - }); - expect(adapter).toBeInstanceOf(TeamsAdapter); - } finally { - if (origTenant !== undefined) { - process.env.TEAMS_APP_TENANT_ID = origTenant; - } else { - // biome-ignore lint/performance/noDelete: env var removal requires delete - delete process.env.TEAMS_APP_TENANT_ID; - } - } + const adapter = new TeamsAdapter({ + appId: "test", + appPassword: "test", + }); + expect(adapter).toBeInstanceOf(TeamsAdapter); + }); + + it("should default logger when not provided", () => { + process.env.TEAMS_APP_ID = "env-app-id"; + process.env.TEAMS_APP_PASSWORD = "env-password"; + const adapter = new TeamsAdapter(); + expect(adapter).toBeInstanceOf(TeamsAdapter); + }); + + it("should prefer config values over env vars", () => { + process.env.TEAMS_APP_ID = "env-app-id"; + const adapter = new TeamsAdapter({ + appId: "config-app-id", + appPassword: "test", + }); + expect(adapter).toBeInstanceOf(TeamsAdapter); + expect(adapter.name).toBe("teams"); + }); + }); + + // ========================================================================== + // createTeamsAdapter Factory Tests + // ========================================================================== + + describe("createTeamsAdapter factory", () => { + it("should delegate to constructor", () => { + const adapter = createTeamsAdapter({ + appId: "test", + appPassword: "test", + logger: mockLogger, + }); + expect(adapter).toBeInstanceOf(TeamsAdapter); }); it("should create adapter with certificate auth (thumbprint)", () => { diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts index c3c045f6..9d7d7674 100644 --- a/packages/adapter-teams/src/index.ts +++ b/packages/adapter-teams/src/index.ts @@ -127,11 +127,11 @@ export interface TeamsAuthFederated { } export interface TeamsAdapterConfig { - /** Microsoft App ID */ - appId: string; - /** Microsoft App Password (client secret auth) */ + /** Microsoft App ID. Defaults to TEAMS_APP_ID env var. */ + appId?: string; + /** Microsoft App Password. Defaults to TEAMS_APP_PASSWORD env var. */ appPassword?: string; - /** Microsoft App Tenant ID */ + /** Microsoft App Tenant ID. Defaults to TEAMS_APP_TENANT_ID env var. */ appTenantId?: string; /** Microsoft App Type */ appType?: "MultiTenant" | "SingleTenant"; @@ -139,8 +139,8 @@ export interface TeamsAdapterConfig { certificate?: TeamsAuthCertificate; /** Federated (workload identity) authentication */ federated?: TeamsAuthFederated; - /** Logger instance for error reporting */ - logger: Logger; + /** Logger instance for error reporting. Defaults to ConsoleLogger. */ + logger?: Logger; /** Override bot username (optional) */ userName?: string; } @@ -169,15 +169,35 @@ export class TeamsAdapter implements Adapter { private chat: ChatInstance | null = null; private readonly logger: Logger; private readonly formatConverter = new TeamsFormatConverter(); - private readonly config: TeamsAdapterConfig; + private readonly config: Required> & + TeamsAdapterConfig; - constructor(config: TeamsAdapterConfig) { - this.config = config; - this.logger = config.logger; + constructor(config: TeamsAdapterConfig = {}) { + const appId = config.appId ?? process.env.TEAMS_APP_ID; + if (!appId) { + throw new ValidationError( + "teams", + "appId is required. Set TEAMS_APP_ID or provide it in config." + ); + } + const hasExplicitAuth = + config.appPassword || config.certificate || config.federated; + const appPassword = hasExplicitAuth + ? config.appPassword + : (config.appPassword ?? process.env.TEAMS_APP_PASSWORD); + const appTenantId = config.appTenantId ?? process.env.TEAMS_APP_TENANT_ID; + + this.config = { + ...config, + appId, + appPassword, + appTenantId, + }; + this.logger = config.logger ?? new ConsoleLogger("info").child("teams"); this.userName = config.userName || "bot"; const authMethodCount = [ - config.appPassword, + appPassword, config.certificate, config.federated, ].filter(Boolean).length; @@ -196,7 +216,7 @@ export class TeamsAdapter implements Adapter { ); } - if (config.appType === "SingleTenant" && !config.appTenantId) { + if (config.appType === "SingleTenant" && !appTenantId) { throw new ValidationError( "teams", "appTenantId is required for SingleTenant app type" @@ -205,10 +225,10 @@ export class TeamsAdapter implements Adapter { // Build Bot Framework auth based on credential type const botFrameworkConfig = { - MicrosoftAppId: config.appId, + MicrosoftAppId: appId, MicrosoftAppType: config.appType || "MultiTenant", MicrosoftAppTenantId: - config.appType === "SingleTenant" ? config.appTenantId : undefined, + config.appType === "SingleTenant" ? appTenantId : undefined, }; let credentialsFactory: @@ -223,17 +243,17 @@ export class TeamsAdapter implements Adapter { if (x5c) { credentialsFactory = new CertificateServiceClientCredentialsFactory( - config.appId, + appId, x5c, certificatePrivateKey, - config.appTenantId + appTenantId ); } else if (certificateThumbprint) { credentialsFactory = new CertificateServiceClientCredentialsFactory( - config.appId, + appId, certificateThumbprint, certificatePrivateKey, - config.appTenantId + appTenantId ); } else { throw new ValidationError( @@ -242,38 +262,34 @@ export class TeamsAdapter implements Adapter { ); } - if (config.appTenantId) { - graphCredential = new ClientCertificateCredential( - config.appTenantId, - config.appId, - { certificate: certificatePrivateKey } - ); + if (appTenantId) { + graphCredential = new ClientCertificateCredential(appTenantId, appId, { + certificate: certificatePrivateKey, + }); } } else if (config.federated) { credentialsFactory = new FederatedServiceClientCredentialsFactory( - config.appId, + appId, config.federated.clientId, - config.appTenantId, + appTenantId, config.federated.clientAudience ); - if (config.appTenantId) { + if (appTenantId) { graphCredential = new DefaultAzureCredential(); } - } else if (config.appPassword && config.appTenantId) { + } else if (appPassword && appTenantId) { graphCredential = new ClientSecretCredential( - config.appTenantId, - config.appId, - config.appPassword + appTenantId, + appId, + appPassword ); } const auth = new ConfigurationBotFrameworkAuthentication( { ...botFrameworkConfig, - ...(config.appPassword - ? { MicrosoftAppPassword: config.appPassword } - : {}), + ...(appPassword ? { MicrosoftAppPassword: appPassword } : {}), }, credentialsFactory ); @@ -2479,45 +2495,8 @@ export class TeamsAdapter implements Adapter { } } -export function createTeamsAdapter( - config?: Partial -): TeamsAdapter { - const appId = config?.appId ?? process.env.TEAMS_APP_ID; - if (!appId) { - throw new ValidationError( - "teams", - "appId is required. Set TEAMS_APP_ID or provide it in config." - ); - } - - // Resolve auth: explicit config takes precedence, then fall back to env vars - const hasExplicitAuth = - config?.appPassword || config?.certificate || config?.federated; - - let appPassword: string | undefined; - if (hasExplicitAuth) { - appPassword = config?.appPassword; - } else { - appPassword = process.env.TEAMS_APP_PASSWORD; - if (!appPassword) { - throw new ValidationError( - "teams", - "Auth is required. Provide appPassword, certificate, or federated in config, or set TEAMS_APP_PASSWORD." - ); - } - } - - const resolved: TeamsAdapterConfig = { - appId, - appPassword, - certificate: config?.certificate, - federated: config?.federated, - appTenantId: config?.appTenantId ?? process.env.TEAMS_APP_TENANT_ID, - appType: config?.appType, - logger: config?.logger ?? new ConsoleLogger("info").child("teams"), - userName: config?.userName, - }; - return new TeamsAdapter(resolved); +export function createTeamsAdapter(config?: TeamsAdapterConfig): TeamsAdapter { + return new TeamsAdapter(config ?? {}); } // Re-export card converter for advanced use diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 90dce409..20c2cc1d 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -165,6 +165,69 @@ describe("createTelegramAdapter", () => { }); }); +describe("constructor env var resolution", () => { + const savedEnv = { ...process.env }; + + beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith("TELEGRAM_")) { + delete process.env[key]; + } + } + }); + + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it("should throw when botToken is missing and env var not set", () => { + expect(() => new TelegramAdapter({})).toThrow("botToken is required"); + }); + + it("should resolve botToken from TELEGRAM_BOT_TOKEN env var", () => { + process.env.TELEGRAM_BOT_TOKEN = "env-bot-token"; + const adapter = new TelegramAdapter(); + expect(adapter).toBeInstanceOf(TelegramAdapter); + }); + + it("should resolve secretToken from TELEGRAM_WEBHOOK_SECRET_TOKEN env var", () => { + process.env.TELEGRAM_BOT_TOKEN = "env-bot-token"; + process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN = "env-secret"; + const adapter = new TelegramAdapter(); + expect(adapter).toBeInstanceOf(TelegramAdapter); + }); + + it("should resolve userName from TELEGRAM_BOT_USERNAME env var", () => { + process.env.TELEGRAM_BOT_TOKEN = "env-bot-token"; + process.env.TELEGRAM_BOT_USERNAME = "env_bot_name"; + const adapter = new TelegramAdapter(); + expect(adapter.userName).toBe("env_bot_name"); + }); + + it("should resolve apiBaseUrl from TELEGRAM_API_BASE_URL env var", () => { + process.env.TELEGRAM_BOT_TOKEN = "env-bot-token"; + process.env.TELEGRAM_API_BASE_URL = "https://custom-api.example.com"; + const adapter = new TelegramAdapter(); + expect(adapter).toBeInstanceOf(TelegramAdapter); + }); + + it("should default logger when not provided", () => { + process.env.TELEGRAM_BOT_TOKEN = "env-bot-token"; + const adapter = new TelegramAdapter(); + expect(adapter).toBeInstanceOf(TelegramAdapter); + }); + + it("should prefer config values over env vars", () => { + process.env.TELEGRAM_BOT_TOKEN = "env-token"; + process.env.TELEGRAM_BOT_USERNAME = "env-name"; + const adapter = new TelegramAdapter({ + botToken: "config-token", + userName: "config-name", + }); + expect(adapter.userName).toBe("config-name"); + }); +}); + describe("TelegramAdapter", () => { it("encodes and decodes thread IDs", () => { const adapter = createTelegramAdapter({ diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index abf0ceca..a88c32cc 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -64,7 +64,13 @@ const TELEGRAM_CAPTION_LIMIT = 1024; const TELEGRAM_SECRET_TOKEN_HEADER = "x-telegram-bot-api-secret-token"; const MESSAGE_ID_PATTERN = /^([^:]+):(\d+)$/; const TELEGRAM_MARKDOWN_PARSE_MODE = "Markdown"; -const TRAILING_SLASHES_REGEX = /\/+$/; +const trimTrailingSlashes = (url: string): string => { + let end = url.length; + while (end > 0 && url[end - 1] === "/") { + end--; + } + return url.slice(0, end); +}; const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; const LEADING_AT_PATTERN = /^@+/; const EMOJI_PLACEHOLDER_PATTERN = /^\{\{emoji:([a-z0-9_]+)\}\}$/i; @@ -137,20 +143,36 @@ export class TelegramAdapter return this._runtimeMode; } - constructor( - config: TelegramAdapterConfig & { logger: Logger; userName?: string } - ) { - this.botToken = config.botToken; - this.apiBaseUrl = (config.apiBaseUrl ?? TELEGRAM_API_BASE).replace( - TRAILING_SLASHES_REGEX, - "" + constructor(config: TelegramAdapterConfig = {}) { + const botToken = config.botToken ?? process.env.TELEGRAM_BOT_TOKEN; + if (!botToken) { + throw new ValidationError( + "telegram", + "botToken is required. Set TELEGRAM_BOT_TOKEN or provide it in config." + ); + } + + this.botToken = botToken; + this.apiBaseUrl = trimTrailingSlashes( + config.apiBaseUrl ?? + process.env.TELEGRAM_API_BASE_URL ?? + TELEGRAM_API_BASE ); - this.secretToken = config.secretToken; - this.logger = config.logger; - this._userName = this.normalizeUserName(config.userName ?? "bot"); - this.hasExplicitUserName = Boolean(config.userName); + this.secretToken = + config.secretToken ?? process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN; + this.logger = config.logger ?? new ConsoleLogger("info").child("telegram"); + const userName = config.userName ?? process.env.TELEGRAM_BOT_USERNAME; + this._userName = this.normalizeUserName(userName ?? "bot"); + this.hasExplicitUserName = Boolean(userName); this.mode = config.mode ?? "auto"; this.longPolling = config.longPolling; + + if (!["auto", "webhook", "polling"].includes(this.mode)) { + throw new ValidationError( + "telegram", + `Invalid mode: ${this.mode}. Expected "auto", "webhook", or "polling".` + ); + } } async initialize(chat: ChatInstance): Promise { @@ -1661,43 +1683,9 @@ export class TelegramAdapter } export function createTelegramAdapter( - config?: Partial< - TelegramAdapterConfig & { logger: Logger; userName?: string } - > + config?: TelegramAdapterConfig ): TelegramAdapter { - const botToken = config?.botToken ?? process.env.TELEGRAM_BOT_TOKEN; - if (!botToken) { - throw new ValidationError( - "telegram", - "botToken is required. Set TELEGRAM_BOT_TOKEN or provide it in config." - ); - } - - const apiBaseUrl = - config?.apiBaseUrl ?? - process.env.TELEGRAM_API_BASE_URL ?? - TELEGRAM_API_BASE; - const secretToken = - config?.secretToken ?? process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN; - const userName = config?.userName ?? process.env.TELEGRAM_BOT_USERNAME; - const mode = config?.mode ?? "auto"; - - if (!["auto", "webhook", "polling"].includes(mode)) { - throw new ValidationError( - "telegram", - `Invalid mode: ${mode}. Expected "auto", "webhook", or "polling".` - ); - } - - return new TelegramAdapter({ - botToken, - apiBaseUrl, - mode, - longPolling: config?.longPolling, - secretToken, - logger: config?.logger ?? new ConsoleLogger("info").child("telegram"), - userName, - }); + return new TelegramAdapter(config ?? {}); } export { TelegramFormatConverter } from "./markdown"; diff --git a/packages/adapter-telegram/src/types.ts b/packages/adapter-telegram/src/types.ts index e59f623e..b313fa9e 100644 --- a/packages/adapter-telegram/src/types.ts +++ b/packages/adapter-telegram/src/types.ts @@ -2,14 +2,18 @@ * Telegram adapter types. */ +import type { Logger } from "chat"; + /** * Telegram adapter configuration. */ export interface TelegramAdapterConfig { - /** Optional custom API base URL (defaults to https://api.telegram.org). */ + /** Optional custom API base URL (defaults to https://api.telegram.org). Defaults to TELEGRAM_API_BASE_URL env var. */ apiBaseUrl?: string; - /** Telegram bot token from BotFather. */ - botToken: string; + /** Telegram bot token from BotFather. Defaults to TELEGRAM_BOT_TOKEN env var. */ + botToken?: string; + /** Logger instance for error reporting. Defaults to ConsoleLogger. */ + logger?: Logger; /** Optional long-polling configuration for getUpdates flow. */ longPolling?: TelegramLongPollingConfig; /** @@ -19,8 +23,10 @@ export interface TelegramAdapterConfig { * - polling: polling-only mode */ mode?: TelegramAdapterMode; - /** Optional webhook secret token checked against x-telegram-bot-api-secret-token. */ + /** Optional webhook secret token checked against x-telegram-bot-api-secret-token. Defaults to TELEGRAM_WEBHOOK_SECRET_TOKEN env var. */ secretToken?: string; + /** Override bot username (optional). Defaults to TELEGRAM_BOT_USERNAME env var. */ + userName?: string; } export type TelegramAdapterMode = "auto" | "webhook" | "polling";