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