remove strip markdown config, enabled by default

This commit is contained in:
jaydenfyi 2026-01-25 21:15:12 +08:00
parent be3a16f294
commit ad828b9f0b
8 changed files with 62 additions and 155 deletions

View File

@ -289,9 +289,6 @@ If you see "token refresh disabled (no refresh token)":
- `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`) - `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`)
- `requireMention` - Require @mention (default: `false`) - `requireMention` - Require @mention (default: `false`)
**Plugin config:**
- `stripMarkdown` - Strip markdown from outbound (default: `true`)
**Provider options:** **Provider options:**
- `channels.twitch.enabled` - Enable/disable channel startup - `channels.twitch.enabled` - Enable/disable channel startup
- `channels.twitch.username` - Bot username (simplified single-account config) - `channels.twitch.username` - Bot username (simplified single-account config)
@ -333,13 +330,6 @@ Full example:
} }
} }
} }
},
plugins: {
entries: {
twitch: {
stripMarkdown: true
}
}
} }
} }
``` ```

View File

@ -36,20 +36,22 @@ const TwitchAccountSchema = z.object({
obtainmentTimestamp: z.number().optional(), 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 * Simplified single-account configuration schema
* *
* Use this for single-account setups. Properties are at the top level, * Use this for single-account setups. Properties are at the top level,
* creating an implicit "default" account. * creating an implicit "default" account.
*/ */
const simplifiedSchema = z.intersection( const simplifiedSchema = z.intersection(TwitchConfigBaseSchema, TwitchAccountSchema);
z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema.optional(),
}),
TwitchAccountSchema,
);
/** /**
* Multi-account configuration schema * 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"). * Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
*/ */
const multiAccountSchema = z.intersection( const multiAccountSchema = z.intersection(
z.object({ TwitchConfigBaseSchema,
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema.optional(),
}),
z z
.object({ .object({
/** Per-account configuration (for multi-account setups) */ /** Per-account configuration (for multi-account setups) */

View File

@ -1,33 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getAccountConfig, parsePluginConfig } from "./config.js"; import { getAccountConfig } 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<string, unknown>);
expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is falsy
});
it("handles null config", () => {
const result = parsePluginConfig(null as unknown as Record<string, unknown>);
expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is null
});
});
describe("getAccountConfig", () => { describe("getAccountConfig", () => {
const mockMultiAccountConfig = { const mockMultiAccountConfig = {

View File

@ -1,5 +1,5 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; 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 * Default account ID for Twitch
@ -24,49 +24,52 @@ export function getAccountConfig(
return null; return null;
} }
const cfg = coreConfig as Record<string, unknown>; const cfg = coreConfig as ClawdbotConfig;
const channels = cfg.channels as Record<string, unknown> | undefined; const twitch = cfg.channels?.twitch;
const twitch = channels?.twitch as Record<string, unknown> | undefined; // Access accounts via unknown to handle union type (single-account vs multi-account)
const accounts = twitch?.accounts as Record<string, unknown> | undefined; const twitchRaw = twitch as Record<string, unknown> | undefined;
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
// For default account, check base-level config first // For default account, check base-level config first
if (accountId === DEFAULT_ACCOUNT_ID) { if (accountId === DEFAULT_ACCOUNT_ID) {
const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID] as const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID];
| Record<string, unknown>
| undefined;
// Base-level properties that can form an implicit default account // Base-level properties that can form an implicit default account
const baseLevel = { const baseLevel = {
username: typeof twitch?.username === "string" ? twitch.username : undefined, username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined,
accessToken: typeof twitch?.accessToken === "string" ? twitch.accessToken : undefined, accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined,
clientId: typeof twitch?.clientId === "string" ? twitch.clientId : undefined, clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined,
channel: typeof twitch?.channel === "string" ? twitch.channel : undefined, channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined,
enabled: typeof twitch?.enabled === "boolean" ? twitch.enabled : undefined, enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined,
allowFrom: Array.isArray(twitch?.allowFrom) ? twitch.allowFrom : undefined, allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined,
allowedRoles: Array.isArray(twitch?.allowedRoles) ? twitch.allowedRoles : undefined, allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined,
requireMention: requireMention:
typeof twitch?.requireMention === "boolean" ? twitch.requireMention : undefined, typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined,
clientSecret: typeof twitch?.clientSecret === "string" ? twitch.clientSecret : undefined, clientSecret:
refreshToken: typeof twitch?.refreshToken === "string" ? twitch.refreshToken : undefined, typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined,
expiresIn: typeof twitch?.expiresIn === "number" ? twitch.expiresIn : undefined, refreshToken:
typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined,
expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined,
obtainmentTimestamp: obtainmentTimestamp:
typeof twitch?.obtainmentTimestamp === "number" ? twitch.obtainmentTimestamp : undefined, typeof twitchRaw?.obtainmentTimestamp === "number"
? twitchRaw.obtainmentTimestamp
: undefined,
}; };
// Merge: base-level takes precedence over accounts.default // Merge: base-level takes precedence over accounts.default
const merged = { const merged: Partial<TwitchAccountConfig> = {
...accountFromAccounts, ...accountFromAccounts,
...baseLevel, ...baseLevel,
} as Record<string, unknown>; } as Partial<TwitchAccountConfig>;
// Only return if we have at least username // Only return if we have at least username
if (merged.username) { if (merged.username) {
return merged as unknown as TwitchAccountConfig; return merged as TwitchAccountConfig;
} }
// Fall through to accounts.default if no base-level username // Fall through to accounts.default if no base-level username
if (accountFromAccounts) { if (accountFromAccounts) {
return accountFromAccounts as unknown as TwitchAccountConfig; return accountFromAccounts;
} }
return null; return null;
@ -80,29 +83,16 @@ export function getAccountConfig(
return accounts[accountId] as TwitchAccountConfig | null; 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<string, unknown>;
return {
stripMarkdown: typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true,
};
}
/** /**
* List all configured account IDs * List all configured account IDs
* *
* Includes both explicit accounts and implicit "default" from base-level config * Includes both explicit accounts and implicit "default" from base-level config
*/ */
export function listAccountIds(cfg: ClawdbotConfig): string[] { export function listAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined; const twitch = cfg.channels?.twitch;
const twitch = accounts?.twitch as Record<string, unknown> | undefined; // Access accounts via unknown to handle union type (single-account vs multi-account)
const accountMap = twitch?.accounts as Record<string, unknown> | undefined; const twitchRaw = twitch as Record<string, unknown> | undefined;
const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
const ids: string[] = []; 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 // Add implicit "default" if base-level config exists and "default" not already present
const hasBaseLevelConfig = const hasBaseLevelConfig =
twitch && twitchRaw &&
(typeof twitch.username === "string" || (typeof twitchRaw.username === "string" ||
typeof twitch.accessToken === "string" || typeof twitchRaw.accessToken === "string" ||
typeof twitch.channel === "string"); typeof twitchRaw.channel === "string");
if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) { if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) {
ids.push(DEFAULT_ACCOUNT_ID); ids.push(DEFAULT_ACCOUNT_ID);

View File

@ -5,10 +5,9 @@
* resolves agent routes, and handles replies. * 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 type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
import { checkTwitchAccessControl } from "./access-control.js"; import { checkTwitchAccessControl } from "./access-control.js";
import { parsePluginConfig } from "./config.js";
import { getTwitchRuntime } from "./runtime.js"; import { getTwitchRuntime } from "./runtime.js";
import { getOrCreateClientManager } from "./client-manager-registry.js"; import { getOrCreateClientManager } from "./client-manager-registry.js";
import { stripMarkdownForTwitch } from "./utils/markdown.js"; import { stripMarkdownForTwitch } from "./utils/markdown.js";
@ -46,9 +45,10 @@ async function processTwitchMessage(params: {
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> { }): Promise<void> {
const { message, account, accountId, config, runtime, core, statusSink } = params; const { message, account, accountId, config, runtime, core, statusSink } = params;
const cfg = config as ClawdbotConfig;
const route = core.channel.routing.resolveAgentRoute({ const route = core.channel.routing.resolveAgentRoute({
cfg: config as Parameters<typeof core.channel.routing.resolveAgentRoute>[0]["cfg"], cfg,
channel: "twitch", channel: "twitch",
accountId, accountId,
peer: { peer: {
@ -62,9 +62,7 @@ async function processTwitchMessage(params: {
channel: "Twitch", channel: "Twitch",
from: message.displayName ?? message.username, from: message.displayName ?? message.username,
timestamp: message.timestamp?.getTime(), timestamp: message.timestamp?.getTime(),
envelope: core.channel.reply.resolveEnvelopeFormatOptions( envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
config as Parameters<typeof core.channel.reply.resolveEnvelopeFormatOptions>[0],
),
body: rawBody, body: rawBody,
}); });
@ -89,7 +87,7 @@ async function processTwitchMessage(params: {
}); });
const storePath = core.channel.session.resolveStorePath( const storePath = core.channel.session.resolveStorePath(
(config as Parameters<typeof core.channel.session.resolveStorePath>[0]["cfg"])?.session?.store, config as Parameters<typeof core.channel.session.resolveStorePath>[0],
{ agentId: route.agentId }, { agentId: route.agentId },
); );
await core.channel.session.recordInboundSession({ await core.channel.session.recordInboundSession({
@ -102,16 +100,14 @@ async function processTwitchMessage(params: {
}); });
const tableMode = core.channel.text.resolveMarkdownTableMode({ const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config as Parameters<typeof core.channel.text.resolveMarkdownTableMode>[0]["cfg"], cfg,
channel: "twitch", channel: "twitch",
accountId, accountId,
}); });
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
cfg: config as Parameters< cfg,
typeof core.channel.reply.dispatchReplyWithBufferedBlockDispatcher
>[0]["cfg"],
dispatcherOptions: { dispatcherOptions: {
deliver: async (payload) => { deliver: async (payload) => {
await deliverTwitchReply({ await deliverTwitchReply({
@ -168,9 +164,7 @@ async function deliverTwitchReply(params: {
return; return;
} }
const pluginCfg = parsePluginConfig((config as any).pluginConfig ?? {}); const textToSend = stripMarkdownForTwitch(payload.text);
const textToSend =
(pluginCfg.stripMarkdown ?? true) ? stripMarkdownForTwitch(payload.text) : payload.text;
await client.say(channel, textToSend); await client.say(channel, textToSend);
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });

View File

@ -17,7 +17,6 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
vi.mock("./config.js", () => ({ vi.mock("./config.js", () => ({
DEFAULT_ACCOUNT_ID: "default", DEFAULT_ACCOUNT_ID: "default",
getAccountConfig: vi.fn(), getAccountConfig: vi.fn(),
parsePluginConfig: vi.fn(() => ({ stripMarkdown: true })),
})); }));
vi.mock("./send.js", () => ({ vi.mock("./send.js", () => ({
@ -294,35 +293,6 @@ describe("outbound", () => {
}), }),
).rejects.toThrow("Connection lost"); ).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", () => { describe("sendMedia", () => {

View File

@ -5,7 +5,7 @@
* Supports text and media (URL) sending with markdown stripping and chunking. * 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 { sendMessageTwitchInternal } from "./send.js";
import type { import type {
ChannelOutboundAdapter, ChannelOutboundAdapter,
@ -127,17 +127,12 @@ export const twitchOutbound: ChannelOutboundAdapter = {
throw new Error("No channel specified and no default channel in account config"); 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( const result = await sendMessageTwitchInternal(
normalizeTwitchChannel(channel), normalizeTwitchChannel(channel),
text, text,
cfg, cfg,
resolvedAccountId, resolvedAccountId,
pluginCfg.stripMarkdown ?? true, true, // stripMarkdown
console, console,
); );

View File

@ -35,14 +35,6 @@ import type { RuntimeEnv } from "../../../src/runtime.js";
*/ */
export type TwitchRole = "moderator" | "owner" | "vip" | "subscriber" | "all"; 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 * Account configuration for a Twitch channel
*/ */
@ -140,5 +132,10 @@ export type {
OutboundDeliveryResult, 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<typeof TwitchConfigSchema>;
export type { ClawdbotConfig }; export type { ClawdbotConfig };
export type { RuntimeEnv }; export type { RuntimeEnv };