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"`)
- `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
}
}
}
}
```

View File

@ -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) */

View File

@ -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<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
});
});
import { getAccountConfig } from "./config.js";
describe("getAccountConfig", () => {
const mockMultiAccountConfig = {

View File

@ -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<string, unknown>;
const channels = cfg.channels as Record<string, unknown> | undefined;
const twitch = channels?.twitch as Record<string, unknown> | undefined;
const accounts = twitch?.accounts as Record<string, unknown> | 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<string, unknown> | undefined;
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
// For default account, check base-level config first
if (accountId === DEFAULT_ACCOUNT_ID) {
const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID] as
| Record<string, unknown>
| 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<TwitchAccountConfig> = {
...accountFromAccounts,
...baseLevel,
} as Record<string, unknown>;
} as Partial<TwitchAccountConfig>;
// 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<string, unknown>;
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<string, unknown>).channels as Record<string, unknown> | undefined;
const twitch = accounts?.twitch as Record<string, unknown> | undefined;
const accountMap = twitch?.accounts as Record<string, unknown> | undefined;
const twitch = cfg.channels?.twitch;
// Access accounts via unknown to handle union type (single-account vs multi-account)
const twitchRaw = twitch as Record<string, unknown> | undefined;
const accountMap = twitchRaw?.accounts as Record<string, unknown> | 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);

View File

@ -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<void> {
const { message, account, accountId, config, runtime, core, statusSink } = params;
const cfg = config as ClawdbotConfig;
const route = core.channel.routing.resolveAgentRoute({
cfg: config as Parameters<typeof core.channel.routing.resolveAgentRoute>[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<typeof core.channel.reply.resolveEnvelopeFormatOptions>[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<typeof core.channel.session.resolveStorePath>[0]["cfg"])?.session?.store,
config as Parameters<typeof core.channel.session.resolveStorePath>[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<typeof core.channel.text.resolveMarkdownTableMode>[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() });

View File

@ -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", () => {

View File

@ -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,
);

View File

@ -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<typeof TwitchConfigSchema>;
export type { ClawdbotConfig };
export type { RuntimeEnv };