diff --git a/docs/channels/twitch.md b/docs/channels/twitch.md index 886a44347..5974225b7 100644 --- a/docs/channels/twitch.md +++ b/docs/channels/twitch.md @@ -289,9 +289,6 @@ If you see "token refresh disabled (no refresh token)": - `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`) - `requireMention` - Require @mention (default: `false`) -**Plugin config:** -- `stripMarkdown` - Strip markdown from outbound (default: `true`) - **Provider options:** - `channels.twitch.enabled` - Enable/disable channel startup - `channels.twitch.username` - Bot username (simplified single-account config) @@ -333,13 +330,6 @@ Full example: } } } - }, - plugins: { - entries: { - twitch: { - stripMarkdown: true - } - } } } ``` diff --git a/extensions/twitch/src/config-schema.ts b/extensions/twitch/src/config-schema.ts index 7b9b6a2df..23e8a106f 100644 --- a/extensions/twitch/src/config-schema.ts +++ b/extensions/twitch/src/config-schema.ts @@ -36,20 +36,22 @@ const TwitchAccountSchema = z.object({ obtainmentTimestamp: z.number().optional(), }); +/** + * Base configuration properties shared by both single and multi-account modes + */ +const TwitchConfigBaseSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema.optional(), +}); + /** * Simplified single-account configuration schema * * Use this for single-account setups. Properties are at the top level, * creating an implicit "default" account. */ -const simplifiedSchema = z.intersection( - z.object({ - name: z.string().optional(), - enabled: z.boolean().optional(), - markdown: MarkdownConfigSchema.optional(), - }), - TwitchAccountSchema, -); +const simplifiedSchema = z.intersection(TwitchConfigBaseSchema, TwitchAccountSchema); /** * Multi-account configuration schema @@ -57,11 +59,7 @@ const simplifiedSchema = z.intersection( * Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary"). */ const multiAccountSchema = z.intersection( - z.object({ - name: z.string().optional(), - enabled: z.boolean().optional(), - markdown: MarkdownConfigSchema.optional(), - }), + TwitchConfigBaseSchema, z .object({ /** Per-account configuration (for multi-account setups) */ diff --git a/extensions/twitch/src/config.test.ts b/extensions/twitch/src/config.test.ts index 413c79bf5..cdef1c4c8 100644 --- a/extensions/twitch/src/config.test.ts +++ b/extensions/twitch/src/config.test.ts @@ -1,33 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getAccountConfig, parsePluginConfig } from "./config.js"; - -describe("parsePluginConfig", () => { - it("parses valid config with stripMarkdown true", () => { - const result = parsePluginConfig({ stripMarkdown: true }); - expect(result.stripMarkdown).toBe(true); - }); - - it("parses valid config with stripMarkdown false", () => { - const result = parsePluginConfig({ stripMarkdown: false }); - expect(result.stripMarkdown).toBe(false); - }); - - it("defaults to stripMarkdown true when not specified", () => { - const result = parsePluginConfig({}); - expect(result.stripMarkdown).toBe(true); - }); - - it("handles undefined config", () => { - const result = parsePluginConfig(undefined as unknown as Record); - expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is falsy - }); - - it("handles null config", () => { - const result = parsePluginConfig(null as unknown as Record); - expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is null - }); -}); +import { getAccountConfig } from "./config.js"; describe("getAccountConfig", () => { const mockMultiAccountConfig = { diff --git a/extensions/twitch/src/config.ts b/extensions/twitch/src/config.ts index a5b8afa82..b4c5d54ca 100644 --- a/extensions/twitch/src/config.ts +++ b/extensions/twitch/src/config.ts @@ -1,5 +1,5 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; -import type { TwitchAccountConfig, TwitchPluginConfig } from "./types.js"; +import type { TwitchAccountConfig } from "./types.js"; /** * Default account ID for Twitch @@ -24,49 +24,52 @@ export function getAccountConfig( return null; } - const cfg = coreConfig as Record; - const channels = cfg.channels as Record | undefined; - const twitch = channels?.twitch as Record | undefined; - const accounts = twitch?.accounts as Record | undefined; + const cfg = coreConfig as ClawdbotConfig; + const twitch = cfg.channels?.twitch; + // Access accounts via unknown to handle union type (single-account vs multi-account) + const twitchRaw = twitch as Record | undefined; + const accounts = twitchRaw?.accounts as Record | undefined; // For default account, check base-level config first if (accountId === DEFAULT_ACCOUNT_ID) { - const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID] as - | Record - | undefined; + const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID]; // Base-level properties that can form an implicit default account const baseLevel = { - username: typeof twitch?.username === "string" ? twitch.username : undefined, - accessToken: typeof twitch?.accessToken === "string" ? twitch.accessToken : undefined, - clientId: typeof twitch?.clientId === "string" ? twitch.clientId : undefined, - channel: typeof twitch?.channel === "string" ? twitch.channel : undefined, - enabled: typeof twitch?.enabled === "boolean" ? twitch.enabled : undefined, - allowFrom: Array.isArray(twitch?.allowFrom) ? twitch.allowFrom : undefined, - allowedRoles: Array.isArray(twitch?.allowedRoles) ? twitch.allowedRoles : undefined, + username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined, + accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined, + clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined, + channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined, + enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined, + allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined, + allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined, requireMention: - typeof twitch?.requireMention === "boolean" ? twitch.requireMention : undefined, - clientSecret: typeof twitch?.clientSecret === "string" ? twitch.clientSecret : undefined, - refreshToken: typeof twitch?.refreshToken === "string" ? twitch.refreshToken : undefined, - expiresIn: typeof twitch?.expiresIn === "number" ? twitch.expiresIn : undefined, + typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined, + clientSecret: + typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined, + refreshToken: + typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined, + expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined, obtainmentTimestamp: - typeof twitch?.obtainmentTimestamp === "number" ? twitch.obtainmentTimestamp : undefined, + typeof twitchRaw?.obtainmentTimestamp === "number" + ? twitchRaw.obtainmentTimestamp + : undefined, }; // Merge: base-level takes precedence over accounts.default - const merged = { + const merged: Partial = { ...accountFromAccounts, ...baseLevel, - } as Record; + } as Partial; // Only return if we have at least username if (merged.username) { - return merged as unknown as TwitchAccountConfig; + return merged as TwitchAccountConfig; } // Fall through to accounts.default if no base-level username if (accountFromAccounts) { - return accountFromAccounts as unknown as TwitchAccountConfig; + return accountFromAccounts; } return null; @@ -80,29 +83,16 @@ export function getAccountConfig( return accounts[accountId] as TwitchAccountConfig | null; } -/** - * Parse plugin config - */ -export function parsePluginConfig(value: unknown): TwitchPluginConfig { - if (!value || typeof value !== "object") { - return { stripMarkdown: true }; - } - - const raw = value as Record; - return { - stripMarkdown: typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true, - }; -} - /** * List all configured account IDs * * Includes both explicit accounts and implicit "default" from base-level config */ export function listAccountIds(cfg: ClawdbotConfig): string[] { - const accounts = (cfg as Record).channels as Record | undefined; - const twitch = accounts?.twitch as Record | undefined; - const accountMap = twitch?.accounts as Record | undefined; + const twitch = cfg.channels?.twitch; + // Access accounts via unknown to handle union type (single-account vs multi-account) + const twitchRaw = twitch as Record | undefined; + const accountMap = twitchRaw?.accounts as Record | undefined; const ids: string[] = []; @@ -113,10 +103,10 @@ export function listAccountIds(cfg: ClawdbotConfig): string[] { // Add implicit "default" if base-level config exists and "default" not already present const hasBaseLevelConfig = - twitch && - (typeof twitch.username === "string" || - typeof twitch.accessToken === "string" || - typeof twitch.channel === "string"); + twitchRaw && + (typeof twitchRaw.username === "string" || + typeof twitchRaw.accessToken === "string" || + typeof twitchRaw.channel === "string"); if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) { ids.push(DEFAULT_ACCOUNT_ID); diff --git a/extensions/twitch/src/monitor.ts b/extensions/twitch/src/monitor.ts index 3f74255ef..cf9161204 100644 --- a/extensions/twitch/src/monitor.ts +++ b/extensions/twitch/src/monitor.ts @@ -5,10 +5,9 @@ * resolves agent routes, and handles replies. */ -import type { ReplyPayload } from "clawdbot/plugin-sdk"; +import type { ReplyPayload, ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js"; import { checkTwitchAccessControl } from "./access-control.js"; -import { parsePluginConfig } from "./config.js"; import { getTwitchRuntime } from "./runtime.js"; import { getOrCreateClientManager } from "./client-manager-registry.js"; import { stripMarkdownForTwitch } from "./utils/markdown.js"; @@ -46,9 +45,10 @@ async function processTwitchMessage(params: { statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; }): Promise { const { message, account, accountId, config, runtime, core, statusSink } = params; + const cfg = config as ClawdbotConfig; const route = core.channel.routing.resolveAgentRoute({ - cfg: config as Parameters[0]["cfg"], + cfg, channel: "twitch", accountId, peer: { @@ -62,9 +62,7 @@ async function processTwitchMessage(params: { channel: "Twitch", from: message.displayName ?? message.username, timestamp: message.timestamp?.getTime(), - envelope: core.channel.reply.resolveEnvelopeFormatOptions( - config as Parameters[0], - ), + envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg), body: rawBody, }); @@ -89,7 +87,7 @@ async function processTwitchMessage(params: { }); const storePath = core.channel.session.resolveStorePath( - (config as Parameters[0]["cfg"])?.session?.store, + config as Parameters[0], { agentId: route.agentId }, ); await core.channel.session.recordInboundSession({ @@ -102,16 +100,14 @@ async function processTwitchMessage(params: { }); const tableMode = core.channel.text.resolveMarkdownTableMode({ - cfg: config as Parameters[0]["cfg"], + cfg, channel: "twitch", accountId, }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, - cfg: config as Parameters< - typeof core.channel.reply.dispatchReplyWithBufferedBlockDispatcher - >[0]["cfg"], + cfg, dispatcherOptions: { deliver: async (payload) => { await deliverTwitchReply({ @@ -168,9 +164,7 @@ async function deliverTwitchReply(params: { return; } - const pluginCfg = parsePluginConfig((config as any).pluginConfig ?? {}); - const textToSend = - (pluginCfg.stripMarkdown ?? true) ? stripMarkdownForTwitch(payload.text) : payload.text; + const textToSend = stripMarkdownForTwitch(payload.text); await client.say(channel, textToSend); statusSink?.({ lastOutboundAt: Date.now() }); diff --git a/extensions/twitch/src/outbound.test.ts b/extensions/twitch/src/outbound.test.ts index b7b1d031c..41a68418f 100644 --- a/extensions/twitch/src/outbound.test.ts +++ b/extensions/twitch/src/outbound.test.ts @@ -17,7 +17,6 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; vi.mock("./config.js", () => ({ DEFAULT_ACCOUNT_ID: "default", getAccountConfig: vi.fn(), - parsePluginConfig: vi.fn(() => ({ stripMarkdown: true })), })); vi.mock("./send.js", () => ({ @@ -294,35 +293,6 @@ describe("outbound", () => { }), ).rejects.toThrow("Connection lost"); }); - - it("should respect stripMarkdown config", async () => { - const { getAccountConfig } = await import("./config.js"); - const { parsePluginConfig } = await import("./config.js"); - const { sendMessageTwitchInternal } = await import("./send.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(parsePluginConfig).mockReturnValue({ stripMarkdown: false }); - vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ - ok: true, - messageId: "msg-789", - }); - - await twitchOutbound.sendText({ - cfg: mockConfig, - to: "#testchannel", - text: "**Bold** text", - accountId: "default", - }); - - expect(sendMessageTwitchInternal).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - false, // stripMarkdown disabled - expect.anything(), - ); - }); }); describe("sendMedia", () => { diff --git a/extensions/twitch/src/outbound.ts b/extensions/twitch/src/outbound.ts index 70a48a669..7f2edabec 100644 --- a/extensions/twitch/src/outbound.ts +++ b/extensions/twitch/src/outbound.ts @@ -5,7 +5,7 @@ * Supports text and media (URL) sending with markdown stripping and chunking. */ -import { DEFAULT_ACCOUNT_ID, getAccountConfig, parsePluginConfig } from "./config.js"; +import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import { sendMessageTwitchInternal } from "./send.js"; import type { ChannelOutboundAdapter, @@ -127,17 +127,12 @@ export const twitchOutbound: ChannelOutboundAdapter = { throw new Error("No channel specified and no default channel in account config"); } - const pluginCfg = parsePluginConfig( - // biome-ignore lint/suspicious/noExplicitAny: pluginConfig is not part of CoreConfig - (cfg as any).pluginConfig ?? {}, - ); - const result = await sendMessageTwitchInternal( normalizeTwitchChannel(channel), text, cfg, resolvedAccountId, - pluginCfg.stripMarkdown ?? true, + true, // stripMarkdown console, ); diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index 2e1134eec..99d9e9af1 100644 --- a/extensions/twitch/src/types.ts +++ b/extensions/twitch/src/types.ts @@ -35,14 +35,6 @@ import type { RuntimeEnv } from "../../../src/runtime.js"; */ export type TwitchRole = "moderator" | "owner" | "vip" | "subscriber" | "all"; -/** - * Plugin configuration passed from Clawdbot - */ -export interface TwitchPluginConfig { - /** Strip markdown from outbound messages before sending to Twitch (default true). */ - stripMarkdown?: boolean; -} - /** * Account configuration for a Twitch channel */ @@ -140,5 +132,10 @@ export type { OutboundDeliveryResult, }; +// Import and re-export the schema type +import type { TwitchConfigSchema } from "./config-schema.js"; +import type { z } from "zod"; +export type TwitchConfig = z.infer; + export type { ClawdbotConfig }; export type { RuntimeEnv };