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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,385 +3,383 @@ import { checkTwitchAccessControl, extractMentions } from "./access-control.js";
|
|||||||
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||||
|
|
||||||
describe("checkTwitchAccessControl", () => {
|
describe("checkTwitchAccessControl", () => {
|
||||||
const mockAccount: TwitchAccountConfig = {
|
const mockAccount: TwitchAccountConfig = {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test",
|
token: "oauth:test",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMessage: TwitchChatMessage = {
|
const mockMessage: TwitchChatMessage = {
|
||||||
username: "testuser",
|
username: "testuser",
|
||||||
userId: "123456",
|
userId: "123456",
|
||||||
message: "hello bot",
|
message: "hello bot",
|
||||||
channel: "testchannel",
|
channel: "testchannel",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("when no restrictions are configured", () => {
|
describe("when no restrictions are configured", () => {
|
||||||
it("allows all messages", () => {
|
it("allows all messages", () => {
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message: mockMessage,
|
message: mockMessage,
|
||||||
account: mockAccount,
|
account: mockAccount,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("requireMention", () => {
|
describe("requireMention", () => {
|
||||||
it("allows messages that mention the bot", () => {
|
it("allows messages that mention the bot", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
message: "@testbot hello",
|
message: "@testbot hello",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks messages that don't mention the bot", () => {
|
it("blocks messages that don't mention the bot", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message: mockMessage,
|
message: mockMessage,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
expect(result.reason).toContain("does not mention the bot");
|
expect(result.reason).toContain("does not mention the bot");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is case-insensitive for bot username", () => {
|
it("is case-insensitive for bot username", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
message: "@TestBot hello",
|
message: "@TestBot hello",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("allowFrom allowlist", () => {
|
describe("allowFrom allowlist", () => {
|
||||||
it("allows users in the allowlist", () => {
|
it("allows users in the allowlist", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowFrom: ["123456", "789012"],
|
allowFrom: ["123456", "789012"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message: mockMessage,
|
message: mockMessage,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
expect(result.matchKey).toBe("123456");
|
expect(result.matchKey).toBe("123456");
|
||||||
expect(result.matchSource).toBe("allowlist");
|
expect(result.matchSource).toBe("allowlist");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks users not in the allowlist", () => {
|
it("blocks users not in the allowlist", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowFrom: ["789012"],
|
allowFrom: ["789012"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message: mockMessage,
|
message: mockMessage,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
expect(result.reason).toContain("not in allowlist");
|
expect(result.reason).toContain("not in allowlist");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks messages without userId", () => {
|
it("blocks messages without userId", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowFrom: ["123456"],
|
allowFrom: ["123456"],
|
||||||
};
|
};
|
||||||
const message = { ...mockMessage, userId: undefined };
|
const message = { ...mockMessage, userId: undefined };
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
expect(result.reason).toContain("user ID not available");
|
expect(result.reason).toContain("user ID not available");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bypasses role checks when user is in allowlist", () => {
|
it("bypasses role checks when user is in allowlist", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowFrom: ["123456"],
|
allowFrom: ["123456"],
|
||||||
allowedRoles: ["owner"],
|
allowedRoles: ["owner"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("allowedRoles", () => {
|
describe("allowedRoles", () => {
|
||||||
it("allows users with matching role", () => {
|
it("allows users with matching role", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowedRoles: ["moderator"],
|
allowedRoles: ["moderator"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
isMod: true,
|
isMod: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
expect(result.matchSource).toBe("role");
|
expect(result.matchSource).toBe("role");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows users with any of multiple roles", () => {
|
it("allows users with any of multiple roles", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowedRoles: ["moderator", "vip", "subscriber"],
|
allowedRoles: ["moderator", "vip", "subscriber"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
isVip: true,
|
isVip: true,
|
||||||
isMod: false,
|
isMod: false,
|
||||||
isSub: false,
|
isSub: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks users without matching role", () => {
|
it("blocks users without matching role", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowedRoles: ["moderator"],
|
allowedRoles: ["moderator"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
isMod: false,
|
isMod: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
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'", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowedRoles: ["all"],
|
allowedRoles: ["all"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message: mockMessage,
|
message: mockMessage,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
expect(result.matchKey).toBe("all");
|
expect(result.matchKey).toBe("all");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles moderator role", () => {
|
it("handles moderator role", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowedRoles: ["moderator"],
|
allowedRoles: ["moderator"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
isMod: true,
|
isMod: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles subscriber role", () => {
|
it("handles subscriber role", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowedRoles: ["subscriber"],
|
allowedRoles: ["subscriber"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
isSub: true,
|
isSub: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles owner role", () => {
|
it("handles owner role", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowedRoles: ["owner"],
|
allowedRoles: ["owner"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
isOwner: true,
|
isOwner: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles vip role", () => {
|
it("handles vip role", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowedRoles: ["vip"],
|
allowedRoles: ["vip"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
isVip: true,
|
isVip: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("combined restrictions", () => {
|
describe("combined restrictions", () => {
|
||||||
it("checks requireMention before allowlist", () => {
|
it("checks requireMention before allowlist", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
allowFrom: ["123456"],
|
allowFrom: ["123456"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
message: "hello", // No mention
|
message: "hello", // No mention
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
expect(result.reason).toContain("does not mention the bot");
|
expect(result.reason).toContain("does not mention the bot");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks allowlist before allowedRoles", () => {
|
it("checks allowlist before allowedRoles", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
allowFrom: ["123456"],
|
allowFrom: ["123456"],
|
||||||
allowedRoles: ["owner"],
|
allowedRoles: ["owner"],
|
||||||
};
|
};
|
||||||
const message: TwitchChatMessage = {
|
const message: TwitchChatMessage = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
isOwner: false, // Not owner, but in allowlist
|
isOwner: false, // Not owner, but in allowlist
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
const result = checkTwitchAccessControl({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
botUsername: "testbot",
|
botUsername: "testbot",
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
expect(result.matchSource).toBe("allowlist");
|
expect(result.matchSource).toBe("allowlist");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractMentions", () => {
|
describe("extractMentions", () => {
|
||||||
it("extracts single mention", () => {
|
it("extracts single mention", () => {
|
||||||
const mentions = extractMentions("hello @testbot");
|
const mentions = extractMentions("hello @testbot");
|
||||||
expect(mentions).toEqual(["testbot"]);
|
expect(mentions).toEqual(["testbot"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts multiple mentions", () => {
|
it("extracts multiple mentions", () => {
|
||||||
const mentions = extractMentions("hello @testbot and @otheruser");
|
const mentions = extractMentions("hello @testbot and @otheruser");
|
||||||
expect(mentions).toEqual(["testbot", "otheruser"]);
|
expect(mentions).toEqual(["testbot", "otheruser"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array when no mentions", () => {
|
it("returns empty array when no mentions", () => {
|
||||||
const mentions = extractMentions("hello everyone");
|
const mentions = extractMentions("hello everyone");
|
||||||
expect(mentions).toEqual([]);
|
expect(mentions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles mentions at start of message", () => {
|
it("handles mentions at start of message", () => {
|
||||||
const mentions = extractMentions("@testbot hello");
|
const mentions = extractMentions("@testbot hello");
|
||||||
expect(mentions).toEqual(["testbot"]);
|
expect(mentions).toEqual(["testbot"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles mentions at end of message", () => {
|
it("handles mentions at end of message", () => {
|
||||||
const mentions = extractMentions("hello @testbot");
|
const mentions = extractMentions("hello @testbot");
|
||||||
expect(mentions).toEqual(["testbot"]);
|
expect(mentions).toEqual(["testbot"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts mentions to lowercase", () => {
|
it("converts mentions to lowercase", () => {
|
||||||
const mentions = extractMentions("hello @TestBot");
|
const mentions = extractMentions("hello @TestBot");
|
||||||
expect(mentions).toEqual(["testbot"]);
|
expect(mentions).toEqual(["testbot"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts alphanumeric usernames", () => {
|
it("extracts alphanumeric usernames", () => {
|
||||||
const mentions = extractMentions("hello @user123");
|
const mentions = extractMentions("hello @user123");
|
||||||
expect(mentions).toEqual(["user123"]);
|
expect(mentions).toEqual(["user123"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles underscores in usernames", () => {
|
it("handles underscores in usernames", () => {
|
||||||
const mentions = extractMentions("hello @test_user");
|
const mentions = extractMentions("hello @test_user");
|
||||||
expect(mentions).toEqual(["test_user"]);
|
expect(mentions).toEqual(["test_user"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty string", () => {
|
it("handles empty string", () => {
|
||||||
const mentions = extractMentions("");
|
const mentions = extractMentions("");
|
||||||
expect(mentions).toEqual([]);
|
expect(mentions).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,10 +4,10 @@ import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
|||||||
* Result of checking access control for a Twitch message
|
* Result of checking access control for a Twitch message
|
||||||
*/
|
*/
|
||||||
export type TwitchAccessControlResult = {
|
export type TwitchAccessControlResult = {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
matchKey?: string;
|
matchKey?: string;
|
||||||
matchSource?: string;
|
matchSource?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,118 +32,115 @@ export type TwitchAccessControlResult = {
|
|||||||
* - "all": Anyone in the chat
|
* - "all": Anyone in the chat
|
||||||
*/
|
*/
|
||||||
export function checkTwitchAccessControl(params: {
|
export function checkTwitchAccessControl(params: {
|
||||||
message: TwitchChatMessage;
|
message: TwitchChatMessage;
|
||||||
account: TwitchAccountConfig;
|
account: TwitchAccountConfig;
|
||||||
botUsername: string;
|
botUsername: string;
|
||||||
}): TwitchAccessControlResult {
|
}): TwitchAccessControlResult {
|
||||||
const { message, account, botUsername } = params;
|
const { message, account, botUsername } = params;
|
||||||
|
|
||||||
// Check mention requirement first
|
// Check mention requirement first
|
||||||
if (account.requireMention) {
|
if (account.requireMention) {
|
||||||
const mentions = extractMentions(message.message);
|
const mentions = extractMentions(message.message);
|
||||||
if (!mentions.includes(botUsername.toLowerCase())) {
|
if (!mentions.includes(botUsername.toLowerCase())) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "message does not mention the bot (requireMention is enabled)",
|
reason: "message does not mention the bot (requireMention is enabled)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check allowlist (by user ID)
|
// Check allowlist (by user ID)
|
||||||
if (account.allowFrom && account.allowFrom.length > 0) {
|
if (account.allowFrom && account.allowFrom.length > 0) {
|
||||||
const allowFrom = account.allowFrom;
|
const allowFrom = account.allowFrom;
|
||||||
const senderId = message.userId;
|
const senderId = message.userId;
|
||||||
|
|
||||||
if (!senderId) {
|
if (!senderId) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "sender user ID not available for allowlist check",
|
reason: "sender user ID not available for allowlist check",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if sender is in allowlist
|
// Check if sender is in allowlist
|
||||||
if (!allowFrom.includes(senderId)) {
|
if (!allowFrom.includes(senderId)) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "sender not in allowlist",
|
reason: "sender not in allowlist",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sender is in allowlist, no need to check role restrictions
|
// Sender is in allowlist, no need to check role restrictions
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
matchKey: senderId,
|
matchKey: senderId,
|
||||||
matchSource: "allowlist",
|
matchSource: "allowlist",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check role-based restrictions
|
// Check role-based restrictions
|
||||||
if (account.allowedRoles && account.allowedRoles.length > 0) {
|
if (account.allowedRoles && account.allowedRoles.length > 0) {
|
||||||
const allowedRoles = account.allowedRoles;
|
const allowedRoles = account.allowedRoles;
|
||||||
|
|
||||||
// "all" grants access to everyone
|
// "all" grants access to everyone
|
||||||
if (allowedRoles.includes("all")) {
|
if (allowedRoles.includes("all")) {
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
matchKey: "all",
|
matchKey: "all",
|
||||||
matchSource: "role",
|
matchSource: "role",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if sender has any of the allowed roles
|
// Check if sender has any of the allowed roles
|
||||||
const hasAllowedRole = checkSenderRoles({
|
const hasAllowedRole = checkSenderRoles({
|
||||||
message,
|
message,
|
||||||
allowedRoles,
|
allowedRoles,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasAllowedRole) {
|
if (!hasAllowedRole) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`,
|
reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
matchKey: allowedRoles.join(","),
|
matchKey: allowedRoles.join(","),
|
||||||
matchSource: "role",
|
matchSource: "role",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// No restrictions configured - allow everyone
|
// No restrictions configured - allow everyone
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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;
|
const { message, allowedRoles } = params;
|
||||||
allowedRoles: string[];
|
const { isMod, isOwner, isVip, isSub } = message;
|
||||||
}): boolean {
|
|
||||||
const { message, allowedRoles } = params;
|
|
||||||
const { isMod, isOwner, isVip, isSub } = message;
|
|
||||||
|
|
||||||
for (const role of allowedRoles) {
|
for (const role of allowedRoles) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case "moderator":
|
case "moderator":
|
||||||
if (isMod) return true;
|
if (isMod) return true;
|
||||||
break;
|
break;
|
||||||
case "owner":
|
case "owner":
|
||||||
if (isOwner) return true;
|
if (isOwner) return true;
|
||||||
break;
|
break;
|
||||||
case "vip":
|
case "vip":
|
||||||
if (isVip) return true;
|
if (isVip) return true;
|
||||||
break;
|
break;
|
||||||
case "subscriber":
|
case "subscriber":
|
||||||
if (isSub) return true;
|
if (isSub) return true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -153,17 +150,17 @@ function checkSenderRoles(params: {
|
|||||||
* Twitch mentions are in the format @username.
|
* Twitch mentions are in the format @username.
|
||||||
*/
|
*/
|
||||||
export function extractMentions(message: string): string[] {
|
export function extractMentions(message: string): string[] {
|
||||||
const mentionRegex = /@(\w+)/g;
|
const mentionRegex = /@(\w+)/g;
|
||||||
const mentions: string[] = [];
|
const mentions: string[] = [];
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noAssignInExpressions: Standard regex iteration pattern
|
// biome-ignore lint/suspicious/noAssignInExpressions: Standard regex iteration pattern
|
||||||
while ((match = mentionRegex.exec(message)) !== null) {
|
while ((match = mentionRegex.exec(message)) !== null) {
|
||||||
const username = match[1];
|
const username = match[1];
|
||||||
if (username) {
|
if (username) {
|
||||||
mentions.push(username.toLowerCase());
|
mentions.push(username.toLowerCase());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mentions;
|
return mentions;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,24 +6,21 @@
|
|||||||
|
|
||||||
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.
|
||||||
*/
|
*/
|
||||||
function errorResponse(error: string) {
|
function errorResponse(error: string) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ ok: false, error }),
|
text: JSON.stringify({ ok: false, error }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: { ok: false },
|
details: { ok: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -35,28 +32,28 @@ function errorResponse(error: string) {
|
|||||||
* @returns The parameter value or undefined if not found
|
* @returns The parameter value or undefined if not found
|
||||||
*/
|
*/
|
||||||
function readStringParam(
|
function readStringParam(
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
key: string,
|
key: string,
|
||||||
options: { required?: boolean; trim?: boolean } = {},
|
options: { required?: boolean; trim?: boolean } = {},
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const value = args[key];
|
const value = args[key];
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
if (options.required) {
|
if (options.required) {
|
||||||
throw new Error(`Missing required parameter: ${key}`);
|
throw new Error(`Missing required parameter: ${key}`);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert value to string safely
|
// Convert value to string safely
|
||||||
let str: string;
|
let str: string;
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
str = value;
|
str = value;
|
||||||
} else if (typeof value === "number" || typeof value === "boolean") {
|
} else if (typeof value === "number" || typeof value === "boolean") {
|
||||||
str = String(value);
|
str = String(value);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Parameter ${key} must be a string, number, or boolean`);
|
throw new Error(`Parameter ${key} must be a string, number, or boolean`);
|
||||||
}
|
}
|
||||||
return options.trim !== false ? str.trim() : str;
|
return options.trim !== false ? str.trim() : str;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Supported Twitch actions */
|
/** Supported Twitch actions */
|
||||||
@ -67,110 +64,110 @@ type TwitchAction = typeof TWITCH_ACTIONS extends Set<infer U> ? U : never;
|
|||||||
* Twitch message actions adapter.
|
* Twitch message actions adapter.
|
||||||
*/
|
*/
|
||||||
export const twitchMessageActions: ChannelMessageActionAdapter = {
|
export const twitchMessageActions: ChannelMessageActionAdapter = {
|
||||||
/**
|
/**
|
||||||
* List available actions for this channel.
|
* List available actions for this channel.
|
||||||
*/
|
*/
|
||||||
listActions: () => [...TWITCH_ACTIONS],
|
listActions: () => [...TWITCH_ACTIONS],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an action is supported.
|
* Check if an action is supported.
|
||||||
*/
|
*/
|
||||||
supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction),
|
supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract tool send parameters from action arguments.
|
* Extract tool send parameters from action arguments.
|
||||||
*
|
*
|
||||||
* Parses and validates the "to" and "message" parameters for sending.
|
* Parses and validates the "to" and "message" parameters for sending.
|
||||||
*
|
*
|
||||||
* @param params - Arguments from the tool call
|
* @param params - Arguments from the tool call
|
||||||
* @returns Parsed send parameters or null if invalid
|
* @returns Parsed send parameters or null if invalid
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const result = twitchMessageActions.extractToolSend!({
|
* const result = twitchMessageActions.extractToolSend!({
|
||||||
* args: { to: "#mychannel", message: "Hello!" }
|
* args: { to: "#mychannel", message: "Hello!" }
|
||||||
* });
|
* });
|
||||||
* // Returns: { to: "#mychannel", message: "Hello!" }
|
* // Returns: { to: "#mychannel", message: "Hello!" }
|
||||||
*/
|
*/
|
||||||
extractToolSend: ({ args }) => {
|
extractToolSend: ({ args }) => {
|
||||||
try {
|
try {
|
||||||
const to = readStringParam(args, "to", { required: true });
|
const to = readStringParam(args, "to", { required: true });
|
||||||
const message = readStringParam(args, "message", { required: true });
|
const message = readStringParam(args, "message", { required: true });
|
||||||
|
|
||||||
if (!to || !message) {
|
if (!to || !message) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { to, message };
|
return { to, message };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an action execution.
|
* Handle an action execution.
|
||||||
*
|
*
|
||||||
* Processes the "send" action to send messages to Twitch.
|
* Processes the "send" action to send messages to Twitch.
|
||||||
*
|
*
|
||||||
* @param ctx - Action context including action type, parameters, and config
|
* @param ctx - Action context including action type, parameters, and config
|
||||||
* @returns Tool result with content or null if action not supported
|
* @returns Tool result with content or null if action not supported
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const result = await twitchMessageActions.handleAction!({
|
* const result = await twitchMessageActions.handleAction!({
|
||||||
* action: "send",
|
* action: "send",
|
||||||
* params: { message: "Hello Twitch!", to: "#mychannel" },
|
* params: { message: "Hello Twitch!", to: "#mychannel" },
|
||||||
* cfg: clawdbotConfig,
|
* cfg: clawdbotConfig,
|
||||||
* accountId: "default",
|
* accountId: "default",
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
handleAction: async (
|
handleAction: async (
|
||||||
ctx: ChannelMessageActionContext,
|
ctx: ChannelMessageActionContext,
|
||||||
): Promise<{ content: Array<{ type: string; text: string }> } | null> => {
|
): Promise<{ content: Array<{ type: string; text: string }> } | null> => {
|
||||||
if (ctx.action !== "send") {
|
if (ctx.action !== "send") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = readStringParam(ctx.params, "message", { required: true });
|
const message = readStringParam(ctx.params, "message", { required: true });
|
||||||
const to = readStringParam(ctx.params, "to", { required: false });
|
const to = readStringParam(ctx.params, "to", { required: false });
|
||||||
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
|
||||||
const account = getAccountConfig(ctx.cfg, accountId);
|
const account = getAccountConfig(ctx.cfg, accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
`Account not found: ${accountId}. Available accounts: ${Object.keys(ctx.cfg.channels?.twitch?.accounts ?? {}).join(", ") || "none"}`,
|
`Account not found: ${accountId}. Available accounts: ${Object.keys(ctx.cfg.channels?.twitch?.accounts ?? {}).join(", ") || "none"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the channel from account config if not specified
|
// Use the channel from account config if not specified
|
||||||
const targetChannel = to || account.channel || account.username;
|
const targetChannel = to || account.channel || account.username;
|
||||||
|
|
||||||
if (!targetChannel) {
|
if (!targetChannel) {
|
||||||
return errorResponse("No channel specified and no default channel in account config");
|
return errorResponse("No channel specified and no default channel in account config");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!twitchOutbound.sendText) {
|
if (!twitchOutbound.sendText) {
|
||||||
return errorResponse("sendText not implemented");
|
return errorResponse("sendText not implemented");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await twitchOutbound.sendText({
|
const result = await twitchOutbound.sendText({
|
||||||
cfg: ctx.cfg,
|
cfg: ctx.cfg,
|
||||||
to: targetChannel,
|
to: targetChannel,
|
||||||
text: message ?? "",
|
text: message ?? "",
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(result),
|
text: JSON.stringify(result),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: { ok: true },
|
details: { ok: true },
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
return errorResponse(errorMsg);
|
return errorResponse(errorMsg);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,97 +2,97 @@ import { StaticAuthProvider } from "@twurple/auth";
|
|||||||
import { ChatClient } from "@twurple/chat";
|
import { ChatClient } from "@twurple/chat";
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
token?: string;
|
token?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseArgs(argv: string[]): Args {
|
function parseArgs(argv: string[]): Args {
|
||||||
const args: Args = { timeoutMs: 10000 };
|
const args: Args = { timeoutMs: 10000 };
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
const key = argv[i];
|
const key = argv[i];
|
||||||
const value = argv[i + 1];
|
const value = argv[i + 1];
|
||||||
if (!key?.startsWith("--")) continue;
|
if (!key?.startsWith("--")) continue;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "--token":
|
case "--token":
|
||||||
args.token = value;
|
args.token = value;
|
||||||
i += 1;
|
i += 1;
|
||||||
break;
|
break;
|
||||||
case "--client-id":
|
case "--client-id":
|
||||||
args.clientId = value;
|
args.clientId = value;
|
||||||
i += 1;
|
i += 1;
|
||||||
break;
|
break;
|
||||||
case "--username":
|
case "--username":
|
||||||
args.username = value;
|
args.username = value;
|
||||||
i += 1;
|
i += 1;
|
||||||
break;
|
break;
|
||||||
case "--channel":
|
case "--channel":
|
||||||
args.channel = value;
|
args.channel = value;
|
||||||
i += 1;
|
i += 1;
|
||||||
break;
|
break;
|
||||||
case "--message":
|
case "--message":
|
||||||
args.message = value;
|
args.message = value;
|
||||||
i += 1;
|
i += 1;
|
||||||
break;
|
break;
|
||||||
case "--timeout-ms":
|
case "--timeout-ms":
|
||||||
args.timeoutMs = Number(value ?? 10000);
|
args.timeoutMs = Number(value ?? 10000);
|
||||||
i += 1;
|
i += 1;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
const token = args.token;
|
const token = args.token;
|
||||||
const clientId = args.clientId;
|
const clientId = args.clientId;
|
||||||
const username = args.username;
|
const username = args.username;
|
||||||
const channel = args.channel ?? username;
|
const channel = args.channel ?? username;
|
||||||
|
|
||||||
if (!token || !clientId || !username || !channel) {
|
if (!token || !clientId || !username || !channel) {
|
||||||
console.error(
|
console.error(
|
||||||
"Usage: npx tsx src/cli/test-connect.ts --token <token> --client-id <id> --username <bot> --channel <channel> [--timeout-ms 10000]",
|
"Usage: npx tsx src/cli/test-connect.ts --token <token> --client-id <id> --username <bot> --channel <channel> [--timeout-ms 10000]",
|
||||||
);
|
);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedToken = token.startsWith("oauth:") ? token.slice(6) : token;
|
const normalizedToken = token.startsWith("oauth:") ? token.slice(6) : token;
|
||||||
|
|
||||||
const authProvider = new StaticAuthProvider(clientId, normalizedToken);
|
const authProvider = new StaticAuthProvider(clientId, normalizedToken);
|
||||||
const client = new ChatClient({
|
const client = new ChatClient({
|
||||||
authProvider,
|
authProvider,
|
||||||
requestMembershipEvents: true,
|
requestMembershipEvents: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeout = setTimeout(
|
const timeout = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
console.error("Timed out waiting for connection.");
|
console.error("Timed out waiting for connection.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
},
|
},
|
||||||
Number.isFinite(args.timeoutMs) ? args.timeoutMs : 10000,
|
Number.isFinite(args.timeoutMs) ? args.timeoutMs : 10000,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.resolve(client.connect());
|
await Promise.resolve(client.connect());
|
||||||
await Promise.resolve(client.join(channel));
|
await Promise.resolve(client.join(channel));
|
||||||
console.log(`Connected as ${username} and joined #${channel}`);
|
console.log(`Connected as ${username} and joined #${channel}`);
|
||||||
if (args.message) {
|
if (args.message) {
|
||||||
await Promise.resolve(client.say(channel, args.message));
|
await Promise.resolve(client.say(channel, args.message));
|
||||||
console.log("Message sent.");
|
console.log("Message sent.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
client.quit();
|
client.quit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run().catch((error) => {
|
run().catch((error) => {
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,14 +12,14 @@ import type { ChannelLogSink } from "./types.js";
|
|||||||
* Registry entry tracking a client manager and its associated account.
|
* Registry entry tracking a client manager and its associated account.
|
||||||
*/
|
*/
|
||||||
type RegistryEntry = {
|
type RegistryEntry = {
|
||||||
/** The client manager instance */
|
/** The client manager instance */
|
||||||
manager: TwitchClientManager;
|
manager: TwitchClientManager;
|
||||||
/** The account ID this manager is for */
|
/** The account ID this manager is for */
|
||||||
accountId: string;
|
accountId: string;
|
||||||
/** Logger for this entry */
|
/** Logger for this entry */
|
||||||
logger: ChannelLogSink;
|
logger: ChannelLogSink;
|
||||||
/** When this entry was created */
|
/** When this entry was created */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,24 +36,24 @@ const registry = new Map<string, RegistryEntry>();
|
|||||||
* @returns The client manager
|
* @returns The client manager
|
||||||
*/
|
*/
|
||||||
export function getOrCreateClientManager(
|
export function getOrCreateClientManager(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
logger: ChannelLogSink,
|
logger: ChannelLogSink,
|
||||||
): TwitchClientManager {
|
): TwitchClientManager {
|
||||||
const existing = registry.get(accountId);
|
const existing = registry.get(accountId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing.manager;
|
return existing.manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
const manager = new TwitchClientManager(logger);
|
const manager = new TwitchClientManager(logger);
|
||||||
registry.set(accountId, {
|
registry.set(accountId, {
|
||||||
manager,
|
manager,
|
||||||
accountId,
|
accountId,
|
||||||
logger,
|
logger,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[twitch] Registered client manager for account: ${accountId}`);
|
logger.info(`[twitch] Registered client manager for account: ${accountId}`);
|
||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,7 +63,7 @@ export function getOrCreateClientManager(
|
|||||||
* @returns The client manager, or undefined if not registered
|
* @returns The client manager, or undefined if not registered
|
||||||
*/
|
*/
|
||||||
export function getClientManager(accountId: string): TwitchClientManager | undefined {
|
export function getClientManager(accountId: string): TwitchClientManager | undefined {
|
||||||
return registry.get(accountId)?.manager;
|
return registry.get(accountId)?.manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -72,23 +72,21 @@ 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,
|
const entry = registry.get(accountId);
|
||||||
): Promise<void> {
|
if (!entry) {
|
||||||
const entry = registry.get(accountId);
|
return;
|
||||||
if (!entry) {
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect the client manager
|
// Disconnect the client manager
|
||||||
await entry.manager.disconnect({
|
await entry.manager.disconnect({
|
||||||
username: accountId,
|
username: accountId,
|
||||||
channel: accountId,
|
channel: accountId,
|
||||||
} as Parameters<typeof entry.manager.disconnect>[0]);
|
} as Parameters<typeof entry.manager.disconnect>[0]);
|
||||||
|
|
||||||
// Remove from registry
|
// Remove from registry
|
||||||
registry.delete(accountId);
|
registry.delete(accountId);
|
||||||
entry.logger.info(`[twitch] Unregistered client manager for account: ${accountId}`);
|
entry.logger.info(`[twitch] Unregistered client manager for account: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -97,10 +95,8 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -109,7 +105,7 @@ export async function removeAllClientManagers(): Promise<void> {
|
|||||||
* @returns The count of registered managers
|
* @returns The count of registered managers
|
||||||
*/
|
*/
|
||||||
export function getRegisteredClientManagerCount(): number {
|
export function getRegisteredClientManagerCount(): number {
|
||||||
return registry.size;
|
return registry.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -118,5 +114,5 @@ export function getRegisteredClientManagerCount(): number {
|
|||||||
* This is primarily for testing purposes.
|
* This is primarily for testing purposes.
|
||||||
*/
|
*/
|
||||||
export function _clearAllClientManagersForTest(): void {
|
export function _clearAllClientManagersForTest(): void {
|
||||||
registry.clear();
|
registry.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,90 +3,86 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { getAccountConfig, parsePluginConfig } from "./config.js";
|
import { getAccountConfig, parsePluginConfig } from "./config.js";
|
||||||
|
|
||||||
describe("parsePluginConfig", () => {
|
describe("parsePluginConfig", () => {
|
||||||
it("parses valid config with stripMarkdown true", () => {
|
it("parses valid config with stripMarkdown true", () => {
|
||||||
const result = parsePluginConfig({ stripMarkdown: true });
|
const result = parsePluginConfig({ stripMarkdown: true });
|
||||||
expect(result.stripMarkdown).toBe(true);
|
expect(result.stripMarkdown).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses valid config with stripMarkdown false", () => {
|
it("parses valid config with stripMarkdown false", () => {
|
||||||
const result = parsePluginConfig({ stripMarkdown: false });
|
const result = parsePluginConfig({ stripMarkdown: false });
|
||||||
expect(result.stripMarkdown).toBe(false);
|
expect(result.stripMarkdown).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to stripMarkdown true when not specified", () => {
|
it("defaults to stripMarkdown true when not specified", () => {
|
||||||
const result = parsePluginConfig({});
|
const result = parsePluginConfig({});
|
||||||
expect(result.stripMarkdown).toBe(true);
|
expect(result.stripMarkdown).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getAccountConfig", () => {
|
describe("getAccountConfig", () => {
|
||||||
const mockCoreConfig = {
|
const mockCoreConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123",
|
token: "oauth:test123",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it("returns account config for valid account ID", () => {
|
it("returns account config for valid account ID", () => {
|
||||||
const result = getAccountConfig(mockCoreConfig, "default");
|
const result = getAccountConfig(mockCoreConfig, "default");
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.username).toBe("testbot");
|
expect(result?.username).toBe("testbot");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for non-existent account ID", () => {
|
it("returns null for non-existent account ID", () => {
|
||||||
const result = getAccountConfig(mockCoreConfig, "nonexistent");
|
const result = getAccountConfig(mockCoreConfig, "nonexistent");
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when core config is null", () => {
|
it("returns null when core config is null", () => {
|
||||||
const result = getAccountConfig(null, "default");
|
const result = getAccountConfig(null, "default");
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when core config is undefined", () => {
|
it("returns null when core config is undefined", () => {
|
||||||
const result = getAccountConfig(undefined, "default");
|
const result = getAccountConfig(undefined, "default");
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when channels are not defined", () => {
|
it("returns null when channels are not defined", () => {
|
||||||
const result = getAccountConfig({}, "default");
|
const result = getAccountConfig({}, "default");
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when twitch is not defined", () => {
|
it("returns null when twitch is not defined", () => {
|
||||||
const result = getAccountConfig({ channels: {} }, "default");
|
const result = getAccountConfig({ channels: {} }, "default");
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when accounts are not defined", () => {
|
it("returns null when accounts are not defined", () => {
|
||||||
const result = getAccountConfig({ channels: { twitch: {} } }, "default");
|
const result = getAccountConfig({ channels: { twitch: {} } }, "default");
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
@ -13,53 +10,50 @@ export const DEFAULT_ACCOUNT_ID = "default";
|
|||||||
* Get account config from core config
|
* Get account config from core config
|
||||||
*/
|
*/
|
||||||
export function getAccountConfig(
|
export function getAccountConfig(
|
||||||
coreConfig: unknown,
|
coreConfig: unknown,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
): TwitchAccountConfig | null {
|
): TwitchAccountConfig | null {
|
||||||
if (!coreConfig || typeof coreConfig !== "object") {
|
if (!coreConfig || typeof coreConfig !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = coreConfig as Record<string, unknown>;
|
const cfg = coreConfig as Record<string, unknown>;
|
||||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||||
const twitch = channels?.twitch as Record<string, unknown> | undefined;
|
const twitch = channels?.twitch as Record<string, unknown> | undefined;
|
||||||
const accounts = twitch?.accounts as Record<string, unknown> | undefined;
|
const accounts = twitch?.accounts as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (!accounts || !accounts[accountId]) {
|
if (!accounts || !accounts[accountId]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts[accountId] as TwitchAccountConfig | null;
|
return accounts[accountId] as TwitchAccountConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse plugin config
|
* Parse plugin config
|
||||||
*/
|
*/
|
||||||
export function parsePluginConfig(value: unknown): TwitchPluginConfig {
|
export function parsePluginConfig(value: unknown): TwitchPluginConfig {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return { stripMarkdown: true };
|
return { stripMarkdown: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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>
|
const twitch = accounts?.twitch as Record<string, unknown> | undefined;
|
||||||
| undefined;
|
const accountMap = twitch?.accounts as Record<string, unknown> | undefined;
|
||||||
const twitch = accounts?.twitch as Record<string, unknown> | undefined;
|
|
||||||
const accountMap = twitch?.accounts as Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
if (!accountMap) {
|
if (!accountMap) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(accountMap);
|
return Object.keys(accountMap);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -19,300 +19,300 @@ import type { TwitchAccountConfig } from "./types.js";
|
|||||||
const mockPromptText = vi.fn();
|
const mockPromptText = vi.fn();
|
||||||
const mockPromptConfirm = vi.fn();
|
const mockPromptConfirm = vi.fn();
|
||||||
const mockPrompter: WizardPrompter = {
|
const mockPrompter: WizardPrompter = {
|
||||||
text: mockPromptText,
|
text: mockPromptText,
|
||||||
confirm: mockPromptConfirm,
|
confirm: mockPromptConfirm,
|
||||||
} as unknown as WizardPrompter;
|
} as unknown as WizardPrompter;
|
||||||
|
|
||||||
const mockAccount: TwitchAccountConfig = {
|
const mockAccount: TwitchAccountConfig = {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123",
|
token: "oauth:test123",
|
||||||
clientId: "test-client-id",
|
clientId: "test-client-id",
|
||||||
channel: "#testchannel",
|
channel: "#testchannel",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("onboarding helpers", () => {
|
describe("onboarding helpers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Don't restoreAllMocks as it breaks module-level mocks
|
// Don't restoreAllMocks as it breaks module-level mocks
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("promptToken", () => {
|
describe("promptToken", () => {
|
||||||
it("should return existing token when user confirms to keep it", async () => {
|
it("should return existing token when user confirms to keep it", async () => {
|
||||||
const { promptToken } = await import("./onboarding.js");
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptConfirm.mockResolvedValue(true);
|
mockPromptConfirm.mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await promptToken(mockPrompter, mockAccount, undefined);
|
const result = await promptToken(mockPrompter, mockAccount, undefined);
|
||||||
|
|
||||||
expect(result).toBe("oauth:test123");
|
expect(result).toBe("oauth:test123");
|
||||||
expect(mockPromptConfirm).toHaveBeenCalledWith({
|
expect(mockPromptConfirm).toHaveBeenCalledWith({
|
||||||
message: "Token already configured. Keep it?",
|
message: "Token already configured. Keep it?",
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
expect(mockPromptText).not.toHaveBeenCalled();
|
expect(mockPromptText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prompt for new token when user doesn't keep existing", async () => {
|
it("should prompt for new token when user doesn't keep existing", async () => {
|
||||||
const { promptToken } = await import("./onboarding.js");
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptConfirm.mockResolvedValue(false);
|
mockPromptConfirm.mockResolvedValue(false);
|
||||||
mockPromptText.mockResolvedValue("oauth:newtoken123");
|
mockPromptText.mockResolvedValue("oauth:newtoken123");
|
||||||
|
|
||||||
const result = await promptToken(mockPrompter, mockAccount, undefined);
|
const result = await promptToken(mockPrompter, mockAccount, undefined);
|
||||||
|
|
||||||
expect(result).toBe("oauth:newtoken123");
|
expect(result).toBe("oauth:newtoken123");
|
||||||
expect(mockPromptText).toHaveBeenCalledWith({
|
expect(mockPromptText).toHaveBeenCalledWith({
|
||||||
message: "Twitch OAuth token (oauth:...)",
|
message: "Twitch OAuth token (oauth:...)",
|
||||||
initialValue: "",
|
initialValue: "",
|
||||||
validate: expect.any(Function),
|
validate: expect.any(Function),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use env token as initial value when provided", async () => {
|
it("should use env token as initial value when provided", async () => {
|
||||||
const { promptToken } = await import("./onboarding.js");
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptConfirm.mockResolvedValue(false);
|
mockPromptConfirm.mockResolvedValue(false);
|
||||||
mockPromptText.mockResolvedValue("oauth:fromenv");
|
mockPromptText.mockResolvedValue("oauth:fromenv");
|
||||||
|
|
||||||
await promptToken(mockPrompter, null, "oauth:fromenv");
|
await promptToken(mockPrompter, null, "oauth:fromenv");
|
||||||
|
|
||||||
expect(mockPromptText).toHaveBeenCalledWith(
|
expect(mockPromptText).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
initialValue: "oauth:fromenv",
|
initialValue: "oauth:fromenv",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should validate token format", async () => {
|
it("should validate token format", async () => {
|
||||||
const { promptToken } = await import("./onboarding.js");
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
// Set up mocks - user doesn't want to keep existing token
|
// Set up mocks - user doesn't want to keep existing token
|
||||||
mockPromptConfirm.mockResolvedValueOnce(false);
|
mockPromptConfirm.mockResolvedValueOnce(false);
|
||||||
|
|
||||||
// Track how many times promptText is called
|
// Track how many times promptText is called
|
||||||
let promptTextCallCount = 0;
|
let promptTextCallCount = 0;
|
||||||
let capturedValidate: ((value: string) => string | undefined) | undefined;
|
let capturedValidate: ((value: string) => string | undefined) | undefined;
|
||||||
|
|
||||||
mockPromptText.mockImplementationOnce((_args) => {
|
mockPromptText.mockImplementationOnce((_args) => {
|
||||||
promptTextCallCount++;
|
promptTextCallCount++;
|
||||||
// Capture the validate function from the first argument
|
// Capture the validate function from the first argument
|
||||||
if (_args?.validate) {
|
if (_args?.validate) {
|
||||||
capturedValidate = _args.validate;
|
capturedValidate = _args.validate;
|
||||||
}
|
}
|
||||||
return Promise.resolve("oauth:test123");
|
return Promise.resolve("oauth:test123");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call promptToken
|
// Call promptToken
|
||||||
const result = await promptToken(mockPrompter, mockAccount, undefined);
|
const result = await promptToken(mockPrompter, mockAccount, undefined);
|
||||||
|
|
||||||
// Verify promptText was called
|
// Verify promptText was called
|
||||||
expect(promptTextCallCount).toBe(1);
|
expect(promptTextCallCount).toBe(1);
|
||||||
expect(result).toBe("oauth:test123");
|
expect(result).toBe("oauth:test123");
|
||||||
|
|
||||||
// Test the validate function
|
// Test the validate function
|
||||||
expect(capturedValidate).toBeDefined();
|
expect(capturedValidate).toBeDefined();
|
||||||
expect(capturedValidate!("")).toBe("Required");
|
expect(capturedValidate!("")).toBe("Required");
|
||||||
expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'");
|
expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return early when no existing token and no env token", async () => {
|
it("should return early when no existing token and no env token", async () => {
|
||||||
const { promptToken } = await import("./onboarding.js");
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptText.mockResolvedValue("oauth:newtoken");
|
mockPromptText.mockResolvedValue("oauth:newtoken");
|
||||||
|
|
||||||
const result = await promptToken(mockPrompter, null, undefined);
|
const result = await promptToken(mockPrompter, null, undefined);
|
||||||
|
|
||||||
expect(result).toBe("oauth:newtoken");
|
expect(result).toBe("oauth:newtoken");
|
||||||
expect(mockPromptConfirm).not.toHaveBeenCalled();
|
expect(mockPromptConfirm).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("promptUsername", () => {
|
describe("promptUsername", () => {
|
||||||
it("should prompt for username with validation", async () => {
|
it("should prompt for username with validation", async () => {
|
||||||
const { promptUsername } = await import("./onboarding.js");
|
const { promptUsername } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptText.mockResolvedValue("mybot");
|
mockPromptText.mockResolvedValue("mybot");
|
||||||
|
|
||||||
const result = await promptUsername(mockPrompter, null);
|
const result = await promptUsername(mockPrompter, null);
|
||||||
|
|
||||||
expect(result).toBe("mybot");
|
expect(result).toBe("mybot");
|
||||||
expect(mockPromptText).toHaveBeenCalledWith({
|
expect(mockPromptText).toHaveBeenCalledWith({
|
||||||
message: "Twitch bot username",
|
message: "Twitch bot username",
|
||||||
initialValue: "",
|
initialValue: "",
|
||||||
validate: expect.any(Function),
|
validate: expect.any(Function),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use existing username as initial value", async () => {
|
it("should use existing username as initial value", async () => {
|
||||||
const { promptUsername } = await import("./onboarding.js");
|
const { promptUsername } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptText.mockResolvedValue("testbot");
|
mockPromptText.mockResolvedValue("testbot");
|
||||||
|
|
||||||
await promptUsername(mockPrompter, mockAccount);
|
await promptUsername(mockPrompter, mockAccount);
|
||||||
|
|
||||||
expect(mockPromptText).toHaveBeenCalledWith(
|
expect(mockPromptText).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
initialValue: "testbot",
|
initialValue: "testbot",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("promptClientId", () => {
|
describe("promptClientId", () => {
|
||||||
it("should prompt for client ID with validation", async () => {
|
it("should prompt for client ID with validation", async () => {
|
||||||
const { promptClientId } = await import("./onboarding.js");
|
const { promptClientId } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptText.mockResolvedValue("abc123xyz");
|
mockPromptText.mockResolvedValue("abc123xyz");
|
||||||
|
|
||||||
const result = await promptClientId(mockPrompter, null);
|
const result = await promptClientId(mockPrompter, null);
|
||||||
|
|
||||||
expect(result).toBe("abc123xyz");
|
expect(result).toBe("abc123xyz");
|
||||||
expect(mockPromptText).toHaveBeenCalledWith({
|
expect(mockPromptText).toHaveBeenCalledWith({
|
||||||
message: "Twitch Client ID",
|
message: "Twitch Client ID",
|
||||||
initialValue: "",
|
initialValue: "",
|
||||||
validate: expect.any(Function),
|
validate: expect.any(Function),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("promptChannelName", () => {
|
describe("promptChannelName", () => {
|
||||||
it("should return channel name when provided", async () => {
|
it("should return channel name when provided", async () => {
|
||||||
const { promptChannelName } = await import("./onboarding.js");
|
const { promptChannelName } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptText.mockResolvedValue("#mychannel");
|
mockPromptText.mockResolvedValue("#mychannel");
|
||||||
|
|
||||||
const result = await promptChannelName(mockPrompter, null);
|
const result = await promptChannelName(mockPrompter, null);
|
||||||
|
|
||||||
expect(result).toBe("#mychannel");
|
expect(result).toBe("#mychannel");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return undefined when empty string provided", async () => {
|
it("should return undefined when empty string provided", async () => {
|
||||||
const { promptChannelName } = await import("./onboarding.js");
|
const { promptChannelName } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptText.mockResolvedValue("");
|
mockPromptText.mockResolvedValue("");
|
||||||
|
|
||||||
const result = await promptChannelName(mockPrompter, null);
|
const result = await promptChannelName(mockPrompter, null);
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return undefined when whitespace only", async () => {
|
it("should return undefined when whitespace only", async () => {
|
||||||
const { promptChannelName } = await import("./onboarding.js");
|
const { promptChannelName } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptText.mockResolvedValue(" ");
|
mockPromptText.mockResolvedValue(" ");
|
||||||
|
|
||||||
const result = await promptChannelName(mockPrompter, null);
|
const result = await promptChannelName(mockPrompter, null);
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("promptRefreshTokenSetup", () => {
|
describe("promptRefreshTokenSetup", () => {
|
||||||
it("should return empty object when user declines", async () => {
|
it("should return empty object when user declines", async () => {
|
||||||
const { promptRefreshTokenSetup } = await import("./onboarding.js");
|
const { promptRefreshTokenSetup } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptConfirm.mockResolvedValue(false);
|
mockPromptConfirm.mockResolvedValue(false);
|
||||||
|
|
||||||
const result = await promptRefreshTokenSetup(mockPrompter, mockAccount);
|
const result = await promptRefreshTokenSetup(mockPrompter, mockAccount);
|
||||||
|
|
||||||
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,
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("should prompt for credentials when user accepts", async () => {
|
it("should prompt for credentials when user accepts", async () => {
|
||||||
const { promptRefreshTokenSetup } = await import("./onboarding.js");
|
const { promptRefreshTokenSetup } = await import("./onboarding.js");
|
||||||
|
|
||||||
mockPromptConfirm
|
mockPromptConfirm
|
||||||
.mockResolvedValueOnce(true) // First call: useRefresh
|
.mockResolvedValueOnce(true) // First call: useRefresh
|
||||||
.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);
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
clientSecret: "secret123",
|
|
||||||
refreshToken: "refresh123",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use existing values as initial prompts", async () => {
|
expect(result).toEqual({
|
||||||
const { promptRefreshTokenSetup } = await import("./onboarding.js");
|
clientSecret: "secret123",
|
||||||
|
refreshToken: "refresh123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const accountWithRefresh = {
|
it("should use existing values as initial prompts", async () => {
|
||||||
...mockAccount,
|
const { promptRefreshTokenSetup } = await import("./onboarding.js");
|
||||||
clientSecret: "existing-secret",
|
|
||||||
refreshToken: "existing-refresh",
|
|
||||||
};
|
|
||||||
|
|
||||||
mockPromptConfirm.mockResolvedValue(true);
|
|
||||||
mockPromptText
|
|
||||||
.mockResolvedValueOnce("existing-secret")
|
|
||||||
.mockResolvedValueOnce("existing-refresh");
|
|
||||||
|
|
||||||
await promptRefreshTokenSetup(mockPrompter, accountWithRefresh);
|
const accountWithRefresh = {
|
||||||
|
...mockAccount,
|
||||||
|
clientSecret: "existing-secret",
|
||||||
|
refreshToken: "existing-refresh",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPromptConfirm.mockResolvedValue(true);
|
||||||
|
mockPromptText
|
||||||
|
.mockResolvedValueOnce("existing-secret")
|
||||||
|
.mockResolvedValueOnce("existing-refresh");
|
||||||
|
|
||||||
expect(mockPromptConfirm).toHaveBeenCalledWith(
|
await promptRefreshTokenSetup(mockPrompter, accountWithRefresh);
|
||||||
expect.objectContaining({
|
|
||||||
initialValue: true, // Both clientSecret and refreshToken exist
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("configureWithEnvToken", () => {
|
|
||||||
it("should return null when user declines env token", async () => {
|
|
||||||
const { configureWithEnvToken } = await import("./onboarding.js");
|
|
||||||
|
|
||||||
// Reset and set up mock - user declines env token
|
expect(mockPromptConfirm).toHaveBeenCalledWith(
|
||||||
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
|
expect.objectContaining({
|
||||||
|
initialValue: true, // Both clientSecret and refreshToken exist
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const result = await configureWithEnvToken(
|
describe("configureWithEnvToken", () => {
|
||||||
{} as Parameters<typeof configureWithEnvToken>[0],
|
it("should return null when user declines env token", async () => {
|
||||||
mockPrompter,
|
const { configureWithEnvToken } = await import("./onboarding.js");
|
||||||
null,
|
|
||||||
"oauth:fromenv",
|
// Reset and set up mock - user declines env token
|
||||||
false,
|
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
|
||||||
{} as Parameters<typeof configureWithEnvToken>[5],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Since user declined, should return null without prompting for username/clientId
|
const result = await configureWithEnvToken(
|
||||||
expect(result).toBeNull();
|
{} as Parameters<typeof configureWithEnvToken>[0],
|
||||||
expect(mockPromptText).not.toHaveBeenCalled();
|
mockPrompter,
|
||||||
});
|
null,
|
||||||
|
"oauth:fromenv",
|
||||||
it("should prompt for username and clientId when using env token", async () => {
|
false,
|
||||||
const { configureWithEnvToken } = await import("./onboarding.js");
|
{} as Parameters<typeof configureWithEnvToken>[5],
|
||||||
|
);
|
||||||
// Reset and set up mocks - user accepts env token
|
|
||||||
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
|
// Since user declined, should return null without prompting for username/clientId
|
||||||
|
expect(result).toBeNull();
|
||||||
// Set up mocks for username and clientId prompts
|
expect(mockPromptText).not.toHaveBeenCalled();
|
||||||
mockPromptText.mockReset().mockResolvedValueOnce("testbot" as never).mockResolvedValueOnce("test-client-id" as never);
|
});
|
||||||
|
|
||||||
const result = await configureWithEnvToken(
|
it("should prompt for username and clientId when using env token", async () => {
|
||||||
{} as Parameters<typeof configureWithEnvToken>[0],
|
const { configureWithEnvToken } = await import("./onboarding.js");
|
||||||
mockPrompter,
|
|
||||||
null,
|
// Reset and set up mocks - user accepts env token
|
||||||
"oauth:fromenv",
|
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
|
||||||
false,
|
|
||||||
{} as Parameters<typeof configureWithEnvToken>[5],
|
// Set up mocks for username and clientId prompts
|
||||||
);
|
mockPromptText
|
||||||
|
.mockReset()
|
||||||
// Should return config with username and clientId
|
.mockResolvedValueOnce("testbot" as never)
|
||||||
expect(result).not.toBeNull();
|
.mockResolvedValueOnce("test-client-id" as never);
|
||||||
expect(result?.cfg.channels?.twitch?.accounts?.default?.username).toBe("testbot");
|
|
||||||
expect(result?.cfg.channels?.twitch?.accounts?.default?.clientId).toBe("test-client-id");
|
const result = await configureWithEnvToken(
|
||||||
});
|
{} as Parameters<typeof configureWithEnvToken>[0],
|
||||||
});
|
mockPrompter,
|
||||||
|
null,
|
||||||
|
"oauth:fromenv",
|
||||||
|
false,
|
||||||
|
{} as Parameters<typeof configureWithEnvToken>[5],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should return config with username and clientId
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.cfg.channels?.twitch?.accounts?.default?.username).toBe("testbot");
|
||||||
|
expect(result?.cfg.channels?.twitch?.accounts?.default?.clientId).toBe("test-client-id");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,21 +192,23 @@ async function promptRefreshTokenSetup(
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientSecret = String(
|
const clientSecret =
|
||||||
await prompter.text({
|
String(
|
||||||
message: "Twitch Client Secret (for token refresh)",
|
await prompter.text({
|
||||||
initialValue: account?.clientSecret ?? "",
|
message: "Twitch Client Secret (for token refresh)",
|
||||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
initialValue: account?.clientSecret ?? "",
|
||||||
}),
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
).trim() || undefined;
|
}),
|
||||||
|
).trim() || undefined;
|
||||||
|
|
||||||
const refreshToken = String(
|
const refreshToken =
|
||||||
await prompter.text({
|
String(
|
||||||
message: "Twitch Refresh Token",
|
await prompter.text({
|
||||||
initialValue: account?.refreshToken ?? "",
|
message: "Twitch Refresh Token",
|
||||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
initialValue: account?.refreshToken ?? "",
|
||||||
}),
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
).trim() || undefined;
|
}),
|
||||||
|
).trim() || undefined;
|
||||||
|
|
||||||
return { clientSecret, refreshToken };
|
return { clientSecret, 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,
|
||||||
|
};
|
||||||
|
|||||||
@ -15,389 +15,389 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
|||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("./config.js", () => ({
|
vi.mock("./config.js", () => ({
|
||||||
DEFAULT_ACCOUNT_ID: "default",
|
DEFAULT_ACCOUNT_ID: "default",
|
||||||
getAccountConfig: vi.fn(),
|
getAccountConfig: vi.fn(),
|
||||||
parsePluginConfig: vi.fn(() => ({ stripMarkdown: true })),
|
parsePluginConfig: vi.fn(() => ({ stripMarkdown: true })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./send.js", () => ({
|
vi.mock("./send.js", () => ({
|
||||||
sendMessageTwitchInternal: vi.fn(),
|
sendMessageTwitchInternal: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./utils/markdown.js", () => ({
|
vi.mock("./utils/markdown.js", () => ({
|
||||||
chunkTextForTwitch: vi.fn((text) => text.split(/(.{500})/).filter(Boolean)),
|
chunkTextForTwitch: vi.fn((text) => text.split(/(.{500})/).filter(Boolean)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./utils/twitch.js", () => ({
|
vi.mock("./utils/twitch.js", () => ({
|
||||||
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
|
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
|
||||||
missingTargetError: (channel: string, hint: string) =>
|
missingTargetError: (channel: string, hint: string) =>
|
||||||
`Missing target for ${channel}. Provide ${hint}`,
|
`Missing target for ${channel}. Provide ${hint}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("outbound", () => {
|
describe("outbound", () => {
|
||||||
const mockAccount = {
|
const mockAccount = {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123",
|
token: "oauth:test123",
|
||||||
clientId: "test-client-id",
|
clientId: "test-client-id",
|
||||||
channel: "#testchannel",
|
channel: "#testchannel",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: mockAccount,
|
default: mockAccount,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("metadata", () => {
|
describe("metadata", () => {
|
||||||
it("should have direct delivery mode", () => {
|
it("should have direct delivery mode", () => {
|
||||||
expect(twitchOutbound.deliveryMode).toBe("direct");
|
expect(twitchOutbound.deliveryMode).toBe("direct");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have 500 character text chunk limit", () => {
|
it("should have 500 character text chunk limit", () => {
|
||||||
expect(twitchOutbound.textChunkLimit).toBe(500);
|
expect(twitchOutbound.textChunkLimit).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have chunker function", () => {
|
it("should have chunker function", () => {
|
||||||
expect(twitchOutbound.chunker).toBeDefined();
|
expect(twitchOutbound.chunker).toBeDefined();
|
||||||
expect(typeof twitchOutbound.chunker).toBe("function");
|
expect(typeof twitchOutbound.chunker).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTarget", () => {
|
describe("resolveTarget", () => {
|
||||||
it("should normalize and return target in explicit mode", () => {
|
it("should normalize and return target in explicit mode", () => {
|
||||||
const result = twitchOutbound.resolveTarget({
|
const result = twitchOutbound.resolveTarget({
|
||||||
to: "#MyChannel",
|
to: "#MyChannel",
|
||||||
mode: "explicit",
|
mode: "explicit",
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.to).toBe("mychannel");
|
expect(result.to).toBe("mychannel");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return target in implicit mode with wildcard allowlist", () => {
|
it("should return target in implicit mode with wildcard allowlist", () => {
|
||||||
const result = twitchOutbound.resolveTarget({
|
const result = twitchOutbound.resolveTarget({
|
||||||
to: "#AnyChannel",
|
to: "#AnyChannel",
|
||||||
mode: "implicit",
|
mode: "implicit",
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.to).toBe("anychannel");
|
expect(result.to).toBe("anychannel");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return target in implicit mode when in allowlist", () => {
|
it("should return target in implicit mode when in allowlist", () => {
|
||||||
const result = twitchOutbound.resolveTarget({
|
const result = twitchOutbound.resolveTarget({
|
||||||
to: "#allowed",
|
to: "#allowed",
|
||||||
mode: "implicit",
|
mode: "implicit",
|
||||||
allowFrom: ["#allowed", "#other"],
|
allowFrom: ["#allowed", "#other"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.to).toBe("allowed");
|
expect(result.to).toBe("allowed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fallback to first allowlist entry when target not in list", () => {
|
it("should fallback to first allowlist entry when target not in list", () => {
|
||||||
const result = twitchOutbound.resolveTarget({
|
const result = twitchOutbound.resolveTarget({
|
||||||
to: "#notallowed",
|
to: "#notallowed",
|
||||||
mode: "implicit",
|
mode: "implicit",
|
||||||
allowFrom: ["#primary", "#secondary"],
|
allowFrom: ["#primary", "#secondary"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.to).toBe("primary");
|
expect(result.to).toBe("primary");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should accept any target when allowlist is empty", () => {
|
it("should accept any target when allowlist is empty", () => {
|
||||||
const result = twitchOutbound.resolveTarget({
|
const result = twitchOutbound.resolveTarget({
|
||||||
to: "#anychannel",
|
to: "#anychannel",
|
||||||
mode: "heartbeat",
|
mode: "heartbeat",
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.to).toBe("anychannel");
|
expect(result.to).toBe("anychannel");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use first allowlist entry when no target provided", () => {
|
it("should use first allowlist entry when no target provided", () => {
|
||||||
const result = twitchOutbound.resolveTarget({
|
const result = twitchOutbound.resolveTarget({
|
||||||
to: undefined,
|
to: undefined,
|
||||||
mode: "implicit",
|
mode: "implicit",
|
||||||
allowFrom: ["#fallback", "#other"],
|
allowFrom: ["#fallback", "#other"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.to).toBe("fallback");
|
expect(result.to).toBe("fallback");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return error when no target and no allowlist", () => {
|
it("should return error when no target and no allowlist", () => {
|
||||||
const result = twitchOutbound.resolveTarget({
|
const result = twitchOutbound.resolveTarget({
|
||||||
to: undefined,
|
to: undefined,
|
||||||
mode: "explicit",
|
mode: "explicit",
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("Missing target");
|
expect(result.error).toContain("Missing target");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle whitespace-only target", () => {
|
it("should handle whitespace-only target", () => {
|
||||||
const result = twitchOutbound.resolveTarget({
|
const result = twitchOutbound.resolveTarget({
|
||||||
to: " ",
|
to: " ",
|
||||||
mode: "explicit",
|
mode: "explicit",
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("Missing target");
|
expect(result.error).toContain("Missing target");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter wildcard from allowlist when checking membership", () => {
|
it("should filter wildcard from allowlist when checking membership", () => {
|
||||||
const result = twitchOutbound.resolveTarget({
|
const result = twitchOutbound.resolveTarget({
|
||||||
to: "#mychannel",
|
to: "#mychannel",
|
||||||
mode: "implicit",
|
mode: "implicit",
|
||||||
allowFrom: ["*", "#specific"],
|
allowFrom: ["*", "#specific"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// With wildcard, any target is accepted
|
// With wildcard, any target is accepted
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.to).toBe("mychannel");
|
expect(result.to).toBe("mychannel");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendText", () => {
|
describe("sendText", () => {
|
||||||
it("should send message successfully", async () => {
|
it("should send message successfully", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
messageId: "twitch-msg-123",
|
messageId: "twitch-msg-123",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await twitchOutbound.sendText({
|
const result = await twitchOutbound.sendText({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: "#testchannel",
|
to: "#testchannel",
|
||||||
text: "Hello Twitch!",
|
text: "Hello Twitch!",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.channel).toBe("twitch");
|
expect(result.channel).toBe("twitch");
|
||||||
expect(result.messageId).toBe("twitch-msg-123");
|
expect(result.messageId).toBe("twitch-msg-123");
|
||||||
expect(result.to).toBe("testchannel");
|
expect(result.to).toBe("testchannel");
|
||||||
expect(result.timestamp).toBeGreaterThan(0);
|
expect(result.timestamp).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when account not found", async () => {
|
it("should throw when account not found", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(null);
|
vi.mocked(getAccountConfig).mockReturnValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
twitchOutbound.sendText({
|
twitchOutbound.sendText({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: "#testchannel",
|
to: "#testchannel",
|
||||||
text: "Hello!",
|
text: "Hello!",
|
||||||
accountId: "nonexistent",
|
accountId: "nonexistent",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("Twitch account not found: nonexistent");
|
).rejects.toThrow("Twitch account not found: nonexistent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when no channel specified", async () => {
|
it("should throw when no channel specified", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
const accountWithoutChannel = { ...mockAccount, channel: undefined, username: undefined };
|
const accountWithoutChannel = { ...mockAccount, channel: undefined, username: undefined };
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
|
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
twitchOutbound.sendText({
|
twitchOutbound.sendText({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: undefined,
|
to: undefined,
|
||||||
text: "Hello!",
|
text: "Hello!",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("No channel specified");
|
).rejects.toThrow("No channel specified");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use account channel when target not provided", async () => {
|
it("should use account channel when target not provided", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
messageId: "msg-456",
|
messageId: "msg-456",
|
||||||
});
|
});
|
||||||
|
|
||||||
await twitchOutbound.sendText({
|
await twitchOutbound.sendText({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: undefined,
|
to: undefined,
|
||||||
text: "Hello!",
|
text: "Hello!",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||||
"testchannel",
|
"testchannel",
|
||||||
"Hello!",
|
"Hello!",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
true,
|
true,
|
||||||
console,
|
console,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle abort signal", async () => {
|
it("should handle abort signal", async () => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
twitchOutbound.sendText({
|
twitchOutbound.sendText({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: "#testchannel",
|
to: "#testchannel",
|
||||||
text: "Hello!",
|
text: "Hello!",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("Outbound delivery aborted");
|
).rejects.toThrow("Outbound delivery aborted");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw on send failure", async () => {
|
it("should throw on send failure", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
messageId: "failed-msg",
|
messageId: "failed-msg",
|
||||||
error: "Connection lost",
|
error: "Connection lost",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
twitchOutbound.sendText({
|
twitchOutbound.sendText({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: "#testchannel",
|
to: "#testchannel",
|
||||||
text: "Hello!",
|
text: "Hello!",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("Connection lost");
|
).rejects.toThrow("Connection lost");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should respect stripMarkdown config", async () => {
|
it("should respect stripMarkdown config", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { parsePluginConfig } = await import("./config.js");
|
const { parsePluginConfig } = await import("./config.js");
|
||||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(parsePluginConfig).mockReturnValue({ stripMarkdown: false });
|
vi.mocked(parsePluginConfig).mockReturnValue({ stripMarkdown: false });
|
||||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
messageId: "msg-789",
|
messageId: "msg-789",
|
||||||
});
|
});
|
||||||
|
|
||||||
await twitchOutbound.sendText({
|
await twitchOutbound.sendText({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: "#testchannel",
|
to: "#testchannel",
|
||||||
text: "**Bold** text",
|
text: "**Bold** text",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
false, // stripMarkdown disabled
|
false, // stripMarkdown disabled
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMedia", () => {
|
describe("sendMedia", () => {
|
||||||
it("should combine text and media URL", async () => {
|
it("should combine text and media URL", async () => {
|
||||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
messageId: "media-msg-123",
|
messageId: "media-msg-123",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await twitchOutbound.sendMedia({
|
const result = await twitchOutbound.sendMedia({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: "#testchannel",
|
to: "#testchannel",
|
||||||
text: "Check this:",
|
text: "Check this:",
|
||||||
mediaUrl: "https://example.com/image.png",
|
mediaUrl: "https://example.com/image.png",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.channel).toBe("twitch");
|
expect(result.channel).toBe("twitch");
|
||||||
expect(result.messageId).toBe("media-msg-123");
|
expect(result.messageId).toBe("media-msg-123");
|
||||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
"Check this: https://example.com/image.png",
|
"Check this: https://example.com/image.png",
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should send media URL only when no text", async () => {
|
it("should send media URL only when no text", async () => {
|
||||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
messageId: "media-only-msg",
|
messageId: "media-only-msg",
|
||||||
});
|
});
|
||||||
|
|
||||||
await twitchOutbound.sendMedia({
|
await twitchOutbound.sendMedia({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: "#testchannel",
|
to: "#testchannel",
|
||||||
text: undefined,
|
text: undefined,
|
||||||
mediaUrl: "https://example.com/image.png",
|
mediaUrl: "https://example.com/image.png",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
"https://example.com/image.png",
|
"https://example.com/image.png",
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle abort signal", async () => {
|
it("should handle abort signal", async () => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
twitchOutbound.sendMedia({
|
twitchOutbound.sendMedia({
|
||||||
cfg: mockConfig,
|
cfg: mockConfig,
|
||||||
to: "#testchannel",
|
to: "#testchannel",
|
||||||
text: "Check this:",
|
text: "Check this:",
|
||||||
mediaUrl: "https://example.com/image.png",
|
mediaUrl: "https://example.com/image.png",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("Outbound delivery aborted");
|
).rejects.toThrow("Outbound delivery aborted");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,16 +5,12 @@
|
|||||||
* 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,
|
||||||
ChannelOutboundContext,
|
ChannelOutboundContext,
|
||||||
OutboundDeliveryResult,
|
OutboundDeliveryResult,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { chunkTextForTwitch } from "./utils/markdown.js";
|
import { chunkTextForTwitch } from "./utils/markdown.js";
|
||||||
import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
|
import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
|
||||||
@ -26,184 +22,178 @@ import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
|
|||||||
* markdown stripping and message chunking.
|
* markdown stripping and message chunking.
|
||||||
*/
|
*/
|
||||||
export const twitchOutbound: ChannelOutboundAdapter = {
|
export const twitchOutbound: ChannelOutboundAdapter = {
|
||||||
/** Direct delivery mode - messages are sent immediately */
|
/** Direct delivery mode - messages are sent immediately */
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
|
|
||||||
/** Twitch chat message limit is 500 characters */
|
/** Twitch chat message limit is 500 characters */
|
||||||
textChunkLimit: 500,
|
textChunkLimit: 500,
|
||||||
|
|
||||||
/** Word-boundary chunker with markdown stripping */
|
/** Word-boundary chunker with markdown stripping */
|
||||||
chunker: chunkTextForTwitch,
|
chunker: chunkTextForTwitch,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve target from context.
|
* Resolve target from context.
|
||||||
*
|
*
|
||||||
* Handles target resolution with allowlist support for implicit/heartbeat modes.
|
* Handles target resolution with allowlist support for implicit/heartbeat modes.
|
||||||
* For explicit mode, accepts any valid channel name.
|
* For explicit mode, accepts any valid channel name.
|
||||||
*
|
*
|
||||||
* @param params - Resolution parameters
|
* @param params - Resolution parameters
|
||||||
* @returns Resolved target or error
|
* @returns Resolved target or error
|
||||||
*/
|
*/
|
||||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||||
const trimmed = to?.trim() ?? "";
|
const trimmed = to?.trim() ?? "";
|
||||||
const allowListRaw = (allowFrom ?? [])
|
const allowListRaw = (allowFrom ?? [])
|
||||||
.map((entry: unknown) => String(entry).trim())
|
.map((entry: unknown) => String(entry).trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const hasWildcard = allowListRaw.includes("*");
|
const hasWildcard = allowListRaw.includes("*");
|
||||||
const allowList = allowListRaw
|
const allowList = allowListRaw
|
||||||
.filter((entry: string) => entry !== "*")
|
.filter((entry: string) => entry !== "*")
|
||||||
.map((entry: string) => normalizeTwitchChannel(entry))
|
.map((entry: string) => normalizeTwitchChannel(entry))
|
||||||
.filter((entry): entry is string => entry.length > 0);
|
.filter((entry): entry is string => entry.length > 0);
|
||||||
|
|
||||||
// If target is provided, normalize and validate it
|
// If target is provided, normalize and validate it
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
const normalizedTo = normalizeTwitchChannel(trimmed);
|
const normalizedTo = normalizeTwitchChannel(trimmed);
|
||||||
|
|
||||||
// For implicit/heartbeat modes with allowList, check against allowlist
|
// For implicit/heartbeat modes with allowList, check against allowlist
|
||||||
if (mode === "implicit" || mode === "heartbeat") {
|
if (mode === "implicit" || mode === "heartbeat") {
|
||||||
if (hasWildcard || allowList.length === 0) {
|
if (hasWildcard || allowList.length === 0) {
|
||||||
return { ok: true, to: normalizedTo };
|
return { ok: true, to: normalizedTo };
|
||||||
}
|
}
|
||||||
if (allowList.includes(normalizedTo)) {
|
if (allowList.includes(normalizedTo)) {
|
||||||
return { ok: true, to: normalizedTo };
|
return { ok: true, to: normalizedTo };
|
||||||
}
|
}
|
||||||
// Fallback to first allowFrom entry
|
// Fallback to first allowFrom entry
|
||||||
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
|
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
|
||||||
return { ok: true, to: allowList[0]! };
|
return { ok: true, to: allowList[0]! };
|
||||||
}
|
}
|
||||||
|
|
||||||
// For explicit mode, accept any valid channel name
|
// For explicit mode, accept any valid channel name
|
||||||
return { ok: true, to: normalizedTo };
|
return { ok: true, to: normalizedTo };
|
||||||
}
|
}
|
||||||
|
|
||||||
// No target provided, use allowFrom fallback
|
// No target provided, use allowFrom fallback
|
||||||
if (allowList.length > 0) {
|
if (allowList.length > 0) {
|
||||||
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
|
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
|
||||||
return { ok: true, to: allowList[0]! };
|
return { ok: true, to: allowList[0]! };
|
||||||
}
|
}
|
||||||
|
|
||||||
// No target and no allowFrom - error
|
// No target and no allowFrom - error
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: missingTargetError(
|
error: missingTargetError(
|
||||||
"Twitch",
|
"Twitch",
|
||||||
"<channel-name> or channels.twitch.accounts.<account>.allowFrom[0]",
|
"<channel-name> or channels.twitch.accounts.<account>.allowFrom[0]",
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a text message to a Twitch channel.
|
* Send a text message to a Twitch channel.
|
||||||
*
|
*
|
||||||
* Strips markdown if enabled, validates account configuration,
|
* Strips markdown if enabled, validates account configuration,
|
||||||
* and sends the message via the Twitch client.
|
* and sends the message via the Twitch client.
|
||||||
*
|
*
|
||||||
* @param params - Send parameters including target, text, and config
|
* @param params - Send parameters including target, text, and config
|
||||||
* @returns Delivery result with message ID and status
|
* @returns Delivery result with message ID and status
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const result = await twitchOutbound.sendText({
|
* const result = await twitchOutbound.sendText({
|
||||||
* cfg: clawdbotConfig,
|
* cfg: clawdbotConfig,
|
||||||
* to: "#mychannel",
|
* to: "#mychannel",
|
||||||
* text: "Hello Twitch!",
|
* text: "Hello Twitch!",
|
||||||
* accountId: "default",
|
* accountId: "default",
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
sendText: async (
|
sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||||
params: ChannelOutboundContext,
|
const { cfg, to, text, accountId, signal } = params;
|
||||||
): Promise<OutboundDeliveryResult> => {
|
|
||||||
const { cfg, to, text, accountId, signal } = params;
|
|
||||||
|
|
||||||
// Check for abort signal
|
// Check for abort signal
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw new Error("Outbound delivery aborted");
|
throw new Error("Outbound delivery aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve account
|
// Resolve account
|
||||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
|
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Twitch account not found: ${resolvedAccountId}. ` +
|
`Twitch account not found: ${resolvedAccountId}. ` +
|
||||||
`Available accounts: ${availableIds.join(", ") || "none"}`,
|
`Available accounts: ${availableIds.join(", ") || "none"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
const pluginCfg = parsePluginConfig(
|
const pluginCfg = parsePluginConfig(
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: pluginConfig is not part of CoreConfig
|
// biome-ignore lint/suspicious/noExplicitAny: pluginConfig is not part of CoreConfig
|
||||||
(cfg as any).pluginConfig ?? {},
|
(cfg as any).pluginConfig ?? {},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send message
|
// Send message
|
||||||
const result = await sendMessageTwitchInternal(
|
const result = await sendMessageTwitchInternal(
|
||||||
normalizeTwitchChannel(channel),
|
normalizeTwitchChannel(channel),
|
||||||
text,
|
text,
|
||||||
cfg,
|
cfg,
|
||||||
resolvedAccountId,
|
resolvedAccountId,
|
||||||
pluginCfg.stripMarkdown ?? true,
|
pluginCfg.stripMarkdown ?? true,
|
||||||
console,
|
console,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error(result.error ?? "Send failed");
|
throw new Error(result.error ?? "Send failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
messageId: result.messageId,
|
messageId: result.messageId,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
to: normalizeTwitchChannel(channel),
|
to: normalizeTwitchChannel(channel),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send media to a Twitch channel.
|
* Send media to a Twitch channel.
|
||||||
*
|
*
|
||||||
* Note: Twitch chat doesn't support direct media uploads.
|
* Note: Twitch chat doesn't support direct media uploads.
|
||||||
* This sends the media URL as text instead.
|
* This sends the media URL as text instead.
|
||||||
*
|
*
|
||||||
* @param params - Send parameters including media URL
|
* @param params - Send parameters including media URL
|
||||||
* @returns Delivery result with message ID and status
|
* @returns Delivery result with message ID and status
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const result = await twitchOutbound.sendMedia({
|
* const result = await twitchOutbound.sendMedia({
|
||||||
* cfg: clawdbotConfig,
|
* cfg: clawdbotConfig,
|
||||||
* to: "#mychannel",
|
* to: "#mychannel",
|
||||||
* text: "Check this out!",
|
* text: "Check this out!",
|
||||||
* mediaUrl: "https://example.com/image.png",
|
* mediaUrl: "https://example.com/image.png",
|
||||||
* accountId: "default",
|
* accountId: "default",
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
sendMedia: async (
|
sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||||
params: ChannelOutboundContext,
|
const { text, mediaUrl, signal } = params;
|
||||||
): Promise<OutboundDeliveryResult> => {
|
|
||||||
const { text, mediaUrl, signal } = params;
|
|
||||||
|
|
||||||
// Check for abort signal
|
// Check for abort signal
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw new Error("Outbound delivery aborted");
|
throw new Error("Outbound delivery aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine text and media URL
|
// Combine text and media URL
|
||||||
const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
|
const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
|
||||||
|
|
||||||
// Delegate to sendText
|
// Delegate to sendText
|
||||||
if (!twitchOutbound.sendText) {
|
if (!twitchOutbound.sendText) {
|
||||||
throw new Error("sendText not implemented");
|
throw new Error("sendText not implemented");
|
||||||
}
|
}
|
||||||
return twitchOutbound.sendText({
|
return twitchOutbound.sendText({
|
||||||
...params,
|
...params,
|
||||||
text: message,
|
text: message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}`,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,139 +7,139 @@ const mockConnect = vi.fn().mockResolvedValue(undefined);
|
|||||||
const mockQuit = vi.fn().mockResolvedValue(undefined);
|
const mockQuit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mock("@twurple/chat", () => ({
|
vi.mock("@twurple/chat", () => ({
|
||||||
ChatClient: class {
|
ChatClient: class {
|
||||||
connect = mockConnect;
|
connect = mockConnect;
|
||||||
quit = mockQuit;
|
quit = mockQuit;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@twurple/auth", () => ({
|
vi.mock("@twurple/auth", () => ({
|
||||||
StaticAuthProvider: class {},
|
StaticAuthProvider: class {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("probeTwitch", () => {
|
describe("probeTwitch", () => {
|
||||||
const mockAccount: TwitchAccountConfig = {
|
const mockAccount: TwitchAccountConfig = {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123456789",
|
token: "oauth:test123456789",
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when username is missing", async () => {
|
it("returns error when username is missing", async () => {
|
||||||
const account = { ...mockAccount, username: "" as unknown as string };
|
const account = { ...mockAccount, username: "" as unknown as string };
|
||||||
const result = await probeTwitch(account, 5000);
|
const result = await probeTwitch(account, 5000);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("missing credentials");
|
expect(result.error).toContain("missing credentials");
|
||||||
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
|
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when token is missing", async () => {
|
it("returns error when token is missing", async () => {
|
||||||
const account = { ...mockAccount, token: "" as unknown as string };
|
const account = { ...mockAccount, token: "" as unknown as string };
|
||||||
const result = await probeTwitch(account, 5000);
|
const result = await probeTwitch(account, 5000);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("missing credentials");
|
expect(result.error).toContain("missing credentials");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("attempts connection regardless of token prefix", async () => {
|
it("attempts connection regardless of token prefix", async () => {
|
||||||
// Note: probeTwitch doesn't validate token format - it tries to connect with whatever token is provided
|
// Note: probeTwitch doesn't validate token format - it tries to connect with whatever token is provided
|
||||||
// The actual connection would fail in production with an invalid token
|
// The actual connection would fail in production with an invalid token
|
||||||
const account = { ...mockAccount, token: "raw_token_no_prefix" };
|
const account = { ...mockAccount, token: "raw_token_no_prefix" };
|
||||||
const result = await probeTwitch(account, 5000);
|
const result = await probeTwitch(account, 5000);
|
||||||
|
|
||||||
// With mock, connection succeeds even without oauth: prefix
|
// With mock, connection succeeds even without oauth: prefix
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("successfully connects with valid credentials", async () => {
|
it("successfully connects with valid credentials", async () => {
|
||||||
const result = await probeTwitch(mockAccount, 5000);
|
const result = await probeTwitch(mockAccount, 5000);
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.connected).toBe(true);
|
expect(result.connected).toBe(true);
|
||||||
expect(result.username).toBe("testbot");
|
expect(result.username).toBe("testbot");
|
||||||
expect(result.channel).toBe("testbot"); // defaults to username
|
expect(result.channel).toBe("testbot"); // defaults to username
|
||||||
expect(result.elapsedMs).toBeGreaterThan(0);
|
expect(result.elapsedMs).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses custom channel when specified", async () => {
|
it("uses custom channel when specified", async () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
channel: "customchannel",
|
channel: "customchannel",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await probeTwitch(account, 5000);
|
const result = await probeTwitch(account, 5000);
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.channel).toBe("customchannel");
|
expect(result.channel).toBe("customchannel");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("times out when connection takes too long", async () => {
|
it("times out when connection takes too long", async () => {
|
||||||
mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
|
mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
|
||||||
|
|
||||||
const result = await probeTwitch(mockAccount, 100);
|
const result = await probeTwitch(mockAccount, 100);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("timeout");
|
expect(result.error).toContain("timeout");
|
||||||
|
|
||||||
// Reset mock
|
// Reset mock
|
||||||
mockConnect.mockResolvedValue(undefined);
|
mockConnect.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up client even on failure", async () => {
|
it("cleans up client even on failure", async () => {
|
||||||
mockConnect.mockRejectedValueOnce(new Error("Connection failed"));
|
mockConnect.mockRejectedValueOnce(new Error("Connection failed"));
|
||||||
|
|
||||||
const result = await probeTwitch(mockAccount, 5000);
|
const result = await probeTwitch(mockAccount, 5000);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("Connection failed");
|
expect(result.error).toContain("Connection failed");
|
||||||
expect(mockQuit).toHaveBeenCalled();
|
expect(mockQuit).toHaveBeenCalled();
|
||||||
|
|
||||||
// Reset mocks
|
// Reset mocks
|
||||||
mockConnect.mockResolvedValue(undefined);
|
mockConnect.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("measures elapsed time", async () => {
|
it("measures elapsed time", async () => {
|
||||||
const result = await probeTwitch(mockAccount, 5000);
|
const result = await probeTwitch(mockAccount, 5000);
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.elapsedMs).toBeGreaterThan(0);
|
expect(result.elapsedMs).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles connection errors gracefully", async () => {
|
it("handles connection errors gracefully", async () => {
|
||||||
mockConnect.mockRejectedValueOnce(new Error("Network error"));
|
mockConnect.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
const result = await probeTwitch(mockAccount, 5000);
|
const result = await probeTwitch(mockAccount, 5000);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("Network error");
|
expect(result.error).toContain("Network error");
|
||||||
|
|
||||||
// Reset mock
|
// Reset mock
|
||||||
mockConnect.mockResolvedValue(undefined);
|
mockConnect.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("trims token before validation", async () => {
|
it("trims token before validation", async () => {
|
||||||
const account: TwitchAccountConfig = {
|
const account: TwitchAccountConfig = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
token: " oauth:test123456789 ",
|
token: " oauth:test123456789 ",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await probeTwitch(account, 5000);
|
const result = await probeTwitch(account, 5000);
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles non-Error objects in catch block", async () => {
|
it("handles non-Error objects in catch block", async () => {
|
||||||
mockConnect.mockRejectedValueOnce("String error");
|
mockConnect.mockRejectedValueOnce("String error");
|
||||||
|
|
||||||
const result = await probeTwitch(mockAccount, 5000);
|
const result = await probeTwitch(mockAccount, 5000);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toBe("String error");
|
expect(result.error).toBe("String error");
|
||||||
|
|
||||||
// Reset mock
|
// Reset mock
|
||||||
mockConnect.mockResolvedValue(undefined);
|
mockConnect.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,12 +6,12 @@ import type { TwitchAccountConfig } from "./types.js";
|
|||||||
* Result of probing a Twitch account
|
* Result of probing a Twitch account
|
||||||
*/
|
*/
|
||||||
export type ProbeTwitchResult = {
|
export type ProbeTwitchResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
elapsedMs: number;
|
elapsedMs: number;
|
||||||
connected?: boolean;
|
connected?: boolean;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -21,108 +21,105 @@ export type ProbeTwitchResult = {
|
|||||||
* to the chat server and verify the bot's username.
|
* to the chat server and verify the bot's username.
|
||||||
*/
|
*/
|
||||||
export async function probeTwitch(
|
export async function probeTwitch(
|
||||||
account: TwitchAccountConfig,
|
account: TwitchAccountConfig,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
): Promise<ProbeTwitchResult> {
|
): Promise<ProbeTwitchResult> {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
|
||||||
if (!account.token || !account.username) {
|
if (!account.token || !account.username) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "missing credentials (token, username)",
|
error: "missing credentials (token, username)",
|
||||||
username: account.username,
|
username: account.username,
|
||||||
elapsedMs: Date.now() - started,
|
elapsedMs: Date.now() - started,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawToken = account.token.trim();
|
const rawToken = account.token.trim();
|
||||||
|
|
||||||
let client: ChatClient | undefined;
|
let client: ChatClient | undefined;
|
||||||
|
|
||||||
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({
|
||||||
authProvider,
|
authProvider,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a promise that resolves when connected
|
// Create a promise that resolves when connected
|
||||||
const connectionPromise = new Promise<void>((resolve, reject) => {
|
const connectionPromise = new Promise<void>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let connectListener: ReturnType<ChatClient["onConnect"]> | undefined;
|
let connectListener: ReturnType<ChatClient["onConnect"]> | undefined;
|
||||||
let disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined;
|
let disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined;
|
||||||
let authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined;
|
let authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined;
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
// Remove all listeners
|
// Remove all listeners
|
||||||
connectListener?.unbind();
|
connectListener?.unbind();
|
||||||
disconnectListener?.unbind();
|
disconnectListener?.unbind();
|
||||||
authFailListener?.unbind();
|
authFailListener?.unbind();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Success: connection established
|
// Success: connection established
|
||||||
connectListener = client?.onConnect(() => {
|
connectListener = client?.onConnect(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Failure: disconnected (e.g., auth failed)
|
// Failure: disconnected (e.g., auth failed)
|
||||||
disconnectListener = client?.onDisconnect((_manually, reason) => {
|
disconnectListener = client?.onDisconnect((_manually, reason) => {
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(reason || new Error("Disconnected"));
|
reject(reason || new Error("Disconnected"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Failure: authentication failed
|
// Failure: authentication failed
|
||||||
authFailListener = client?.onAuthenticationFailure(() => {
|
authFailListener = client?.onAuthenticationFailure(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(new Error("Authentication failed"));
|
reject(new Error("Authentication failed"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create timeout promise
|
// Create timeout promise
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up listeners BEFORE connecting, then race against timeout
|
// Set up listeners BEFORE connecting, then race against timeout
|
||||||
client.connect();
|
client.connect();
|
||||||
await Promise.race([connectionPromise, timeout]);
|
await Promise.race([connectionPromise, timeout]);
|
||||||
|
|
||||||
// Clean up connection before returning
|
// Clean up connection before returning
|
||||||
client.quit();
|
client.quit();
|
||||||
client = undefined;
|
client = undefined;
|
||||||
|
|
||||||
// If we got here, connection was successful
|
// If we got here, connection was successful
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
connected: true,
|
connected: true,
|
||||||
username: account.username,
|
username: account.username,
|
||||||
channel: account.channel ?? account.username,
|
channel: account.channel ?? account.username,
|
||||||
elapsedMs: Date.now() - started,
|
elapsedMs: Date.now() - started,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
username: account.username,
|
username: account.username,
|
||||||
channel: account.channel ?? account.username,
|
channel: account.channel ?? account.username,
|
||||||
elapsedMs: Date.now() - started,
|
elapsedMs: Date.now() - started,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
// Always clean up the client
|
// Always clean up the client
|
||||||
if (client) {
|
if (client) {
|
||||||
try {
|
try {
|
||||||
client.quit();
|
client.quit();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore cleanup errors
|
// Ignore cleanup errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -16,277 +16,275 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
|||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("./config.js", () => ({
|
vi.mock("./config.js", () => ({
|
||||||
DEFAULT_ACCOUNT_ID: "default",
|
DEFAULT_ACCOUNT_ID: "default",
|
||||||
getAccountConfig: vi.fn(),
|
getAccountConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./utils/twitch.js", () => ({
|
vi.mock("./utils/twitch.js", () => ({
|
||||||
generateMessageId: vi.fn(() => "test-msg-id"),
|
generateMessageId: vi.fn(() => "test-msg-id"),
|
||||||
isAccountConfigured: vi.fn(() => true),
|
isAccountConfigured: vi.fn(() => true),
|
||||||
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
|
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./utils/markdown.js", () => ({
|
vi.mock("./utils/markdown.js", () => ({
|
||||||
stripMarkdownForTwitch: vi.fn((text: string) => text.replace(/\*\*/g, "")),
|
stripMarkdownForTwitch: vi.fn((text: string) => text.replace(/\*\*/g, "")),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./client-manager-registry.js", () => ({
|
vi.mock("./client-manager-registry.js", () => ({
|
||||||
getClientManager: vi.fn(),
|
getClientManager: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("send", () => {
|
describe("send", () => {
|
||||||
const mockLogger = {
|
const mockLogger = {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAccount = {
|
const mockAccount = {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123",
|
token: "oauth:test123",
|
||||||
clientId: "test-client-id",
|
clientId: "test-client-id",
|
||||||
channel: "#testchannel",
|
channel: "#testchannel",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: mockAccount,
|
default: mockAccount,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessageTwitchInternal", () => {
|
describe("sendMessageTwitchInternal", () => {
|
||||||
it("should send a message successfully", async () => {
|
it("should send a message successfully", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { getClientManager } = await import("./client-manager-registry.js");
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(getClientManager).mockReturnValue({
|
vi.mocked(getClientManager).mockReturnValue({
|
||||||
sendMessage: vi.fn().mockResolvedValue({
|
sendMessage: vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
messageId: "twitch-msg-123",
|
messageId: "twitch-msg-123",
|
||||||
}),
|
}),
|
||||||
} as ReturnType<typeof getClientManager>);
|
} as ReturnType<typeof getClientManager>);
|
||||||
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text);
|
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text);
|
||||||
|
|
||||||
const result = await sendMessageTwitchInternal(
|
const result = await sendMessageTwitchInternal(
|
||||||
"#testchannel",
|
"#testchannel",
|
||||||
"Hello Twitch!",
|
"Hello Twitch!",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
false,
|
false,
|
||||||
mockLogger as unknown as Console,
|
mockLogger as unknown as Console,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.messageId).toBe("twitch-msg-123");
|
expect(result.messageId).toBe("twitch-msg-123");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should strip markdown when enabled", async () => {
|
it("should strip markdown when enabled", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { getClientManager } = await import("./client-manager-registry.js");
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(getClientManager).mockReturnValue({
|
vi.mocked(getClientManager).mockReturnValue({
|
||||||
sendMessage: vi.fn().mockResolvedValue({
|
sendMessage: vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
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",
|
||||||
"**Bold** text",
|
"**Bold** text",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
true,
|
true,
|
||||||
mockLogger as unknown as Console,
|
mockLogger as unknown as Console,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(stripMarkdownForTwitch).toHaveBeenCalledWith("**Bold** text");
|
expect(stripMarkdownForTwitch).toHaveBeenCalledWith("**Bold** text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return error when account not found", async () => {
|
it("should return error when account not found", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(null);
|
vi.mocked(getAccountConfig).mockReturnValue(null);
|
||||||
|
|
||||||
const result = await sendMessageTwitchInternal(
|
const result = await sendMessageTwitchInternal(
|
||||||
"#testchannel",
|
"#testchannel",
|
||||||
"Hello!",
|
"Hello!",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"nonexistent",
|
"nonexistent",
|
||||||
false,
|
false,
|
||||||
mockLogger as unknown as Console,
|
mockLogger as unknown as Console,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("Account not found: nonexistent");
|
expect(result.error).toContain("Account not found: nonexistent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return error when account not configured", async () => {
|
it("should return error when account not configured", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { isAccountConfigured } = await import("./utils/twitch.js");
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(isAccountConfigured).mockReturnValue(false);
|
vi.mocked(isAccountConfigured).mockReturnValue(false);
|
||||||
|
|
||||||
const result = await sendMessageTwitchInternal(
|
const result = await sendMessageTwitchInternal(
|
||||||
"#testchannel",
|
"#testchannel",
|
||||||
"Hello!",
|
"Hello!",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
false,
|
false,
|
||||||
mockLogger as unknown as Console,
|
mockLogger as unknown as Console,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("not properly configured");
|
expect(result.error).toContain("not properly configured");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return error when no channel specified", async () => {
|
it("should return error when no channel specified", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { isAccountConfigured } = await import("./utils/twitch.js");
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
|
|
||||||
// Set both channel and username to undefined to trigger the error
|
// Set both channel and username to undefined to trigger the error
|
||||||
const accountWithoutChannelOrUsername = {
|
const accountWithoutChannelOrUsername = {
|
||||||
...mockAccount,
|
...mockAccount,
|
||||||
channel: undefined,
|
channel: undefined,
|
||||||
username: undefined,
|
username: undefined,
|
||||||
};
|
};
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannelOrUsername);
|
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannelOrUsername);
|
||||||
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
|
|
||||||
const result = await sendMessageTwitchInternal(
|
const result = await sendMessageTwitchInternal(
|
||||||
"",
|
"",
|
||||||
"Hello!",
|
"Hello!",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
false,
|
false,
|
||||||
mockLogger as unknown as Console,
|
mockLogger as unknown as Console,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("No channel specified");
|
expect(result.error).toContain("No channel specified");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should skip sending empty message after markdown stripping", async () => {
|
it("should skip sending empty message after markdown stripping", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { isAccountConfigured } = await import("./utils/twitch.js");
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
vi.mocked(stripMarkdownForTwitch).mockReturnValue("");
|
vi.mocked(stripMarkdownForTwitch).mockReturnValue("");
|
||||||
|
|
||||||
const result = await sendMessageTwitchInternal(
|
const result = await sendMessageTwitchInternal(
|
||||||
"#testchannel",
|
"#testchannel",
|
||||||
"**Only markdown**",
|
"**Only markdown**",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
true,
|
true,
|
||||||
mockLogger as unknown as Console,
|
mockLogger as unknown as Console,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.messageId).toBe("skipped");
|
expect(result.messageId).toBe("skipped");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return error when client manager not found", async () => {
|
it("should return error when client manager not found", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { isAccountConfigured } = await import("./utils/twitch.js");
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
const { getClientManager } = await import("./client-manager-registry.js");
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
vi.mocked(getClientManager).mockReturnValue(undefined);
|
vi.mocked(getClientManager).mockReturnValue(undefined);
|
||||||
|
|
||||||
const result = await sendMessageTwitchInternal(
|
const result = await sendMessageTwitchInternal(
|
||||||
"#testchannel",
|
"#testchannel",
|
||||||
"Hello!",
|
"Hello!",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
false,
|
false,
|
||||||
mockLogger as unknown as Console,
|
mockLogger as unknown as Console,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toContain("Client manager not found");
|
expect(result.error).toContain("Client manager not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle send errors gracefully", async () => {
|
it("should handle send errors gracefully", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { isAccountConfigured } = await import("./utils/twitch.js");
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
const { getClientManager } = await import("./client-manager-registry.js");
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
vi.mocked(getClientManager).mockReturnValue({
|
vi.mocked(getClientManager).mockReturnValue({
|
||||||
sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")),
|
sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")),
|
||||||
} as ReturnType<typeof getClientManager>);
|
} as ReturnType<typeof getClientManager>);
|
||||||
|
|
||||||
const result = await sendMessageTwitchInternal(
|
const result = await sendMessageTwitchInternal(
|
||||||
"#testchannel",
|
"#testchannel",
|
||||||
"Hello!",
|
"Hello!",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
false,
|
false,
|
||||||
mockLogger as unknown as Console,
|
mockLogger as unknown as Console,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).toBe("Connection lost");
|
expect(result.error).toBe("Connection lost");
|
||||||
expect(mockLogger.error).toHaveBeenCalled();
|
expect(mockLogger.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use account channel when channel parameter is empty", async () => {
|
it("should use account channel when channel parameter is empty", async () => {
|
||||||
const { getAccountConfig } = await import("./config.js");
|
const { getAccountConfig } = await import("./config.js");
|
||||||
const { isAccountConfigured } = await import("./utils/twitch.js");
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
const { getClientManager } = await import("./client-manager-registry.js");
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
|
|
||||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
const mockSend = vi.fn().mockResolvedValue({
|
const mockSend = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
messageId: "twitch-msg-789",
|
messageId: "twitch-msg-789",
|
||||||
});
|
});
|
||||||
vi.mocked(getClientManager).mockReturnValue({
|
vi.mocked(getClientManager).mockReturnValue({
|
||||||
sendMessage: mockSend,
|
sendMessage: mockSend,
|
||||||
} as ReturnType<typeof getClientManager>);
|
} as ReturnType<typeof getClientManager>);
|
||||||
|
|
||||||
await sendMessageTwitchInternal(
|
await sendMessageTwitchInternal(
|
||||||
"",
|
"",
|
||||||
"Hello!",
|
"Hello!",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
false,
|
false,
|
||||||
mockLogger as unknown as Console,
|
mockLogger as unknown as Console,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockSend).toHaveBeenCalledWith(
|
||||||
mockAccount,
|
mockAccount,
|
||||||
"testchannel", // normalized account channel
|
"testchannel", // normalized account channel
|
||||||
"Hello!",
|
"Hello!",
|
||||||
mockConfig,
|
mockConfig,
|
||||||
"default",
|
"default",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,22 +9,18 @@ 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.
|
||||||
*/
|
*/
|
||||||
export interface SendMessageResult {
|
export interface SendMessageResult {
|
||||||
/** Whether the send was successful */
|
/** Whether the send was successful */
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
/** The message ID (generated for tracking) */
|
/** The message ID (generated for tracking) */
|
||||||
messageId: string;
|
messageId: string;
|
||||||
/** Error message if the send failed */
|
/** Error message if the send failed */
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,89 +48,89 @@ export interface SendMessageResult {
|
|||||||
* );
|
* );
|
||||||
*/
|
*/
|
||||||
export async function sendMessageTwitchInternal(
|
export async function sendMessageTwitchInternal(
|
||||||
channel: string,
|
channel: string,
|
||||||
text: string,
|
text: string,
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
accountId: string = DEFAULT_ACCOUNT_ID,
|
accountId: string = DEFAULT_ACCOUNT_ID,
|
||||||
stripMarkdown: boolean = true,
|
stripMarkdown: boolean = true,
|
||||||
logger: Console = console,
|
logger: Console = console,
|
||||||
): Promise<SendMessageResult> {
|
): Promise<SendMessageResult> {
|
||||||
// Resolve account
|
// Resolve account
|
||||||
const account = getAccountConfig(cfg, accountId);
|
const account = getAccountConfig(cfg, accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
|
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
messageId: generateMessageId(),
|
messageId: generateMessageId(),
|
||||||
error: `Account not found: ${accountId}. Available accounts: ${availableIds.join(", ") || "none"}`,
|
error: `Account not found: ${accountId}. Available accounts: ${availableIds.join(", ") || "none"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAccountConfigured(account)) {
|
if (!isAccountConfigured(account)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
messageId: generateMessageId(),
|
messageId: generateMessageId(),
|
||||||
error: `Account ${accountId} is not properly configured. Required: username, token, clientId`,
|
error: `Account ${accountId} is not properly configured. Required: username, token, clientId`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize channel
|
// Normalize channel
|
||||||
const normalizedChannel = channel || account.channel || account.username;
|
const normalizedChannel = channel || account.channel || account.username;
|
||||||
if (!normalizedChannel) {
|
if (!normalizedChannel) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
messageId: generateMessageId(),
|
messageId: generateMessageId(),
|
||||||
error: "No channel specified and no default channel in account config",
|
error: "No channel specified and no default channel in account config",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip markdown if enabled
|
// Strip markdown if enabled
|
||||||
const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
|
const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
|
||||||
if (!cleanedText) {
|
if (!cleanedText) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
messageId: "skipped",
|
messageId: "skipped",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get client manager from registry
|
// Get client manager from registry
|
||||||
const clientManager = getRegistryClientManager(accountId);
|
const clientManager = getRegistryClientManager(accountId);
|
||||||
if (!clientManager) {
|
if (!clientManager) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
messageId: generateMessageId(),
|
messageId: generateMessageId(),
|
||||||
error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`,
|
error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await clientManager.sendMessage(
|
const result = await clientManager.sendMessage(
|
||||||
account,
|
account,
|
||||||
normalizeTwitchChannel(normalizedChannel),
|
normalizeTwitchChannel(normalizedChannel),
|
||||||
cleanedText,
|
cleanedText,
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
messageId: result.messageId ?? generateMessageId(),
|
messageId: result.messageId ?? generateMessageId(),
|
||||||
error: result.error ?? "Send failed",
|
error: result.error ?? "Send failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
messageId: result.messageId ?? generateMessageId(),
|
messageId: result.messageId ?? generateMessageId(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
logger.error(`[twitch] Failed to send message: ${errorMsg}`);
|
logger.error(`[twitch] Failed to send message: ${errorMsg}`);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
messageId: generateMessageId(),
|
messageId: generateMessageId(),
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,291 +15,276 @@ import { collectTwitchStatusIssues } from "./status.js";
|
|||||||
import type { ChannelAccountSnapshot } from "./types.js";
|
import type { ChannelAccountSnapshot } from "./types.js";
|
||||||
|
|
||||||
describe("status", () => {
|
describe("status", () => {
|
||||||
describe("collectTwitchStatusIssues", () => {
|
describe("collectTwitchStatusIssues", () => {
|
||||||
it("should detect unconfigured accounts", () => {
|
it("should detect unconfigured accounts", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: false,
|
configured: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
expect(issues.length).toBeGreaterThan(0);
|
expect(issues.length).toBeGreaterThan(0);
|
||||||
expect(issues[0]?.kind).toBe("config");
|
expect(issues[0]?.kind).toBe("config");
|
||||||
expect(issues[0]?.message).toContain("not properly configured");
|
expect(issues[0]?.message).toContain("not properly configured");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect disabled accounts", () => {
|
it("should detect disabled accounts", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: true,
|
configured: true,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
running: false,
|
running: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
expect(issues.length).toBeGreaterThan(0);
|
expect(issues.length).toBeGreaterThan(0);
|
||||||
const disabledIssue = issues.find((i) => i.message.includes("disabled"));
|
const disabledIssue = issues.find((i) => i.message.includes("disabled"));
|
||||||
expect(disabledIssue).toBeDefined();
|
expect(disabledIssue).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect missing clientId when account configured", () => {
|
it("should detect missing clientId when account configured", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: true,
|
configured: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123",
|
token: "oauth:test123",
|
||||||
// clientId missing
|
// clientId missing
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should warn about oauth: prefix in token", () => {
|
it("should warn about oauth: prefix in token", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: true,
|
configured: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123", // has prefix
|
token: "oauth:test123", // has prefix
|
||||||
clientId: "test-id",
|
clientId: "test-id",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
expect(prefixIssue?.kind).toBe("config");
|
expect(prefixIssue?.kind).toBe("config");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect clientSecret without refreshToken", () => {
|
it("should detect clientSecret without refreshToken", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: true,
|
configured: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123",
|
token: "oauth:test123",
|
||||||
clientId: "test-id",
|
clientId: "test-id",
|
||||||
clientSecret: "secret123",
|
clientSecret: "secret123",
|
||||||
// refreshToken missing
|
// refreshToken missing
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect empty allowFrom array", () => {
|
it("should detect empty allowFrom array", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: true,
|
configured: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "test123",
|
token: "test123",
|
||||||
clientId: "test-id",
|
clientId: "test-id",
|
||||||
allowFrom: [], // empty array
|
allowFrom: [], // empty array
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect allowedRoles 'all' with allowFrom conflict", () => {
|
it("should detect allowedRoles 'all' with allowFrom conflict", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: true,
|
configured: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "test123",
|
token: "test123",
|
||||||
clientId: "test-id",
|
clientId: "test-id",
|
||||||
allowedRoles: ["all"],
|
allowedRoles: ["all"],
|
||||||
allowFrom: ["123456"], // conflict!
|
allowFrom: ["123456"], // conflict!
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'");
|
expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect runtime errors", () => {
|
it("should detect runtime errors", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: true,
|
configured: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
lastError: "Connection timeout",
|
lastError: "Connection timeout",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
const runtimeIssue = issues.find((i) => i.kind === "runtime");
|
const runtimeIssue = issues.find((i) => i.kind === "runtime");
|
||||||
expect(runtimeIssue).toBeDefined();
|
expect(runtimeIssue).toBeDefined();
|
||||||
expect(runtimeIssue?.message).toContain("Connection timeout");
|
expect(runtimeIssue?.message).toContain("Connection timeout");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect accounts that never connected", () => {
|
it("should detect accounts that never connected", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: true,
|
configured: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
lastStartAt: undefined,
|
lastStartAt: undefined,
|
||||||
lastInboundAt: undefined,
|
lastInboundAt: undefined,
|
||||||
lastOutboundAt: undefined,
|
lastOutboundAt: undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
const neverConnectedIssue = issues.find((i) =>
|
const neverConnectedIssue = issues.find((i) =>
|
||||||
i.message.includes("never connected successfully"),
|
i.message.includes("never connected successfully"),
|
||||||
);
|
);
|
||||||
expect(neverConnectedIssue).toBeDefined();
|
expect(neverConnectedIssue).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect long-running connections", () => {
|
it("should detect long-running connections", () => {
|
||||||
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
|
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
|
||||||
|
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
configured: true,
|
configured: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: true,
|
running: true,
|
||||||
lastStartAt: oldDate,
|
lastStartAt: oldDate,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
const uptimeIssue = issues.find((i) => i.message.includes("running for"));
|
const uptimeIssue = issues.find((i) => i.message.includes("running for"));
|
||||||
expect(uptimeIssue).toBeDefined();
|
expect(uptimeIssue).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle empty snapshots array", () => {
|
it("should handle empty snapshots array", () => {
|
||||||
const issues = collectTwitchStatusIssues([]);
|
const issues = collectTwitchStatusIssues([]);
|
||||||
|
|
||||||
expect(issues).toEqual([]);
|
expect(issues).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should skip non-Twitch accounts gracefully", () => {
|
it("should skip non-Twitch accounts gracefully", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: undefined,
|
accountId: undefined,
|
||||||
configured: false,
|
configured: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
// Should not crash, may return empty or minimal issues
|
// Should not crash, may return empty or minimal issues
|
||||||
expect(Array.isArray(issues)).toBe(true);
|
expect(Array.isArray(issues)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,161 +26,159 @@ import { isAccountConfigured } from "./utils/twitch.js";
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export function collectTwitchStatusIssues(
|
export function collectTwitchStatusIssues(
|
||||||
accounts: ChannelAccountSnapshot[],
|
accounts: ChannelAccountSnapshot[],
|
||||||
getCfg?: () => unknown,
|
getCfg?: () => unknown,
|
||||||
): ChannelStatusIssue[] {
|
): ChannelStatusIssue[] {
|
||||||
const issues: ChannelStatusIssue[] = [];
|
const issues: ChannelStatusIssue[] = [];
|
||||||
|
|
||||||
for (const entry of accounts) {
|
for (const entry of accounts) {
|
||||||
const accountId = entry.accountId;
|
const accountId = entry.accountId;
|
||||||
|
|
||||||
// Skip if not a Twitch account
|
// Skip if not a Twitch account
|
||||||
if (!accountId) continue;
|
if (!accountId) continue;
|
||||||
|
|
||||||
// Get full account config if available
|
// Get full account config if available
|
||||||
let account: ReturnType<typeof getAccountConfig> | null = null;
|
let account: ReturnType<typeof getAccountConfig> | null = null;
|
||||||
if (getCfg) {
|
if (getCfg) {
|
||||||
try {
|
try {
|
||||||
const cfg = getCfg() as {
|
const cfg = getCfg() as {
|
||||||
channels?: { twitch?: { accounts?: Record<string, unknown> } };
|
channels?: { twitch?: { accounts?: Record<string, unknown> } };
|
||||||
};
|
};
|
||||||
account = getAccountConfig(cfg, accountId);
|
account = getAccountConfig(cfg, accountId);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore config access errors
|
// Ignore config access errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 1: Account not configured
|
// Check 1: Account not configured
|
||||||
if (!entry.configured) {
|
if (!entry.configured) {
|
||||||
issues.push({
|
issues.push({
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
accountId,
|
accountId,
|
||||||
kind: "config",
|
kind: "config",
|
||||||
message: "Twitch account is not properly configured",
|
message: "Twitch account is not properly configured",
|
||||||
fix: "Add required fields: username, token, and clientId to your account configuration",
|
fix: "Add required fields: username, token, and clientId to your account configuration",
|
||||||
});
|
});
|
||||||
continue; // Skip further checks if not configured
|
continue; // Skip further checks if not configured
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 2: Account disabled
|
// Check 2: Account disabled
|
||||||
if (entry.enabled === false) {
|
if (entry.enabled === false) {
|
||||||
issues.push({
|
issues.push({
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
accountId,
|
accountId,
|
||||||
kind: "config",
|
kind: "config",
|
||||||
message: "Twitch account is disabled",
|
message: "Twitch account is disabled",
|
||||||
fix: "Set enabled: true in your account configuration to enable this account",
|
fix: "Set enabled: true in your account configuration to enable this account",
|
||||||
});
|
});
|
||||||
continue; // Skip further checks if disabled
|
continue; // Skip further checks if disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 3: Missing clientId (check if username/token present but no clientId)
|
// Check 3: Missing clientId (check if username/token present but no clientId)
|
||||||
if (account && account.username && account.token && !account.clientId) {
|
if (account && account.username && account.token && !account.clientId) {
|
||||||
issues.push({
|
issues.push({
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
accountId,
|
accountId,
|
||||||
kind: "config",
|
kind: "config",
|
||||||
message: "Twitch client ID is required",
|
message: "Twitch client ID is required",
|
||||||
fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
|
fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
if (account.token?.startsWith("oauth:")) {
|
||||||
|
issues.push({
|
||||||
|
channel: "twitch",
|
||||||
|
accountId,
|
||||||
|
kind: "config",
|
||||||
|
message: "Token contains 'oauth:' prefix (will be stripped)",
|
||||||
|
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check 4: Token format warning (normalized, but may indicate config issue)
|
// Check 5: clientSecret provided without refreshToken
|
||||||
if (account.token?.startsWith("oauth:")) {
|
if (account.clientSecret && !account.refreshToken) {
|
||||||
issues.push({
|
issues.push({
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
accountId,
|
accountId,
|
||||||
kind: "config",
|
kind: "config",
|
||||||
message: "Token contains 'oauth:' prefix (will be stripped)",
|
message: "clientSecret provided without refreshToken",
|
||||||
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
|
fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 5: clientSecret provided without refreshToken
|
// Check 6: Access control warnings
|
||||||
if (account.clientSecret && !account.refreshToken) {
|
if (account.allowFrom && account.allowFrom.length === 0) {
|
||||||
issues.push({
|
issues.push({
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
accountId,
|
accountId,
|
||||||
kind: "config",
|
kind: "config",
|
||||||
message: "clientSecret provided without refreshToken",
|
message: "allowFrom is configured but empty",
|
||||||
fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
|
fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 6: Access control warnings
|
// Check 7: Invalid role combinations
|
||||||
if (account.allowFrom && account.allowFrom.length === 0) {
|
if (
|
||||||
issues.push({
|
account.allowedRoles?.includes("all") &&
|
||||||
channel: "twitch",
|
account.allowFrom &&
|
||||||
accountId,
|
account.allowFrom.length > 0
|
||||||
kind: "config",
|
) {
|
||||||
message: "allowFrom is configured but empty",
|
issues.push({
|
||||||
fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
|
channel: "twitch",
|
||||||
});
|
accountId,
|
||||||
}
|
kind: "intent",
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check 7: Invalid role combinations
|
// Check 8: Runtime errors
|
||||||
if (
|
if (entry.lastError) {
|
||||||
account.allowedRoles?.includes("all") &&
|
issues.push({
|
||||||
account.allowFrom &&
|
channel: "twitch",
|
||||||
account.allowFrom.length > 0
|
accountId,
|
||||||
) {
|
kind: "runtime",
|
||||||
issues.push({
|
message: `Last error: ${entry.lastError}`,
|
||||||
channel: "twitch",
|
fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
|
||||||
accountId,
|
});
|
||||||
kind: "intent",
|
}
|
||||||
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.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check 8: Runtime errors
|
// Check 9: Account never connected successfully
|
||||||
if (entry.lastError) {
|
if (
|
||||||
issues.push({
|
entry.configured &&
|
||||||
channel: "twitch",
|
!entry.running &&
|
||||||
accountId,
|
!entry.lastStartAt &&
|
||||||
kind: "runtime",
|
!entry.lastInboundAt &&
|
||||||
message: `Last error: ${entry.lastError}`,
|
!entry.lastOutboundAt
|
||||||
fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
|
) {
|
||||||
});
|
issues.push({
|
||||||
}
|
channel: "twitch",
|
||||||
|
accountId,
|
||||||
|
kind: "runtime",
|
||||||
|
message: "Account has never connected successfully",
|
||||||
|
fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check 9: Account never connected successfully
|
// Check 10: Long-running connection may need reconnection
|
||||||
if (
|
if (entry.running && entry.lastStartAt) {
|
||||||
entry.configured &&
|
const uptime = Date.now() - entry.lastStartAt;
|
||||||
!entry.running &&
|
const daysSinceStart = uptime / (1000 * 60 * 60 * 24);
|
||||||
!entry.lastStartAt &&
|
if (daysSinceStart > 7) {
|
||||||
!entry.lastInboundAt &&
|
issues.push({
|
||||||
!entry.lastOutboundAt
|
channel: "twitch",
|
||||||
) {
|
accountId,
|
||||||
issues.push({
|
kind: "runtime",
|
||||||
channel: "twitch",
|
message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
|
||||||
accountId,
|
fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
|
||||||
kind: "runtime",
|
});
|
||||||
message: "Account has never connected successfully",
|
}
|
||||||
fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
|
}
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check 10: Long-running connection may need reconnection
|
return issues;
|
||||||
if (entry.running && entry.lastStartAt) {
|
|
||||||
const uptime = Date.now() - entry.lastStartAt;
|
|
||||||
const daysSinceStart = uptime / (1000 * 60 * 60 * 24);
|
|
||||||
if (daysSinceStart > 7) {
|
|
||||||
issues.push({
|
|
||||||
channel: "twitch",
|
|
||||||
accountId,
|
|
||||||
kind: "runtime",
|
|
||||||
message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
|
|
||||||
fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return issues;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,156 +13,156 @@ import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
|
|||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
describe("token", () => {
|
describe("token", () => {
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:config-token",
|
token: "oauth:config-token",
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
username: "otherbot",
|
username: "otherbot",
|
||||||
token: "oauth:other-token",
|
token: "oauth:other-token",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
delete process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN;
|
delete process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTwitchToken", () => {
|
describe("resolveTwitchToken", () => {
|
||||||
it("should resolve token from config for default account", () => {
|
it("should resolve token from config for default account", () => {
|
||||||
const result = resolveTwitchToken(mockConfig, { accountId: "default" });
|
const result = resolveTwitchToken(mockConfig, { accountId: "default" });
|
||||||
|
|
||||||
expect(result.token).toBe("oauth:config-token");
|
expect(result.token).toBe("oauth:config-token");
|
||||||
expect(result.source).toBe("config");
|
expect(result.source).toBe("config");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve token from config for non-default account", () => {
|
it("should resolve token from config for non-default account", () => {
|
||||||
const result = resolveTwitchToken(mockConfig, { accountId: "other" });
|
const result = resolveTwitchToken(mockConfig, { accountId: "other" });
|
||||||
|
|
||||||
expect(result.token).toBe("oauth:other-token");
|
expect(result.token).toBe("oauth:other-token");
|
||||||
expect(result.source).toBe("config");
|
expect(result.source).toBe("config");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prioritize config token over env var", () => {
|
it("should prioritize config token over env var", () => {
|
||||||
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||||
|
|
||||||
const result = resolveTwitchToken(mockConfig, { accountId: "default" });
|
const result = resolveTwitchToken(mockConfig, { accountId: "default" });
|
||||||
|
|
||||||
// Config token should be used even if env var exists
|
// Config token should be used even if env var exists
|
||||||
expect(result.token).toBe("oauth:config-token");
|
expect(result.token).toBe("oauth:config-token");
|
||||||
expect(result.source).toBe("config");
|
expect(result.source).toBe("config");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use env var when config token is empty", () => {
|
it("should use env var when config token is empty", () => {
|
||||||
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||||
|
|
||||||
const configWithEmptyToken = {
|
const configWithEmptyToken = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "",
|
token: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
const result = resolveTwitchToken(configWithEmptyToken, { accountId: "default" });
|
const result = resolveTwitchToken(configWithEmptyToken, { accountId: "default" });
|
||||||
|
|
||||||
expect(result.token).toBe("oauth:env-token");
|
expect(result.token).toBe("oauth:env-token");
|
||||||
expect(result.source).toBe("env");
|
expect(result.source).toBe("env");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty token when neither config nor env has token", () => {
|
it("should return empty token when neither config nor env has token", () => {
|
||||||
const configWithoutToken = {
|
const configWithoutToken = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "",
|
token: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
const result = resolveTwitchToken(configWithoutToken, { accountId: "default" });
|
const result = resolveTwitchToken(configWithoutToken, { accountId: "default" });
|
||||||
|
|
||||||
expect(result.token).toBe("");
|
expect(result.token).toBe("");
|
||||||
expect(result.source).toBe("none");
|
expect(result.source).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not use env var for non-default accounts", () => {
|
it("should not use env var for non-default accounts", () => {
|
||||||
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||||
|
|
||||||
const configWithoutToken = {
|
const configWithoutToken = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
secondary: {
|
secondary: {
|
||||||
username: "secondary",
|
username: "secondary",
|
||||||
token: "",
|
token: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" });
|
const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" });
|
||||||
|
|
||||||
// Non-default accounts shouldn't use env var
|
// Non-default accounts shouldn't use env var
|
||||||
expect(result.token).toBe("");
|
expect(result.token).toBe("");
|
||||||
expect(result.source).toBe("none");
|
expect(result.source).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle missing account gracefully", () => {
|
it("should handle missing account gracefully", () => {
|
||||||
const configWithoutAccount = {
|
const configWithoutAccount = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {},
|
accounts: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" });
|
const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" });
|
||||||
|
|
||||||
expect(result.token).toBe("");
|
expect(result.token).toBe("");
|
||||||
expect(result.source).toBe("none");
|
expect(result.source).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle missing Twitch config section", () => {
|
it("should handle missing Twitch config section", () => {
|
||||||
const configWithoutSection = {
|
const configWithoutSection = {
|
||||||
channels: {},
|
channels: {},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
const result = resolveTwitchToken(configWithoutSection, { accountId: "default" });
|
const result = resolveTwitchToken(configWithoutSection, { accountId: "default" });
|
||||||
|
|
||||||
expect(result.token).toBe("");
|
expect(result.token).toBe("");
|
||||||
expect(result.source).toBe("none");
|
expect(result.source).toBe("none");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TwitchTokenSource type", () => {
|
describe("TwitchTokenSource type", () => {
|
||||||
it("should have correct values", () => {
|
it("should have correct values", () => {
|
||||||
const sources: TwitchTokenSource[] = ["env", "config", "none"];
|
const sources: TwitchTokenSource[] = ["env", "config", "none"];
|
||||||
|
|
||||||
expect(sources).toContain("env");
|
expect(sources).toContain("env");
|
||||||
expect(sources).toContain("config");
|
expect(sources).toContain("config");
|
||||||
expect(sources).toContain("none");
|
expect(sources).toContain("none");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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" };
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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";
|
||||||
|
|
||||||
@ -13,312 +9,299 @@ import { normalizeToken } from "./utils/twitch.js";
|
|||||||
* Manages Twitch chat client connections
|
* Manages Twitch chat client connections
|
||||||
*/
|
*/
|
||||||
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) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an auth provider for the account.
|
* Create an auth provider for the account.
|
||||||
*/
|
*/
|
||||||
private createAuthProvider(
|
private createAuthProvider(
|
||||||
account: TwitchAccountConfig,
|
account: TwitchAccountConfig,
|
||||||
normalizedToken: string,
|
normalizedToken: string,
|
||||||
): StaticAuthProvider | RefreshingAuthProvider {
|
): StaticAuthProvider | RefreshingAuthProvider {
|
||||||
if (!account.clientId) {
|
if (!account.clientId) {
|
||||||
throw new Error("Missing Twitch client ID");
|
throw new Error("Missing Twitch client ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.clientSecret) {
|
if (account.clientSecret) {
|
||||||
// Use RefreshingAuthProvider - can handle tokens with or without refresh tokens
|
// Use RefreshingAuthProvider - can handle tokens with or without refresh tokens
|
||||||
const authProvider = new RefreshingAuthProvider({
|
const authProvider = new RefreshingAuthProvider({
|
||||||
clientId: account.clientId,
|
clientId: account.clientId,
|
||||||
clientSecret: account.clientSecret,
|
clientSecret: account.clientSecret,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use addUserForToken to figure out the user ID from the token
|
// Use addUserForToken to figure out the user ID from the token
|
||||||
// This works whether we have a refresh token or not
|
// This works whether we have a refresh token or not
|
||||||
authProvider
|
authProvider
|
||||||
.addUserForToken({
|
.addUserForToken({
|
||||||
accessToken: normalizedToken,
|
accessToken: normalizedToken,
|
||||||
refreshToken: account.refreshToken ?? null,
|
refreshToken: account.refreshToken ?? null,
|
||||||
expiresIn: account.expiresIn ?? null,
|
expiresIn: account.expiresIn ?? null,
|
||||||
obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(),
|
obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(),
|
||||||
})
|
})
|
||||||
.then((userId) => {
|
.then((userId) => {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`[twitch] Added user ${userId} to RefreshingAuthProvider for ${account.username}`,
|
`[twitch] Added user ${userId} to RefreshingAuthProvider for ${account.username}`,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[twitch] Failed to add user to RefreshingAuthProvider: ${err instanceof Error ? err.message : String(err)}`,
|
`[twitch] Failed to add user to RefreshingAuthProvider: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up token refresh event listener (only fires if refreshToken is provided)
|
// Set up token refresh event listener (only fires if refreshToken is provided)
|
||||||
authProvider.onRefresh((userId, token) => {
|
authProvider.onRefresh((userId, token) => {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`[twitch] Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`,
|
`[twitch] Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up token refresh failure listener
|
// Set up token refresh failure listener
|
||||||
authProvider.onRefreshFailure((userId, error) => {
|
authProvider.onRefreshFailure((userId, error) => {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[twitch] Failed to refresh access token for user ${userId}: ${error.message}`,
|
`[twitch] Failed to refresh access token for user ${userId}: ${error.message}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshStatus = account.refreshToken
|
const refreshStatus = account.refreshToken
|
||||||
? "automatic token refresh enabled"
|
? "automatic token refresh enabled"
|
||||||
: "token refresh disabled (no refresh token)";
|
: "token refresh disabled (no refresh token)";
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`[twitch] Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`,
|
`[twitch] Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return authProvider;
|
return authProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to StaticAuthProvider for backward compatibility (no clientSecret)
|
// Fall back to StaticAuthProvider for backward compatibility (no clientSecret)
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`[twitch] Using StaticAuthProvider for ${account.username} (no clientSecret provided)`,
|
`[twitch] Using StaticAuthProvider for ${account.username} (no clientSecret provided)`,
|
||||||
);
|
);
|
||||||
return new StaticAuthProvider(account.clientId, normalizedToken);
|
return new StaticAuthProvider(account.clientId, normalizedToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a chat client for an account
|
* Get or create a chat client for an account
|
||||||
*/
|
*/
|
||||||
async getClient(
|
async getClient(
|
||||||
account: TwitchAccountConfig,
|
account: TwitchAccountConfig,
|
||||||
cfg?: ClawdbotConfig,
|
cfg?: ClawdbotConfig,
|
||||||
accountId?: string,
|
accountId?: string,
|
||||||
): Promise<ChatClient> {
|
): Promise<ChatClient> {
|
||||||
const key = this.getAccountKey(account);
|
const key = this.getAccountKey(account);
|
||||||
|
|
||||||
const existing = this.clients.get(key);
|
const existing = this.clients.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve token from config or environment
|
// Resolve token from config or environment
|
||||||
const tokenResolution = resolveTwitchToken(cfg, {
|
const tokenResolution = resolveTwitchToken(cfg, {
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tokenResolution.token) {
|
if (!tokenResolution.token) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[twitch] Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or CLAWDBOT_TWITCH_ACCESS_TOKEN for default)`,
|
`[twitch] Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or CLAWDBOT_TWITCH_ACCESS_TOKEN for default)`,
|
||||||
);
|
);
|
||||||
throw new Error("Missing Twitch token");
|
throw new Error("Missing Twitch token");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug?.(
|
this.logger.debug?.(
|
||||||
`[twitch] Using ${tokenResolution.source} token source for ${account.username}`,
|
`[twitch] Using ${tokenResolution.source} token source for ${account.username}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize token - strip oauth: prefix if present (Twurple doesn't need it)
|
// Normalize token - strip oauth: prefix if present (Twurple doesn't need it)
|
||||||
const normalizedToken = normalizeToken(tokenResolution.token);
|
const normalizedToken = normalizeToken(tokenResolution.token);
|
||||||
|
|
||||||
// Create auth provider
|
// Create auth provider
|
||||||
const authProvider = this.createAuthProvider(account, normalizedToken);
|
const authProvider = this.createAuthProvider(account, normalizedToken);
|
||||||
|
|
||||||
const channel = account.channel ?? account.username;
|
const channel = account.channel ?? account.username;
|
||||||
|
|
||||||
// Create chat client
|
// Create chat client
|
||||||
const client = new ChatClient({
|
const client = new ChatClient({
|
||||||
authProvider,
|
authProvider,
|
||||||
channels: [channel],
|
channels: [channel],
|
||||||
rejoinChannelsOnReconnect: true,
|
rejoinChannelsOnReconnect: true,
|
||||||
requestMembershipEvents: true,
|
requestMembershipEvents: true,
|
||||||
logger: {
|
logger: {
|
||||||
minLevel: LogLevel.WARNING,
|
minLevel: LogLevel.WARNING,
|
||||||
custom: {
|
custom: {
|
||||||
log: (level, message) => {
|
log: (level, message) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case LogLevel.CRITICAL:
|
case LogLevel.CRITICAL:
|
||||||
this.logger.error(`[twitch] ${message}`);
|
this.logger.error(`[twitch] ${message}`);
|
||||||
break;
|
break;
|
||||||
case LogLevel.ERROR:
|
case LogLevel.ERROR:
|
||||||
this.logger.error(`[twitch] ${message}`);
|
this.logger.error(`[twitch] ${message}`);
|
||||||
break;
|
break;
|
||||||
case LogLevel.WARNING:
|
case LogLevel.WARNING:
|
||||||
this.logger.warn(`[twitch] ${message}`);
|
this.logger.warn(`[twitch] ${message}`);
|
||||||
break;
|
break;
|
||||||
case LogLevel.INFO:
|
case LogLevel.INFO:
|
||||||
this.logger.info(`[twitch] ${message}`);
|
this.logger.info(`[twitch] ${message}`);
|
||||||
break;
|
break;
|
||||||
case LogLevel.DEBUG:
|
case LogLevel.DEBUG:
|
||||||
this.logger.debug?.(`[twitch] ${message}`);
|
this.logger.debug?.(`[twitch] ${message}`);
|
||||||
break;
|
break;
|
||||||
case LogLevel.TRACE:
|
case LogLevel.TRACE:
|
||||||
this.logger.debug?.(`[twitch] ${message}`);
|
this.logger.debug?.(`[twitch] ${message}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
this.setupClientHandlers(client, account);
|
this.setupClientHandlers(client, account);
|
||||||
|
|
||||||
// Connect
|
// Connect
|
||||||
client.connect();
|
client.connect();
|
||||||
|
|
||||||
this.clients.set(key, client);
|
this.clients.set(key, client);
|
||||||
this.logger.info(`[twitch] Connected to Twitch as ${account.username}`);
|
this.logger.info(`[twitch] Connected to Twitch as ${account.username}`);
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
const key = this.getAccountKey(account);
|
||||||
account: TwitchAccountConfig,
|
|
||||||
): void {
|
|
||||||
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)
|
handler({
|
||||||
: channelName;
|
username: msg.userInfo.userName,
|
||||||
handler({
|
displayName: msg.userInfo.displayName,
|
||||||
username: msg.userInfo.userName,
|
userId: msg.userInfo.userId,
|
||||||
displayName: msg.userInfo.displayName,
|
message: messageText,
|
||||||
userId: msg.userInfo.userId,
|
channel: normalizedChannel,
|
||||||
message: messageText,
|
id: msg.id,
|
||||||
channel: normalizedChannel,
|
timestamp: new Date(),
|
||||||
id: msg.id,
|
isMod: msg.userInfo.isMod,
|
||||||
timestamp: new Date(),
|
isOwner: msg.userInfo.isBroadcaster,
|
||||||
isMod: msg.userInfo.isMod,
|
isVip: msg.userInfo.isVip,
|
||||||
isOwner: msg.userInfo.isBroadcaster,
|
isSub: msg.userInfo.isSubscriber,
|
||||||
isVip: msg.userInfo.isVip,
|
chatType: "group",
|
||||||
isSub: msg.userInfo.isSubscriber,
|
});
|
||||||
chatType: "group",
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle whispers (DMs)
|
// Handle whispers (DMs)
|
||||||
client.onWhisper((_user, messageText, msg) => {
|
client.onWhisper((_user, messageText, msg) => {
|
||||||
const handler = this.messageHandlers.get(key);
|
const handler = this.messageHandlers.get(key);
|
||||||
if (handler) {
|
if (handler) {
|
||||||
handler({
|
handler({
|
||||||
username: msg.userInfo.userName,
|
username: msg.userInfo.userName,
|
||||||
displayName: msg.userInfo.displayName,
|
displayName: msg.userInfo.displayName,
|
||||||
userId: msg.userInfo.userId,
|
userId: msg.userInfo.userId,
|
||||||
message: messageText,
|
message: messageText,
|
||||||
channel: msg.userInfo.userName,
|
channel: msg.userInfo.userName,
|
||||||
id: undefined, // Whisper doesn't have id property
|
id: undefined, // Whisper doesn't have id property
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
isMod: msg.userInfo.isMod,
|
isMod: msg.userInfo.isMod,
|
||||||
isOwner: msg.userInfo.isBroadcaster,
|
isOwner: msg.userInfo.isBroadcaster,
|
||||||
isVip: msg.userInfo.isVip,
|
isVip: msg.userInfo.isVip,
|
||||||
isSub: msg.userInfo.isSubscriber,
|
isSub: msg.userInfo.isSubscriber,
|
||||||
chatType: "direct",
|
chatType: "direct",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.info(`[twitch] Set up handlers for ${key}`);
|
this.logger.info(`[twitch] Set up handlers for ${key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
const key = this.getAccountKey(account);
|
||||||
handler: (message: TwitchChatMessage) => void,
|
this.messageHandlers.set(key, handler);
|
||||||
): void {
|
}
|
||||||
const key = this.getAccountKey(account);
|
|
||||||
this.messageHandlers.set(key, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect a client
|
* Disconnect a client
|
||||||
*/
|
*/
|
||||||
async disconnect(account: TwitchAccountConfig): Promise<void> {
|
async disconnect(account: TwitchAccountConfig): Promise<void> {
|
||||||
const key = this.getAccountKey(account);
|
const key = this.getAccountKey(account);
|
||||||
const client = this.clients.get(key);
|
const client = this.clients.get(key);
|
||||||
|
|
||||||
if (client) {
|
if (client) {
|
||||||
client.quit();
|
client.quit();
|
||||||
this.clients.delete(key);
|
this.clients.delete(key);
|
||||||
this.messageHandlers.delete(key);
|
this.messageHandlers.delete(key);
|
||||||
this.logger.info(`[twitch] Disconnected ${key}`);
|
this.logger.info(`[twitch] Disconnected ${key}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect all clients
|
* Disconnect all clients
|
||||||
*/
|
*/
|
||||||
async disconnectAll(): Promise<void> {
|
async disconnectAll(): Promise<void> {
|
||||||
this.clients.forEach((client) => client.quit());
|
this.clients.forEach((client) => client.quit());
|
||||||
this.clients.clear();
|
this.clients.clear();
|
||||||
this.messageHandlers.clear();
|
this.messageHandlers.clear();
|
||||||
this.logger.info("[twitch] Disconnected all clients");
|
this.logger.info("[twitch] Disconnected all clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to a channel
|
* Send a message to a channel
|
||||||
*/
|
*/
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
account: TwitchAccountConfig,
|
account: TwitchAccountConfig,
|
||||||
channel: string,
|
channel: string,
|
||||||
message: string,
|
message: string,
|
||||||
cfg?: ClawdbotConfig,
|
cfg?: ClawdbotConfig,
|
||||||
accountId?: string,
|
accountId?: string,
|
||||||
): Promise<{ ok: boolean; error?: string; messageId?: string }> {
|
): Promise<{ ok: boolean; error?: string; messageId?: string }> {
|
||||||
try {
|
try {
|
||||||
const client = await this.getClient(account, cfg, accountId);
|
const client = await this.getClient(account, cfg, accountId);
|
||||||
|
|
||||||
// Generate a message ID (Twurple's say() doesn't return the message ID, so we generate one)
|
// Generate a message ID (Twurple's say() doesn't return the message ID, so we generate one)
|
||||||
const messageId = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
const messageId = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
|
||||||
// Send message (Twurple handles rate limiting)
|
// Send message (Twurple handles rate limiting)
|
||||||
await client.say(channel, message);
|
await client.say(channel, message);
|
||||||
|
|
||||||
return { ok: true, messageId };
|
return { ok: true, messageId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[twitch] Failed to send message: ${error instanceof Error ? error.message : String(error)}`,
|
`[twitch] Failed to send message: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique key for an account
|
* Generate a unique key for an account
|
||||||
*/
|
*/
|
||||||
public getAccountKey(account: TwitchAccountConfig): string {
|
public getAccountKey(account: TwitchAccountConfig): string {
|
||||||
return `${account.username}:${account.channel ?? account.username}`;
|
return `${account.username}:${account.channel ?? account.username}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all clients and handlers (for testing)
|
* Clear all clients and handlers (for testing)
|
||||||
*/
|
*/
|
||||||
_clearForTest(): void {
|
_clearForTest(): void {
|
||||||
this.clients.clear();
|
this.clients.clear();
|
||||||
this.messageHandlers.clear();
|
this.messageHandlers.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,38 +16,38 @@
|
|||||||
* @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 (
|
||||||
// Images
|
markdown
|
||||||
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
|
// Images
|
||||||
// Links
|
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
|
||||||
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
|
// Links
|
||||||
// Bold (**text**)
|
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
|
||||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
// Bold (**text**)
|
||||||
// Bold (__text__)
|
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||||
.replace(/__([^_]+)__/g, "$1")
|
// Bold (__text__)
|
||||||
// Italic (*text*)
|
.replace(/__([^_]+)__/g, "$1")
|
||||||
.replace(/\*([^*]+)\*/g, "$1")
|
// Italic (*text*)
|
||||||
// Italic (_text_)
|
.replace(/\*([^*]+)\*/g, "$1")
|
||||||
.replace(/_([^_]+)_/g, "$1")
|
// Italic (_text_)
|
||||||
// Strikethrough (~~text~~)
|
.replace(/_([^_]+)_/g, "$1")
|
||||||
.replace(/~~([^~]+)~~/g, "$1")
|
// Strikethrough (~~text~~)
|
||||||
// Code blocks
|
.replace(/~~([^~]+)~~/g, "$1")
|
||||||
.replace(/```[\s\S]*?```/g, (block) =>
|
// Code blocks
|
||||||
block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""),
|
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""))
|
||||||
)
|
// Inline code
|
||||||
// Inline code
|
.replace(/`([^`]+)`/g, "$1")
|
||||||
.replace(/`([^`]+)`/g, "$1")
|
// Headers
|
||||||
// Headers
|
.replace(/^#{1,6}\s+/gm, "")
|
||||||
.replace(/^#{1,6}\s+/gm, "")
|
// Lists
|
||||||
// Lists
|
.replace(/^\s*[-*+]\s+/gm, "")
|
||||||
.replace(/^\s*[-*+]\s+/gm, "")
|
.replace(/^\s*\d+\.\s+/gm, "")
|
||||||
.replace(/^\s*\d+\.\s+/gm, "")
|
// Normalize whitespace
|
||||||
// Normalize whitespace
|
.replace(/\r/g, "") // Remove carriage returns
|
||||||
.replace(/\r/g, "") // Remove carriage returns
|
.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();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -59,34 +59,34 @@ export function stripMarkdownForTwitch(markdown: string): string {
|
|||||||
* @returns Array of text chunks
|
* @returns Array of text chunks
|
||||||
*/
|
*/
|
||||||
export function chunkTextForTwitch(text: string, limit: number): string[] {
|
export function chunkTextForTwitch(text: string, limit: number): string[] {
|
||||||
// First, strip markdown
|
// First, strip markdown
|
||||||
const cleaned = stripMarkdownForTwitch(text);
|
const cleaned = stripMarkdownForTwitch(text);
|
||||||
if (!cleaned) return [];
|
if (!cleaned) return [];
|
||||||
if (limit <= 0) return [cleaned];
|
if (limit <= 0) return [cleaned];
|
||||||
if (cleaned.length <= limit) return [cleaned];
|
if (cleaned.length <= limit) return [cleaned];
|
||||||
|
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
let remaining = cleaned;
|
let remaining = cleaned;
|
||||||
|
|
||||||
while (remaining.length > limit) {
|
while (remaining.length > limit) {
|
||||||
// Find the last space before the limit
|
// Find the last space before the limit
|
||||||
const window = remaining.slice(0, limit);
|
const window = remaining.slice(0, limit);
|
||||||
const lastSpaceIndex = window.lastIndexOf(" ");
|
const lastSpaceIndex = window.lastIndexOf(" ");
|
||||||
|
|
||||||
if (lastSpaceIndex === -1) {
|
if (lastSpaceIndex === -1) {
|
||||||
// No space found, hard split at limit
|
// No space found, hard split at limit
|
||||||
chunks.push(window);
|
chunks.push(window);
|
||||||
remaining = remaining.slice(limit);
|
remaining = remaining.slice(limit);
|
||||||
} else {
|
} else {
|
||||||
// Split at the last space
|
// Split at the last space
|
||||||
chunks.push(window.slice(0, lastSpaceIndex));
|
chunks.push(window.slice(0, lastSpaceIndex));
|
||||||
remaining = remaining.slice(lastSpaceIndex + 1);
|
remaining = remaining.slice(lastSpaceIndex + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remaining) {
|
if (remaining) {
|
||||||
chunks.push(remaining);
|
chunks.push(remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,8 +16,8 @@
|
|||||||
* normalizeTwitchChannel("MyChannel") // "mychannel"
|
* normalizeTwitchChannel("MyChannel") // "mychannel"
|
||||||
*/
|
*/
|
||||||
export function normalizeTwitchChannel(channel: string): string {
|
export function normalizeTwitchChannel(channel: string): string {
|
||||||
const trimmed = channel.trim().toLowerCase();
|
const trimmed = channel.trim().toLowerCase();
|
||||||
return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -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}` : ""}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +40,7 @@ export function missingTargetError(provider: string, hint?: string): Error {
|
|||||||
* @returns A unique message ID
|
* @returns A unique message ID
|
||||||
*/
|
*/
|
||||||
export function generateMessageId(): string {
|
export function generateMessageId(): string {
|
||||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -58,7 +56,7 @@ export function generateMessageId(): string {
|
|||||||
* normalizeToken("abc123") // "abc123"
|
* normalizeToken("abc123") // "abc123"
|
||||||
*/
|
*/
|
||||||
export function normalizeToken(token: string): string {
|
export function normalizeToken(token: string): string {
|
||||||
return token.startsWith("oauth:") ? token.slice(6) : token;
|
return token.startsWith("oauth:") ? token.slice(6) : token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,9 +66,9 @@ export function normalizeToken(token: string): string {
|
|||||||
* @returns true if the account has required credentials
|
* @returns true if the account has required credentials
|
||||||
*/
|
*/
|
||||||
export function isAccountConfigured(account: {
|
export function isAccountConfigured(account: {
|
||||||
username?: string;
|
username?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return Boolean(account?.username && account?.token && account?.clientId);
|
return Boolean(account?.username && account?.token && account?.clientId);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user