format
This commit is contained in:
parent
3dbca21334
commit
960bb108d0
@ -3,6 +3,7 @@
|
||||
## 2026.1.23
|
||||
|
||||
### Features
|
||||
|
||||
- Initial Twitch plugin release
|
||||
- Twitch chat integration via @twurple (IRC connection)
|
||||
- Multi-account support with per-channel configuration
|
||||
@ -14,6 +15,7 @@
|
||||
- Outbound message delivery with markdown stripping
|
||||
|
||||
### Improvements
|
||||
|
||||
- Added proper configuration schema with Zod validation
|
||||
- Added plugin descriptor (clawdbot.plugin.json)
|
||||
- Added comprehensive README and documentation
|
||||
|
||||
@ -29,11 +29,11 @@ Minimal config (default account):
|
||||
default: {
|
||||
username: "mybot",
|
||||
token: "oauth:your_token_here",
|
||||
clientId: "your_client_id_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clientId: "your_client_id_here",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@ -70,11 +70,11 @@ For long-running bots, configure automatic token refresh:
|
||||
token: "oauth:abc123...",
|
||||
clientId: "your_client_id",
|
||||
clientSecret: "your_client_secret",
|
||||
refreshToken: "your_refresh_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshToken: "your_refresh_token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@ -91,11 +91,11 @@ Allowlist by user ID (recommended):
|
||||
username: "mybot",
|
||||
token: "oauth:...",
|
||||
clientId: "...",
|
||||
allowFrom: ["123456789", "987654321"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
allowFrom: ["123456789", "987654321"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@ -110,11 +110,11 @@ Role-based restrictions:
|
||||
username: "mybot",
|
||||
token: "oauth:...",
|
||||
clientId: "...",
|
||||
allowedRoles: ["moderator", "vip"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
allowedRoles: ["moderator", "vip"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@ -131,23 +131,24 @@ Available roles: `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`
|
||||
username: "mybot",
|
||||
token: "oauth:...",
|
||||
clientId: "...",
|
||||
channel: "streamer1"
|
||||
channel: "streamer1",
|
||||
},
|
||||
secondary: {
|
||||
username: "mybot",
|
||||
token: "oauth:...",
|
||||
clientId: "...",
|
||||
channel: "streamer2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
channel: "streamer2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
For the default account:
|
||||
|
||||
- `CLAWDBOT_TWITCH_ACCESS_TOKEN` - OAuth token (with `oauth:` prefix)
|
||||
|
||||
Restart the gateway after config changes.
|
||||
@ -155,6 +156,7 @@ Restart the gateway after config changes.
|
||||
## Full documentation
|
||||
|
||||
See [https://docs.clawd.bot/channels/twitch](https://docs.clawd.bot/channels/twitch) for complete documentation including:
|
||||
|
||||
- Token setup options
|
||||
- Access control patterns
|
||||
- Troubleshooting
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
{
|
||||
"id": "twitch",
|
||||
"channels": [
|
||||
"twitch"
|
||||
],
|
||||
"channels": ["twitch"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
{
|
||||
"name": "@clawdbot/twitch",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Twitch channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@twurple/api": "^8.0.3",
|
||||
"@twurple/auth": "^8.0.3",
|
||||
@ -16,5 +11,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,9 +203,7 @@ describe("checkTwitchAccessControl", () => {
|
||||
botUsername: "testbot",
|
||||
});
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain(
|
||||
"does not have any of the required roles",
|
||||
);
|
||||
expect(result.reason).toContain("does not have any of the required roles");
|
||||
});
|
||||
|
||||
it("allows all users when role is 'all'", () => {
|
||||
|
||||
@ -119,10 +119,7 @@ export function checkTwitchAccessControl(params: {
|
||||
/**
|
||||
* Check if the sender has any of the allowed roles
|
||||
*/
|
||||
function checkSenderRoles(params: {
|
||||
message: TwitchChatMessage;
|
||||
allowedRoles: string[];
|
||||
}): boolean {
|
||||
function checkSenderRoles(params: { message: TwitchChatMessage; allowedRoles: string[] }): boolean {
|
||||
const { message, allowedRoles } = params;
|
||||
const { isMod, isOwner, isVip, isSub } = message;
|
||||
|
||||
|
||||
@ -6,10 +6,7 @@
|
||||
|
||||
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
|
||||
import { twitchOutbound } from "./outbound.js";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
} from "./types.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* Create a tool result with error content.
|
||||
|
||||
@ -72,9 +72,7 @@ export function getClientManager(accountId: string): TwitchClientManager | undef
|
||||
* @param accountId - The account ID
|
||||
* @returns Promise that resolves when cleanup is complete
|
||||
*/
|
||||
export async function removeClientManager(
|
||||
accountId: string,
|
||||
): Promise<void> {
|
||||
export async function removeClientManager(accountId: string): Promise<void> {
|
||||
const entry = registry.get(accountId);
|
||||
if (!entry) {
|
||||
return;
|
||||
@ -97,9 +95,7 @@ export async function removeClientManager(
|
||||
* @returns Promise that resolves when all cleanup is complete
|
||||
*/
|
||||
export async function removeAllClientManagers(): Promise<void> {
|
||||
const promises = Array.from(registry.keys()).map((accountId) =>
|
||||
removeClientManager(accountId),
|
||||
);
|
||||
const promises = Array.from(registry.keys()).map((accountId) => removeClientManager(accountId));
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
|
||||
@ -19,16 +19,12 @@ describe("parsePluginConfig", () => {
|
||||
});
|
||||
|
||||
it("handles undefined config", () => {
|
||||
const result = parsePluginConfig(
|
||||
undefined as unknown as Record<string, unknown>,
|
||||
);
|
||||
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>,
|
||||
);
|
||||
const result = parsePluginConfig(null as unknown as Record<string, unknown>);
|
||||
expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is null
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type {
|
||||
TwitchAccountConfig,
|
||||
TwitchPluginConfig,
|
||||
} from "./types.js";
|
||||
import type { TwitchAccountConfig, TwitchPluginConfig } from "./types.js";
|
||||
|
||||
/**
|
||||
* Default account ID for Twitch
|
||||
@ -42,8 +39,7 @@ export function parsePluginConfig(value: unknown): TwitchPluginConfig {
|
||||
|
||||
const raw = value as Record<string, unknown>;
|
||||
return {
|
||||
stripMarkdown:
|
||||
typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true,
|
||||
stripMarkdown: typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true,
|
||||
};
|
||||
}
|
||||
|
||||
@ -51,9 +47,7 @@ export function parsePluginConfig(value: unknown): TwitchPluginConfig {
|
||||
* List all configured account IDs
|
||||
*/
|
||||
export function listAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = (cfg as Record<string, unknown>).channels as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
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;
|
||||
|
||||
|
||||
@ -62,7 +62,9 @@ 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(
|
||||
config as Parameters<typeof core.channel.reply.resolveEnvelopeFormatOptions>[0],
|
||||
),
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
@ -111,7 +113,9 @@ async function processTwitchMessage(params: {
|
||||
// Dispatch reply
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config as Parameters<typeof core.channel.reply.dispatchReplyWithBufferedBlockDispatcher>[0]["cfg"],
|
||||
cfg: config as Parameters<
|
||||
typeof core.channel.reply.dispatchReplyWithBufferedBlockDispatcher
|
||||
>[0]["cfg"],
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverTwitchReply({
|
||||
@ -188,7 +192,11 @@ export async function monitorTwitchProvider(
|
||||
|
||||
// Establish connection
|
||||
try {
|
||||
await clientManager.getClient(account, config as Parameters<typeof clientManager.getClient>[1], accountId);
|
||||
await clientManager.getClient(
|
||||
account,
|
||||
config as Parameters<typeof clientManager.getClient>[1],
|
||||
accountId,
|
||||
);
|
||||
runtime.log?.(`[twitch] Connected to Twitch as ${account.username}`);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@ -220,8 +220,7 @@ describe("onboarding helpers", () => {
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(mockPromptConfirm).toHaveBeenCalledWith({
|
||||
message:
|
||||
"Enable automatic token refresh (requires client secret and refresh token)?",
|
||||
message: "Enable automatic token refresh (requires client secret and refresh token)?",
|
||||
initialValue: false,
|
||||
});
|
||||
});
|
||||
@ -234,9 +233,7 @@ describe("onboarding helpers", () => {
|
||||
.mockResolvedValueOnce("secret123") // clientSecret
|
||||
.mockResolvedValueOnce("refresh123"); // refreshToken
|
||||
|
||||
mockPromptText
|
||||
.mockResolvedValueOnce("secret123")
|
||||
.mockResolvedValueOnce("refresh123");
|
||||
mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123");
|
||||
|
||||
const result = await promptRefreshTokenSetup(mockPrompter, null);
|
||||
|
||||
@ -298,7 +295,10 @@ describe("onboarding helpers", () => {
|
||||
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
|
||||
|
||||
// Set up mocks for username and clientId prompts
|
||||
mockPromptText.mockReset().mockResolvedValueOnce("testbot" as never).mockResolvedValueOnce("test-client-id" as never);
|
||||
mockPromptText
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce("testbot" as never)
|
||||
.mockResolvedValueOnce("test-client-id" as never);
|
||||
|
||||
const result = await configureWithEnvToken(
|
||||
{} as Parameters<typeof configureWithEnvToken>[0],
|
||||
|
||||
@ -9,10 +9,7 @@ import {
|
||||
type ChannelOnboardingDmPolicy,
|
||||
type WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getAccountConfig,
|
||||
} from "./config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
|
||||
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
@ -60,10 +57,14 @@ function setTwitchAccount(
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
twitch: {
|
||||
...((cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined),
|
||||
...((cfg.channels as Record<string, unknown>)?.twitch as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(((cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined)?.accounts as Record<string, unknown> | undefined),
|
||||
...((
|
||||
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
|
||||
)?.accounts as Record<string, unknown> | undefined),
|
||||
[DEFAULT_ACCOUNT_ID]: merged,
|
||||
},
|
||||
},
|
||||
@ -191,7 +192,8 @@ async function promptRefreshTokenSetup(
|
||||
return {};
|
||||
}
|
||||
|
||||
const clientSecret = String(
|
||||
const clientSecret =
|
||||
String(
|
||||
await prompter.text({
|
||||
message: "Twitch Client Secret (for token refresh)",
|
||||
initialValue: account?.clientSecret ?? "",
|
||||
@ -199,7 +201,8 @@ async function promptRefreshTokenSetup(
|
||||
}),
|
||||
).trim() || undefined;
|
||||
|
||||
const refreshToken = String(
|
||||
const refreshToken =
|
||||
String(
|
||||
await prompter.text({
|
||||
message: "Twitch Refresh Token",
|
||||
initialValue: account?.refreshToken ?? "",
|
||||
@ -315,9 +318,7 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [
|
||||
`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`,
|
||||
],
|
||||
statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`],
|
||||
selectionHint: configured ? "configured" : "needs setup",
|
||||
};
|
||||
},
|
||||
@ -399,7 +400,9 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => {
|
||||
const twitch = (cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined;
|
||||
const twitch = (cfg.channels as Record<string, unknown>)?.twitch as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
@ -411,4 +414,11 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
};
|
||||
|
||||
// Export helper functions for testing
|
||||
export { promptToken, promptUsername, promptClientId, promptChannelName, promptRefreshTokenSetup, configureWithEnvToken };
|
||||
export {
|
||||
promptToken,
|
||||
promptUsername,
|
||||
promptClientId,
|
||||
promptChannelName,
|
||||
promptRefreshTokenSetup,
|
||||
configureWithEnvToken,
|
||||
};
|
||||
|
||||
@ -5,11 +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, parsePluginConfig } from "./config.js";
|
||||
import { sendMessageTwitchInternal } from "./send.js";
|
||||
import type {
|
||||
ChannelOutboundAdapter,
|
||||
@ -109,9 +105,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
|
||||
* accountId: "default",
|
||||
* });
|
||||
*/
|
||||
sendText: async (
|
||||
params: ChannelOutboundContext,
|
||||
): Promise<OutboundDeliveryResult> => {
|
||||
sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||
const { cfg, to, text, accountId, signal } = params;
|
||||
|
||||
// Check for abort signal
|
||||
@ -133,9 +127,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
|
||||
// Get channel (support target parameter)
|
||||
const channel = to || account.channel || account.username;
|
||||
if (!channel) {
|
||||
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");
|
||||
}
|
||||
|
||||
// Get plugin config for markdown stripping
|
||||
@ -184,9 +176,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
|
||||
* accountId: "default",
|
||||
* });
|
||||
*/
|
||||
sendMedia: async (
|
||||
params: ChannelOutboundContext,
|
||||
): Promise<OutboundDeliveryResult> => {
|
||||
sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||
const { text, mediaUrl, signal } = params;
|
||||
|
||||
// Check for abort signal
|
||||
|
||||
@ -9,11 +9,7 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { twitchMessageActions } from "./actions.js";
|
||||
import { TwitchConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getAccountConfig,
|
||||
listAccountIds,
|
||||
} from "./config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js";
|
||||
import { twitchOnboardingAdapter } from "./onboarding.js";
|
||||
import { twitchOutbound } from "./outbound.js";
|
||||
import { probeTwitch } from "./probe.js";
|
||||
@ -34,9 +30,7 @@ import type {
|
||||
/**
|
||||
* Check if an account is properly configured.
|
||||
*/
|
||||
function isConfigured(
|
||||
account: TwitchAccountConfig | null | undefined,
|
||||
): boolean {
|
||||
function isConfigured(account: TwitchAccountConfig | null | undefined): boolean {
|
||||
return Boolean(account?.token && account?.username && account?.clientId);
|
||||
}
|
||||
|
||||
@ -71,7 +65,9 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
|
||||
notifyApproval: async ({ id }) => {
|
||||
// Note: Twitch doesn't support DMs from bots, so pairing approval is limited
|
||||
// We'll log the approval instead
|
||||
console.warn(`[twitch] Pairing approved for user ${id} (notification sent via chat if possible)`);
|
||||
console.warn(
|
||||
`[twitch] Pairing approved for user ${id} (notification sent via chat if possible)`,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@ -89,10 +85,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
|
||||
listAccountIds: (cfg: ClawdbotConfig): string[] => listAccountIds(cfg),
|
||||
|
||||
/** Resolve an account config by ID */
|
||||
resolveAccount: (
|
||||
cfg: ClawdbotConfig,
|
||||
accountId?: string | null,
|
||||
): TwitchAccountConfig => {
|
||||
resolveAccount: (cfg: ClawdbotConfig, accountId?: string | null): TwitchAccountConfig => {
|
||||
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
if (!account) {
|
||||
// Return a default/empty account if not configured
|
||||
@ -116,8 +109,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
|
||||
},
|
||||
|
||||
/** Check if an account is enabled */
|
||||
isEnabled: (account: TwitchAccountConfig | undefined): boolean =>
|
||||
account?.enabled !== false,
|
||||
isEnabled: (account: TwitchAccountConfig | undefined): boolean => account?.enabled !== false,
|
||||
|
||||
/** Describe account status */
|
||||
describeAccount: (account: TwitchAccountConfig | undefined) => ({
|
||||
@ -181,11 +173,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
|
||||
},
|
||||
|
||||
/** Build channel summary from snapshot */
|
||||
buildChannelSummary: ({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ChannelAccountSnapshot;
|
||||
}) => ({
|
||||
buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
@ -247,9 +235,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
ctx.log?.info(
|
||||
`[twitch] Starting Twitch connection for ${account.username}`,
|
||||
);
|
||||
ctx.log?.info(`[twitch] Starting Twitch connection for ${account.username}`);
|
||||
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorTwitchProvider } = await import("./monitor.js");
|
||||
@ -276,9 +262,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
|
||||
ctx.log?.info(
|
||||
`[twitch] Stopped Twitch connection for ${account.username}`,
|
||||
);
|
||||
ctx.log?.info(`[twitch] Stopped Twitch connection for ${account.username}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -41,10 +41,7 @@ export async function probeTwitch(
|
||||
|
||||
try {
|
||||
// Create auth provider with the token
|
||||
const authProvider = new StaticAuthProvider(
|
||||
account.clientId ?? "",
|
||||
rawToken,
|
||||
);
|
||||
const authProvider = new StaticAuthProvider(account.clientId ?? "", rawToken);
|
||||
|
||||
// Create chat client
|
||||
client = new ChatClient({
|
||||
|
||||
@ -65,10 +65,7 @@ export async function resolveTwitchTargets(
|
||||
const normalizedToken = normalizeToken(account.token);
|
||||
|
||||
// Create auth provider and API client
|
||||
const authProvider = new StaticAuthProvider(
|
||||
account.clientId,
|
||||
normalizedToken,
|
||||
);
|
||||
const authProvider = new StaticAuthProvider(account.clientId, normalizedToken);
|
||||
const apiClient = new ApiClient({ authProvider });
|
||||
|
||||
const results: ChannelResolveResult[] = [];
|
||||
@ -121,14 +118,9 @@ export async function resolveTwitchTargets(
|
||||
resolved: true,
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
note:
|
||||
user.displayName !== user.name
|
||||
? `display: ${user.displayName}`
|
||||
: undefined,
|
||||
note: user.displayName !== user.name ? `display: ${user.displayName}` : undefined,
|
||||
});
|
||||
log.debug(
|
||||
`Resolved username ${normalized} -> ${user.id} (${user.name})`,
|
||||
);
|
||||
log.debug(`Resolved username ${normalized} -> ${user.id} (${user.name})`);
|
||||
} else {
|
||||
results.push({
|
||||
input,
|
||||
@ -139,8 +131,7 @@ export async function resolveTwitchTargets(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
results.push({
|
||||
input,
|
||||
resolved: false,
|
||||
|
||||
@ -107,9 +107,7 @@ describe("send", () => {
|
||||
messageId: "twitch-msg-456",
|
||||
}),
|
||||
} as ReturnType<typeof getClientManager>);
|
||||
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) =>
|
||||
text.replace(/\*\*/g, ""),
|
||||
);
|
||||
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, ""));
|
||||
|
||||
await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
|
||||
@ -9,11 +9,7 @@ import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
|
||||
import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { stripMarkdownForTwitch } from "./utils/markdown.js";
|
||||
import {
|
||||
generateMessageId,
|
||||
isAccountConfigured,
|
||||
normalizeTwitchChannel,
|
||||
} from "./utils/twitch.js";
|
||||
import { generateMessageId, isAccountConfigured, normalizeTwitchChannel } from "./utils/twitch.js";
|
||||
|
||||
/**
|
||||
* Result from sending a message to Twitch.
|
||||
|
||||
@ -74,10 +74,7 @@ describe("status", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const issues = collectTwitchStatusIssues(
|
||||
snapshots,
|
||||
() => mockCfg as never,
|
||||
);
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
const clientIdIssue = issues.find((i) => i.message.includes("client ID"));
|
||||
expect(clientIdIssue).toBeDefined();
|
||||
@ -107,10 +104,7 @@ describe("status", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const issues = collectTwitchStatusIssues(
|
||||
snapshots,
|
||||
() => mockCfg as never,
|
||||
);
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
const prefixIssue = issues.find((i) => i.message.includes("oauth:"));
|
||||
expect(prefixIssue).toBeDefined();
|
||||
@ -143,10 +137,7 @@ describe("status", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const issues = collectTwitchStatusIssues(
|
||||
snapshots,
|
||||
() => mockCfg as never,
|
||||
);
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
const secretIssue = issues.find((i) => i.message.includes("clientSecret"));
|
||||
expect(secretIssue).toBeDefined();
|
||||
@ -177,10 +168,7 @@ describe("status", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const issues = collectTwitchStatusIssues(
|
||||
snapshots,
|
||||
() => mockCfg as never,
|
||||
);
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
const allowFromIssue = issues.find((i) => i.message.includes("allowFrom"));
|
||||
expect(allowFromIssue).toBeDefined();
|
||||
@ -212,10 +200,7 @@ describe("status", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const issues = collectTwitchStatusIssues(
|
||||
snapshots,
|
||||
() => mockCfg as never,
|
||||
);
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
const conflictIssue = issues.find((i) => i.kind === "intent");
|
||||
expect(conflictIssue).toBeDefined();
|
||||
|
||||
@ -87,7 +87,6 @@ export function collectTwitchStatusIssues(
|
||||
|
||||
// Checks that require account config
|
||||
if (account && isAccountConfigured(account)) {
|
||||
|
||||
// Check 4: Token format warning (normalized, but may indicate config issue)
|
||||
if (account.token?.startsWith("oauth:")) {
|
||||
issues.push({
|
||||
@ -131,8 +130,7 @@ export function collectTwitchStatusIssues(
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "intent",
|
||||
message:
|
||||
"allowedRoles is set to 'all' but allowFrom is also configured",
|
||||
message: "allowedRoles is set to 'all' but allowFrom is also configured",
|
||||
fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.",
|
||||
});
|
||||
}
|
||||
|
||||
@ -57,9 +57,7 @@ export function resolveTwitchToken(
|
||||
|
||||
// 2. Base config token (default account only)
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const configToken = allowEnv
|
||||
? normalizeTwitchToken(twitchCfg?.token ?? undefined)
|
||||
: undefined;
|
||||
const configToken = allowEnv ? normalizeTwitchToken(twitchCfg?.token ?? undefined) : undefined;
|
||||
if (configToken) {
|
||||
return { token: configToken, source: "config" };
|
||||
}
|
||||
|
||||
@ -11,11 +11,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TwitchClientManager } from "./twitch-client.js";
|
||||
import type {
|
||||
ChannelLogSink,
|
||||
TwitchAccountConfig,
|
||||
TwitchChatMessage,
|
||||
} from "./types.js";
|
||||
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||
|
||||
// Mock @twurple dependencies
|
||||
const mockConnect = vi.fn().mockResolvedValue(undefined);
|
||||
@ -166,10 +162,7 @@ describe("TwitchClientManager", () => {
|
||||
|
||||
await manager.getClient(accountWithPrefix);
|
||||
|
||||
expect(mockAuthProvider.constructor).toHaveBeenCalledWith(
|
||||
"test-client-id",
|
||||
"actualtoken123",
|
||||
);
|
||||
expect(mockAuthProvider.constructor).toHaveBeenCalledWith("test-client-id", "actualtoken123");
|
||||
});
|
||||
|
||||
it("should use token directly when no oauth: prefix", async () => {
|
||||
@ -212,9 +205,7 @@ describe("TwitchClientManager", () => {
|
||||
source: "none" as const,
|
||||
});
|
||||
|
||||
await expect(manager.getClient(testAccount)).rejects.toThrow(
|
||||
"Missing Twitch token",
|
||||
);
|
||||
await expect(manager.getClient(testAccount)).rejects.toThrow("Missing Twitch token");
|
||||
});
|
||||
|
||||
it("should set up message handlers on client connection", async () => {
|
||||
@ -222,9 +213,7 @@ describe("TwitchClientManager", () => {
|
||||
|
||||
expect(mockOnMessage).toHaveBeenCalled();
|
||||
expect(mockOnWhisper).toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Set up handlers for"),
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Set up handlers for"));
|
||||
});
|
||||
|
||||
it("should create separate clients for same account with different channels", async () => {
|
||||
@ -271,9 +260,7 @@ describe("TwitchClientManager", () => {
|
||||
await manager.disconnect(testAccount);
|
||||
|
||||
expect(mockQuit).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Disconnected"),
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Disconnected"));
|
||||
});
|
||||
|
||||
it("should clear client and message handler", async () => {
|
||||
@ -332,11 +319,7 @@ describe("TwitchClientManager", () => {
|
||||
});
|
||||
|
||||
it("should send message successfully", async () => {
|
||||
const result = await manager.sendMessage(
|
||||
testAccount,
|
||||
"testchannel",
|
||||
"Hello, world!",
|
||||
);
|
||||
const result = await manager.sendMessage(testAccount, "testchannel", "Hello, world!");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
@ -344,16 +327,8 @@ describe("TwitchClientManager", () => {
|
||||
});
|
||||
|
||||
it("should generate unique message ID for each message", async () => {
|
||||
const result1 = await manager.sendMessage(
|
||||
testAccount,
|
||||
"testchannel",
|
||||
"First message",
|
||||
);
|
||||
const result2 = await manager.sendMessage(
|
||||
testAccount,
|
||||
"testchannel",
|
||||
"Second message",
|
||||
);
|
||||
const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
|
||||
const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");
|
||||
|
||||
expect(result1.messageId).not.toBe(result2.messageId);
|
||||
});
|
||||
@ -373,11 +348,7 @@ describe("TwitchClientManager", () => {
|
||||
it("should return error on send failure", async () => {
|
||||
mockSay.mockRejectedValueOnce(new Error("Rate limited"));
|
||||
|
||||
const result = await manager.sendMessage(
|
||||
testAccount,
|
||||
"testchannel",
|
||||
"Test message",
|
||||
);
|
||||
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("Rate limited");
|
||||
@ -389,11 +360,7 @@ describe("TwitchClientManager", () => {
|
||||
it("should handle unknown error types", async () => {
|
||||
mockSay.mockRejectedValueOnce("String error");
|
||||
|
||||
const result = await manager.sendMessage(
|
||||
testAccount,
|
||||
"testchannel",
|
||||
"Test message",
|
||||
);
|
||||
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("String error");
|
||||
@ -406,16 +373,10 @@ describe("TwitchClientManager", () => {
|
||||
// Reset connect call count for this specific test
|
||||
const connectCallCountBefore = mockConnect.mock.calls.length;
|
||||
|
||||
const result = await manager.sendMessage(
|
||||
testAccount,
|
||||
"testchannel",
|
||||
"Test message",
|
||||
);
|
||||
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockConnect.mock.calls.length).toBeGreaterThan(
|
||||
connectCallCountBefore,
|
||||
);
|
||||
expect(mockConnect.mock.calls.length).toBeGreaterThan(connectCallCountBefore);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
|
||||
import { ChatClient, LogLevel } from "@twurple/chat";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type {
|
||||
ChannelLogSink,
|
||||
TwitchAccountConfig,
|
||||
TwitchChatMessage,
|
||||
} from "./types.js";
|
||||
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||
import { resolveTwitchToken } from "./token.js";
|
||||
import { normalizeToken } from "./utils/twitch.js";
|
||||
|
||||
@ -14,10 +10,7 @@ import { normalizeToken } from "./utils/twitch.js";
|
||||
*/
|
||||
export class TwitchClientManager {
|
||||
private clients = new Map<string, ChatClient>();
|
||||
private messageHandlers = new Map<
|
||||
string,
|
||||
(message: TwitchChatMessage) => void
|
||||
>();
|
||||
private messageHandlers = new Map<string, (message: TwitchChatMessage) => void>();
|
||||
|
||||
constructor(private logger: ChannelLogSink) {}
|
||||
|
||||
@ -122,9 +115,7 @@ export class TwitchClientManager {
|
||||
);
|
||||
|
||||
if (!account.clientId) {
|
||||
this.logger.error(
|
||||
`[twitch] Missing Twitch client ID for account ${account.username}`,
|
||||
);
|
||||
this.logger.error(`[twitch] Missing Twitch client ID for account ${account.username}`);
|
||||
throw new Error("Missing Twitch client ID");
|
||||
}
|
||||
|
||||
@ -186,19 +177,14 @@ export class TwitchClientManager {
|
||||
/**
|
||||
* Set up message and event handlers for a client
|
||||
*/
|
||||
private setupClientHandlers(
|
||||
client: ChatClient,
|
||||
account: TwitchAccountConfig,
|
||||
): void {
|
||||
private setupClientHandlers(client: ChatClient, account: TwitchAccountConfig): void {
|
||||
const key = this.getAccountKey(account);
|
||||
|
||||
// Handle incoming messages
|
||||
client.onMessage((channelName, _user, messageText, msg) => {
|
||||
const handler = this.messageHandlers.get(key);
|
||||
if (handler) {
|
||||
const normalizedChannel = channelName.startsWith("#")
|
||||
? channelName.slice(1)
|
||||
: channelName;
|
||||
const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
|
||||
handler({
|
||||
username: msg.userInfo.userName,
|
||||
displayName: msg.userInfo.displayName,
|
||||
@ -243,10 +229,7 @@ export class TwitchClientManager {
|
||||
/**
|
||||
* Set a message handler for an account
|
||||
*/
|
||||
onMessage(
|
||||
account: TwitchAccountConfig,
|
||||
handler: (message: TwitchChatMessage) => void,
|
||||
): void {
|
||||
onMessage(account: TwitchAccountConfig, handler: (message: TwitchChatMessage) => void): void {
|
||||
const key = this.getAccountKey(account);
|
||||
this.messageHandlers.set(key, handler);
|
||||
}
|
||||
|
||||
@ -16,7 +16,8 @@
|
||||
* @returns Plain text with markdown removed
|
||||
*/
|
||||
export function stripMarkdownForTwitch(markdown: string): string {
|
||||
return markdown
|
||||
return (
|
||||
markdown
|
||||
// Images
|
||||
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
|
||||
// Links
|
||||
@ -32,9 +33,7 @@ export function stripMarkdownForTwitch(markdown: string): string {
|
||||
// Strikethrough (~~text~~)
|
||||
.replace(/~~([^~]+)~~/g, "$1")
|
||||
// Code blocks
|
||||
.replace(/```[\s\S]*?```/g, (block) =>
|
||||
block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""),
|
||||
)
|
||||
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""))
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
// Headers
|
||||
@ -47,7 +46,8 @@ export function stripMarkdownForTwitch(markdown: string): string {
|
||||
.replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines
|
||||
.replace(/\n/g, " ") // Replace newlines with spaces (for Twitch)
|
||||
.replace(/[ \t]{2,}/g, " ") // Reduce multiple spaces to single
|
||||
.trim();
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -28,9 +28,7 @@ export function normalizeTwitchChannel(channel: string): string {
|
||||
* @returns Error object with descriptive message
|
||||
*/
|
||||
export function missingTargetError(provider: string, hint?: string): Error {
|
||||
return new Error(
|
||||
`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`,
|
||||
);
|
||||
return new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user