Merge branch 'main' into nanogpt

This commit is contained in:
0xGingi 2026-01-26 16:57:32 -05:00 committed by GitHub
commit 91dbc7f512
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 7519 additions and 248 deletions

BIN
.agent/.DS_Store vendored

Binary file not shown.

36
.github/labeler.yml vendored
View File

@ -138,6 +138,42 @@
- any-glob-to-any-file:
- "src/cli/**"
"commands":
- changed-files:
- any-glob-to-any-file:
- "src/commands/**"
"scripts":
- changed-files:
- any-glob-to-any-file:
- "scripts/**"
"docker":
- changed-files:
- any-glob-to-any-file:
- "Dockerfile"
- "Dockerfile.*"
- "docker-compose.yml"
- "docker-setup.sh"
- ".dockerignore"
- "scripts/**/*docker*"
- "scripts/**/Dockerfile*"
- "scripts/sandbox-*.sh"
- "src/agents/sandbox*.ts"
- "src/commands/sandbox*.ts"
- "src/cli/sandbox-cli.ts"
- "src/docker-setup.test.ts"
- "src/config/**/*sandbox*"
- "docs/cli/sandbox.md"
- "docs/gateway/sandbox*.md"
- "docs/install/docker.md"
- "docs/multi-agent-sandbox-tools.md"
"agents":
- changed-files:
- any-glob-to-any-file:
- "src/agents/**"
"security":
- changed-files:
- any-glob-to-any-file:

View File

@ -3,7 +3,7 @@ name: Auto response
on:
issues:
types: [labeled]
pull_request:
pull_request_target:
types: [labeled]
permissions:
@ -14,9 +14,15 @@ jobs:
auto-response:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Handle labeled items
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const rules = [
{

View File

@ -8,6 +8,7 @@ Status: unreleased.
### Changes
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Docs: add migration guide for moving to a new machine. (#2381)
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
@ -40,6 +41,7 @@ Status: unreleased.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
@ -48,6 +50,7 @@ Status: unreleased.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.

View File

@ -282,22 +282,6 @@ enum CommandResolver {
guard !settings.target.isEmpty else { return nil }
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
var args: [String] = [
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !identity.isEmpty {
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", identity])
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)
// Run the real clawdbot CLI on the remote host.
let exportedPath = [
"/opt/homebrew/bin",
@ -324,7 +308,7 @@ enum CommandResolver {
} else {
"""
PRJ=\(self.shellQuote(userPRJ))
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
"""
}
@ -378,7 +362,16 @@ enum CommandResolver {
echo "clawdbot CLI missing on remote host"; exit 127;
fi
"""
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
let options: [String] = [
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
let args = self.sshArguments(
target: parsed,
identity: settings.identity,
options: options,
remoteCommand: ["/bin/sh", "-c", scriptBody])
return ["/usr/bin/ssh"] + args
}
@ -427,8 +420,11 @@ enum CommandResolver {
}
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmed = self.normalizeSSHTargetInput(target)
guard !trimmed.isEmpty else { return nil }
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
return nil
}
let userHostPort: String
let user: String?
if let atRange = trimmed.range(of: "@") {
@ -444,13 +440,31 @@ enum CommandResolver {
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
host = String(userHostPort[..<colon])
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
port = Int(portStr) ?? 22
guard let parsedPort = Int(portStr), parsedPort > 0, parsedPort <= 65535 else {
return nil
}
port = parsedPort
} else {
host = userHostPort
port = 22
}
return SSHParsedTarget(user: user, host: host, port: port)
return self.makeSSHTarget(user: user, host: host, port: port)
}
static func sshTargetValidationMessage(_ target: String) -> String? {
let trimmed = self.normalizeSSHTargetInput(target)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("-") {
return "SSH target cannot start with '-'"
}
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
return "SSH target cannot contain spaces"
}
if self.parseSSHTarget(trimmed) == nil {
return "SSH target must look like user@host[:port]"
}
return nil
}
private static func shellQuote(_ text: String) -> String {
@ -468,6 +482,64 @@ enum CommandResolver {
return URL(fileURLWithPath: expanded)
}
private static func normalizeSSHTargetInput(_ target: String) -> String {
var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool {
if value.isEmpty { return false }
if !allowLeadingDash, value.hasPrefix("-") { return false }
let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
return value.rangeOfCharacter(from: invalid) == nil
}
static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? {
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.isValidSSHComponent(trimmedHost) else { return nil }
let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedUser: String?
if let trimmedUser {
guard self.isValidSSHComponent(trimmedUser) else { return nil }
normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser
} else {
normalizedUser = nil
}
guard port > 0, port <= 65535 else { return nil }
return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port)
}
private static func sshTargetString(_ target: SSHParsedTarget) -> String {
target.user.map { "\($0)@\(target.host)" } ?? target.host
}
static func sshArguments(
target: SSHParsedTarget,
identity: String,
options: [String],
remoteCommand: [String] = []) -> [String]
{
var args = options
if target.port > 0 {
args.append(contentsOf: ["-p", String(target.port)])
}
let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", trimmedIdentity])
}
args.append("--")
args.append(self.sshTargetString(target))
args.append(contentsOf: remoteCommand)
return args
}
#if SWIFT_PACKAGE
static func _testNodeManagerBinPaths(home: URL) -> [String] {
self.nodeManagerBinPaths(home: home)

View File

@ -243,25 +243,36 @@ struct GeneralSettings: View {
}
private var remoteSshRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget)
let canTest = !trimmedTarget.isEmpty && validationMessage == nil
return VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .center, spacing: 10) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || !canTest)
}
if let validationMessage {
Text(validationMessage)
.font(.caption)
.foregroundStyle(.red)
.padding(.leading, self.remoteLabelWidth + 10)
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
@ -540,8 +551,15 @@ extension GeneralSettings {
}
// Step 1: basic SSH reachability check
guard let sshCommand = Self.sshCheckCommand(
target: settings.target,
identity: settings.identity)
else {
self.remoteStatus = .failed("SSH target is invalid")
return
}
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
command: sshCommand,
cwd: nil,
env: nil,
timeout: 8)
@ -587,20 +605,20 @@ extension GeneralSettings {
return !host.isEmpty
}
private static func sshCheckCommand(target: String, identity: String) -> [String] {
var args: [String] = [
"/usr/bin/ssh",
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args.append(contentsOf: ["-i", identity])
}
args.append(target)
args.append("echo ok")
return args
let args = CommandResolver.sshArguments(
target: parsed,
identity: identity,
options: options,
remoteCommand: ["echo", "ok"])
return ["/usr/bin/ssh"] + args
}
private func formatSSHFailure(_ response: Response, target: String) -> String {

View File

@ -559,22 +559,21 @@ final class NodePairingApprovalPrompter {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
var args = [
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
"-o",
"NumberOfPasswordPrompts=0",
"-o",
"PreferredAuthentications=publickey",
"-o",
"StrictHostKeyChecking=accept-new",
let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "NumberOfPasswordPrompts=0",
"-o", "PreferredAuthentications=publickey",
"-o", "StrictHostKeyChecking=accept-new",
]
if port > 0, port != 22 {
args.append(contentsOf: ["-p", String(port)])
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
return false
}
args.append(contentsOf: ["-l", user, host, "/usr/bin/true"])
let args = CommandResolver.sshArguments(
target: target,
identity: "",
options: options,
remoteCommand: ["/usr/bin/true"])
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe

View File

@ -206,6 +206,16 @@ extension OnboardingView {
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
if let message = CommandResolver.sshTargetValidationMessage(self.state.remoteTarget) {
GridRow {
Text("")
.frame(width: labelWidth, alignment: .leading)
Text(message)
.font(.caption)
.foregroundStyle(.red)
.frame(width: fieldWidth, alignment: .leading)
}
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))

View File

@ -70,7 +70,7 @@ final class RemotePortTunnel {
"ssh tunnel using default remote port " +
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
}
var args: [String] = [
let options: [String] = [
"-o", "BatchMode=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
@ -81,16 +81,11 @@ final class RemotePortTunnel {
"-N",
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !identity.isEmpty {
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", identity])
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)
let args = CommandResolver.sshArguments(
target: parsed,
identity: identity,
options: options)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")

View File

@ -123,11 +123,16 @@ import Testing
configRoot: [:])
#expect(cmd.first == "/usr/bin/ssh")
#expect(cmd.contains("clawd@example.com"))
if let marker = cmd.firstIndex(of: "--") {
#expect(cmd[marker + 1] == "clawd@example.com")
} else {
#expect(Bool(false))
}
#expect(cmd.contains("-i"))
#expect(cmd.contains("/tmp/id_ed25519"))
if let script = cmd.last {
#expect(script.contains("cd '/srv/clawdbot'"))
#expect(script.contains("PRJ='/srv/clawdbot'"))
#expect(script.contains("cd \"$PRJ\""))
#expect(script.contains("clawdbot"))
#expect(script.contains("status"))
#expect(script.contains("--json"))
@ -135,6 +140,12 @@ import Testing
}
}
@Test func rejectsUnsafeSSHTargets() async throws {
#expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
#expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
#expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
}
@Test func configRootLocalOverridesRemoteDefaults() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)

View File

@ -11,7 +11,12 @@ struct MasterDiscoveryMenuSmokeTests {
discovery.statusText = "Searching…"
discovery.gateways = []
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
let view = GatewayDiscoveryInlineList(
discovery: discovery,
currentTarget: nil,
currentUrl: nil,
transport: .ssh,
onSelect: { _ in })
_ = view.body
}
@ -32,7 +37,12 @@ struct MasterDiscoveryMenuSmokeTests {
]
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
let view = GatewayDiscoveryInlineList(
discovery: discovery,
currentTarget: currentTarget,
currentUrl: nil,
transport: .ssh,
onSelect: { _ in })
_ = view.body
}

View File

@ -201,7 +201,7 @@ For ad-hoc workflows, call Lobster directly.
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
- The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended).
- If you pass `lobsterPath`, it must be an **absolute path**.
See [Lobster](/tools/lobster) for full usage and examples.

View File

@ -10,13 +10,14 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
## Quick setup (beginner)
1) Create a Discord bot and copy the bot token.
2) Set the token for Clawdbot:
2) In the Discord app settings, enable **Message Content Intent** (and **Server Members Intent** if you plan to use allowlists or name lookups).
3) Set the token for Clawdbot:
- Env: `DISCORD_BOT_TOKEN=...`
- Or config: `channels.discord.token: "..."`.
- If both are set, config takes precedence (env fallback is default-account only).
3) Invite the bot to your server with message permissions.
4) Start the gateway.
5) DM access is pairing by default; approve the pairing code on first contact.
4) Invite the bot to your server with message permissions (create a private server if you just want DMs).
5) Start the gateway.
6) DM access is pairing by default; approve the pairing code on first contact.
Minimal config:
```json5

View File

@ -26,6 +26,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.

366
docs/channels/twitch.md Normal file
View File

@ -0,0 +1,366 @@
---
summary: "Twitch chat bot configuration and setup"
read_when:
- Setting up Twitch chat integration for Clawdbot
---
# Twitch (plugin)
Twitch chat support via IRC connection. Clawdbot connects as a Twitch user (bot account) to receive and send messages in channels.
## Plugin required
Twitch ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/twitch
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/twitch
```
Details: [Plugins](/plugin)
## Quick setup (beginner)
1) Create a dedicated Twitch account for the bot (or use an existing account).
2) Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
- Select **Bot Token**
- Verify scopes `chat:read` and `chat:write` are selected
- Copy the **Client ID** and **Access Token**
3) Find your Twitch user ID: https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/
4) Configure the token:
- Env: `CLAWDBOT_TWITCH_ACCESS_TOKEN=...` (default account only)
- Or config: `channels.twitch.accessToken`
- If both are set, config takes precedence (env fallback is default-account only).
5) Start the gateway.
**⚠️ Important:** Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`.
Minimal config:
```json5
{
channels: {
twitch: {
enabled: true,
username: "clawdbot", // Bot's Twitch account
accessToken: "oauth:abc123...", // OAuth Access Token (or use CLAWDBOT_TWITCH_ACCESS_TOKEN env var)
clientId: "xyz789...", // Client ID from Token Generator
channel: "vevisk", // Which Twitch channel's chat to join (required)
allowFrom: ["123456789"] // (recommended) Your Twitch user ID only - get it from https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/
}
}
}
```
## What it is
- A Twitch channel owned by the Gateway.
- Deterministic routing: replies always go back to Twitch.
- Each account maps to an isolated session key `agent:<agentId>:twitch:<accountName>`.
- `username` is the bot's account (who authenticates), `channel` is which chat room to join.
## Setup (detailed)
### Generate credentials
Use [Twitch Token Generator](https://twitchtokengenerator.com/):
- Select **Bot Token**
- Verify scopes `chat:read` and `chat:write` are selected
- Copy the **Client ID** and **Access Token**
No manual app registration needed. Tokens expire after several hours.
### Configure the bot
**Env var (default account only):**
```bash
CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:abc123...
```
**Or config:**
```json5
{
channels: {
twitch: {
enabled: true,
username: "clawdbot",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "vevisk"
}
}
}
```
If both env and config are set, config takes precedence.
### Access control (recommended)
```json5
{
channels: {
twitch: {
allowFrom: ["123456789"], // (recommended) Your Twitch user ID only
allowedRoles: ["moderator"] // Or restrict to roles
}
}
}
```
**Available roles:** `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`.
**Why user IDs?** Usernames can change, allowing impersonation. User IDs are permanent.
Find your Twitch user ID: https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/ (Convert your Twitch username to ID)
## Token refresh (optional)
Tokens from [Twitch Token Generator](https://twitchtokengenerator.com/) cannot be automatically refreshed - regenerate when expired.
For automatic token refresh, create your own Twitch application at [Twitch Developer Console](https://dev.twitch.tv/console) and add to config:
```json5
{
channels: {
twitch: {
clientSecret: "your_client_secret",
refreshToken: "your_refresh_token"
}
}
}
```
The bot automatically refreshes tokens before expiration and logs refresh events.
## Multi-account support
Use `channels.twitch.accounts` with per-account tokens. See [`gateway/configuration`](/gateway/configuration) for the shared pattern.
Example (one bot account in two channels):
```json5
{
channels: {
twitch: {
accounts: {
channel1: {
username: "clawdbot",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "vevisk"
},
channel2: {
username: "clawdbot",
accessToken: "oauth:def456...",
clientId: "uvw012...",
channel: "secondchannel"
}
}
}
}
}
```
**Note:** Each account needs its own token (one token per channel).
## Access control
### Role-based restrictions
```json5
{
channels: {
twitch: {
accounts: {
default: {
allowedRoles: ["moderator", "vip"]
}
}
}
}
}
```
### Allowlist by User ID (most secure)
```json5
{
channels: {
twitch: {
accounts: {
default: {
allowFrom: ["123456789", "987654321"]
}
}
}
}
}
```
### Combined allowlist + roles
Users in `allowFrom` bypass role checks:
```json5
{
channels: {
twitch: {
accounts: {
default: {
allowFrom: ["123456789"],
allowedRoles: ["moderator"]
}
}
}
}
}
```
### Disable @mention requirement
By default, `requireMention` is `true`. To disable and respond to all messages:
```json5
{
channels: {
twitch: {
accounts: {
default: {
requireMention: false
}
}
}
}
}
```
## Troubleshooting
First, run diagnostic commands:
```bash
clawdbot doctor
clawdbot channels status --probe
```
### Bot doesn't respond to messages
**Check access control:** Temporarily set `allowedRoles: ["all"]` to test.
**Check the bot is in the channel:** The bot must join the channel specified in `channel`.
### Token issues
**"Failed to connect" or authentication errors:**
- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix)
- Check token has `chat:read` and `chat:write` scopes
- If using token refresh, verify `clientSecret` and `refreshToken` are set
### Token refresh not working
**Check logs for refresh events:**
```
Using env token source for mybot
Access token refreshed for user 123456 (expires in 14400s)
```
If you see "token refresh disabled (no refresh token)":
- Ensure `clientSecret` is provided
- Ensure `refreshToken` is provided
## Config
**Account config:**
- `username` - Bot username
- `accessToken` - OAuth access token with `chat:read` and `chat:write`
- `clientId` - Twitch Client ID (from Token Generator or your app)
- `channel` - Channel to join (required)
- `enabled` - Enable this account (default: `true`)
- `clientSecret` - Optional: For automatic token refresh
- `refreshToken` - Optional: For automatic token refresh
- `expiresIn` - Token expiry in seconds
- `obtainmentTimestamp` - Token obtained timestamp
- `allowFrom` - User ID allowlist
- `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`)
- `requireMention` - Require @mention (default: `true`)
**Provider options:**
- `channels.twitch.enabled` - Enable/disable channel startup
- `channels.twitch.username` - Bot username (simplified single-account config)
- `channels.twitch.accessToken` - OAuth access token (simplified single-account config)
- `channels.twitch.clientId` - Twitch Client ID (simplified single-account config)
- `channels.twitch.channel` - Channel to join (simplified single-account config)
- `channels.twitch.accounts.<accountName>` - Multi-account config (all account fields above)
Full example:
```json5
{
channels: {
twitch: {
enabled: true,
username: "clawdbot",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "vevisk",
clientSecret: "secret123...",
refreshToken: "refresh456...",
allowFrom: ["123456789"],
allowedRoles: ["moderator", "vip"],
accounts: {
default: {
username: "mybot",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "your_channel",
enabled: true,
clientSecret: "secret123...",
refreshToken: "refresh456...",
expiresIn: 14400,
obtainmentTimestamp: 1706092800000,
allowFrom: ["123456789", "987654321"],
allowedRoles: ["moderator"]
}
}
}
}
}
```
## Tool actions
The agent can call `twitch` with action:
- `send` - Send a message to a channel
Example:
```json5
{
"action": "twitch",
"params": {
"message": "Hello Twitch!",
"to": "#mychannel"
}
}
```
## Safety & ops
- **Treat tokens like passwords** - Never commit tokens to git
- **Use automatic token refresh** for long-running bots
- **Use user ID allowlists** instead of usernames for access control
- **Monitor logs** for token refresh events and connection status
- **Scope tokens minimally** - Only request `chat:read` and `chat:write`
- **If stuck**: Restart the gateway after confirming no other process owns the session
## Limits
- **500 characters** per message (auto-chunked at word boundaries)
- Markdown is stripped before chunking
- No rate limiting (uses Twitch's built-in rate limits)

View File

@ -23,3 +23,4 @@ clawdbot onboard --mode remote --remote-url ws://gateway-host:18789
Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
- Fastest first chat: `clawdbot dashboard` (Control UI, no channel setup).

View File

@ -43,6 +43,18 @@ Start with the smallest access that still works, then widen it as you gain confi
If you run `--deep`, Clawdbot also attempts a best-effort live Gateway probe.
## Credential storage map
Use this when auditing access or deciding what to back up:
- **WhatsApp**: `~/.clawdbot/credentials/whatsapp/<accountId>/creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
- **Discord bot token**: config/env (token file not yet supported)
- **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**: `~/.clawdbot/credentials/<channel>-allowFrom.json`
- **Model auth profiles**: `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json`
- **Legacy OAuth import**: `~/.clawdbot/credentials/oauth.json`
## Security Audit Checklist
When the audit prints findings, treat this as a priority order:
@ -199,6 +211,7 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
- Prefer mention gating in groups; avoid “always-on” bots in public rooms.
- Treat links, attachments, and pasted instructions as hostile by default.
- Run sensitive tool execution in a sandbox; keep secrets out of the agents reachable filesystem.
- Note: sandboxing is opt-in. If sandbox mode is off, exec runs on the gateway host even though tools.exec.host defaults to sandbox, and host exec does not require approvals unless you set host=gateway and configure exec approvals.
- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists.
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because its quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).

View File

@ -401,7 +401,7 @@ remote mode, remember the gateway host owns the session store and workspace.
up **memory + bootstrap files**, but **not** session history or auth. Those live
under `~/.clawdbot/` (for example `~/.clawdbot/agents/<agentId>/sessions/`).
Related: [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
Related: [Migrating](/install/migrating), [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
[Agent workspace](/concepts/agent-workspace), [Doctor](/gateway/doctor),
[Remote mode](/gateway/remote).

View File

@ -177,4 +177,5 @@ Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Update / uninstall
- Updates: [Updating](/install/updating)
- Migrate to a new machine: [Migrating](/install/migrating)
- Uninstall: [Uninstall](/install/uninstall)

190
docs/install/migrating.md Normal file
View File

@ -0,0 +1,190 @@
---
summary: "Move (migrate) a Clawdbot install from one machine to another"
read_when:
- You are moving Clawdbot to a new laptop/server
- You want to preserve sessions, auth, and channel logins (WhatsApp, etc.)
---
# Migrating Clawdbot to a new machine
This guide migrates a Clawdbot Gateway from one machine to another **without redoing onboarding**.
The migration is simple conceptually:
- Copy the **state directory** (`$CLAWDBOT_STATE_DIR`, default: `~/.clawdbot/`) — this includes config, auth, sessions, and channel state.
- Copy your **workspace** (`~/clawd/` by default) — this includes your agent files (memory, prompts, etc.).
But there are common footguns around **profiles**, **permissions**, and **partial copies**.
## Before you start (what you are migrating)
### 1) Identify your state directory
Most installs use the default:
- **State dir:** `~/.clawdbot/`
But it may be different if you use:
- `--profile <name>` (often becomes `~/.clawdbot-<profile>/`)
- `CLAWDBOT_STATE_DIR=/some/path`
If youre not sure, run on the **old** machine:
```bash
clawdbot status
```
Look for mentions of `CLAWDBOT_STATE_DIR` / profile in the output. If you run multiple gateways, repeat for each profile.
### 2) Identify your workspace
Common defaults:
- `~/clawd/` (recommended workspace)
- a custom folder you created
Your workspace is where files like `MEMORY.md`, `USER.md`, and `memory/*.md` live.
### 3) Understand what you will preserve
If you copy **both** the state dir and workspace, you keep:
- Gateway configuration (`clawdbot.json`)
- Auth profiles / API keys / OAuth tokens
- Session history + agent state
- Channel state (e.g. WhatsApp login/session)
- Your workspace files (memory, skills notes, etc.)
If you copy **only** the workspace (e.g., via Git), you do **not** preserve:
- sessions
- credentials
- channel logins
Those live under `$CLAWDBOT_STATE_DIR`.
## Migration steps (recommended)
### Step 0 — Make a backup (old machine)
On the **old** machine, stop the gateway first so files arent changing mid-copy:
```bash
clawdbot gateway stop
```
(Optional but recommended) archive the state dir and workspace:
```bash
# Adjust paths if you use a profile or custom locations
cd ~
tar -czf clawdbot-state.tgz .clawdbot
tar -czf clawd-workspace.tgz clawd
```
If you have multiple profiles/state dirs (e.g. `~/.clawdbot-main`, `~/.clawdbot-work`), archive each.
### Step 1 — Install Clawdbot on the new machine
On the **new** machine, install the CLI (and Node if needed):
- See: [Install](/install)
At this stage, its OK if onboarding creates a fresh `~/.clawdbot/` — you will overwrite it in the next step.
### Step 2 — Copy the state dir + workspace to the new machine
Copy **both**:
- `$CLAWDBOT_STATE_DIR` (default `~/.clawdbot/`)
- your workspace (default `~/clawd/`)
Common approaches:
- `scp` the tarballs and extract
- `rsync -a` over SSH
- external drive
After copying, ensure:
- Hidden directories were included (e.g. `.clawdbot/`)
- File ownership is correct for the user running the gateway
### Step 3 — Run Doctor (migrations + service repair)
On the **new** machine:
```bash
clawdbot doctor
```
Doctor is the “safe boring” command. It repairs services, applies config migrations, and warns about mismatches.
Then:
```bash
clawdbot gateway restart
clawdbot status
```
## Common footguns (and how to avoid them)
### Footgun: profile / state-dir mismatch
If you ran the old gateway with a profile (or `CLAWDBOT_STATE_DIR`), and the new gateway uses a different one, youll see symptoms like:
- config changes not taking effect
- channels missing / logged out
- empty session history
Fix: run the gateway/service using the **same** profile/state dir you migrated, then rerun:
```bash
clawdbot doctor
```
### Footgun: copying only `clawdbot.json`
`clawdbot.json` is not enough. Many providers store state under:
- `$CLAWDBOT_STATE_DIR/credentials/`
- `$CLAWDBOT_STATE_DIR/agents/<agentId>/...`
Always migrate the entire `$CLAWDBOT_STATE_DIR` folder.
### Footgun: permissions / ownership
If you copied as root or changed users, the gateway may fail to read credentials/sessions.
Fix: ensure the state dir + workspace are owned by the user running the gateway.
### Footgun: migrating between remote/local modes
- If your UI (WebUI/TUI) points at a **remote** gateway, the remote host owns the session store + workspace.
- Migrating your laptop wont move the remote gateways state.
If youre in remote mode, migrate the **gateway host**.
### Footgun: secrets in backups
`$CLAWDBOT_STATE_DIR` contains secrets (API keys, OAuth tokens, WhatsApp creds). Treat backups like production secrets:
- store encrypted
- avoid sharing over insecure channels
- rotate keys if you suspect exposure
## Verification checklist
On the new machine, confirm:
- `clawdbot status` shows the gateway running
- Your channels are still connected (e.g. WhatsApp doesnt require re-pair)
- The dashboard opens and shows existing sessions
- Your workspace files (memory, configs) are present
## Related
- [Doctor](/gateway/doctor)
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Where does Clawdbot store its data?](/help/faq#where-does-clawdbot-store-its-data)

View File

@ -9,6 +9,10 @@ read_when:
Goal: go from **zero****first working chat** (with sane defaults) as quickly as possible.
Fastest chat: open the Control UI (no channel setup needed). Run `clawdbot dashboard`
and chat in the browser, or open `http://127.0.0.1:18789/` on the gateway host.
Docs: [Dashboard](/web/dashboard) and [Control UI](/web/control-ui).
Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up:
- model/auth (OAuth recommended)
- gateway settings
@ -121,6 +125,7 @@ channels. If you use WhatsApp or Telegram, run the Gateway with **Node**.
```bash
clawdbot status
clawdbot health
clawdbot security audit --deep
```
## 4) Pair + connect your first chat surface

View File

@ -104,6 +104,19 @@ clawdbot health
- Sessions: `~/.clawdbot/agents/<agentId>/sessions/`
- Logs: `/tmp/clawdbot/`
## Credential storage map
Use this when debugging auth or deciding what to back up:
- **WhatsApp**: `~/.clawdbot/credentials/whatsapp/<accountId>/creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
- **Discord bot token**: config/env (token file not yet supported)
- **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**: `~/.clawdbot/credentials/<channel>-allowFrom.json`
- **Model auth profiles**: `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json`
- **Legacy OAuth import**: `~/.clawdbot/credentials/oauth.json`
More detail: [Security](/gateway/security#credential-storage-map).
## Updating (without wrecking your setup)
- Keep `~/clawd` and `~/.clawdbot/` as “your stuff”; dont put personal prompts/config into the `clawdbot` repo.

View File

@ -18,6 +18,9 @@ Primary entrypoint:
clawdbot onboard
```
Fastest first chat: open the Control UI (no channel setup needed). Run
`clawdbot dashboard` and chat in the browser. Docs: [Dashboard](/web/dashboard).
Followup reconfiguration:
```bash

View File

@ -34,6 +34,9 @@ Notes:
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`)
from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists.
- Important: sandboxing is **off by default**. If sandboxing is off, `host=sandbox` runs directly on
the gateway host (no container) and **does not require approvals**. To require approvals, run with
`host=gateway` and configure exec approvals (or enable sandboxing).
## Config

View File

@ -158,7 +158,19 @@ If you want to use a custom binary location, pass an **absolute** `lobsterPath`
## Enable the tool
Lobster is an **optional** plugin tool (not enabled by default). Allow it per agent:
Lobster is an **optional** plugin tool (not enabled by default).
Recommended (additive, safe):
```json
{
"tools": {
"alsoAllow": ["lobster"]
}
}
```
Or per-agent:
```json
{
@ -167,7 +179,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
{
"id": "main",
"tools": {
"allow": ["lobster"]
"alsoAllow": ["lobster"]
}
}
]
@ -175,7 +187,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
}
```
You can also allow it globally with `tools.allow` if every agent should see it.
Avoid using `tools.allow: ["lobster"]` unless you intend to run in restrictive allowlist mode.
Note: allowlists are opt-in for optional plugins. If your allowlist only names
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core

View File

@ -64,6 +64,14 @@ By default, `clawdhub` installs into `./skills` under your current working
directory (or falls back to the configured Clawdbot workspace). Clawdbot picks
that up as `<workspace>/skills` on the next session.
## Security notes
- Treat third-party skills as **trusted code**. Read them before enabling.
- Prefer sandboxed runs for untrusted inputs and risky tools. See [Sandboxing](/gateway/sandboxing).
- `skills.entries.*.env` and `skills.entries.*.apiKey` inject secrets into the **host** process
for that agent turn (not the sandbox). Keep secrets out of prompts and logs.
- For a broader threat model and checklists, see [Security](/gateway/security).
## Format (AgentSkills + Pi-compatible)
`SKILL.md` must include at least:

View File

@ -19,6 +19,10 @@ Key references:
Authentication is enforced at the WebSocket handshake via `connect.params.auth`
(token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration).
Security note: the Control UI is an **admin surface** (chat, config, exec approvals).
Do not expose it publicly. The UI stores the token in `localStorage` after first load.
Prefer localhost, Tailscale Serve, or an SSH tunnel.
## Fast path (recommended)
- After onboarding, the CLI now auto-opens the dashboard with your token and prints the same tokenized link.

View File

@ -0,0 +1,21 @@
# Changelog
## 2026.1.23
### Features
- Initial Twitch plugin release
- Twitch chat integration via @twurple (IRC connection)
- Multi-account support with per-channel configuration
- Access control via user ID allowlists and role-based restrictions
- Automatic token refresh with RefreshingAuthProvider
- Environment variable fallback for default account token
- Message actions support
- Status monitoring and probing
- Outbound message delivery with markdown stripping
### Improvements
- Added proper configuration schema with Zod validation
- Added plugin descriptor (clawdbot.plugin.json)
- Added comprehensive README and documentation

View File

@ -0,0 +1,89 @@
# @clawdbot/twitch
Twitch channel plugin for Clawdbot.
## Install (local checkout)
```bash
clawdbot plugins install ./extensions/twitch
```
## Install (npm)
```bash
clawdbot plugins install @clawdbot/twitch
```
Onboarding: select Twitch and confirm the install prompt to fetch the plugin automatically.
## Config
Minimal config (simplified single-account):
**⚠️ Important:** `requireMention` defaults to `true`. Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot.
```json5
{
channels: {
twitch: {
enabled: true,
username: "clawdbot",
accessToken: "oauth:abc123...", // OAuth Access Token (add oauth: prefix)
clientId: "xyz789...", // Client ID from Token Generator
channel: "vevisk", // Channel to join (required)
allowFrom: ["123456789"], // (recommended) Your Twitch user ID only (Convert your twitch username to ID at https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/)
},
},
}
```
**Access control options:**
- `requireMention: false` - Disable the default mention requirement to respond to all messages
- `allowFrom: ["your_user_id"]` - Restrict to your Twitch user ID only (find your ID at https://www.twitchangles.com/xqc or similar)
- `allowedRoles: ["moderator", "vip", "subscriber"]` - Restrict to specific roles
Multi-account config (advanced):
```json5
{
channels: {
twitch: {
enabled: true,
accounts: {
default: {
username: "clawdbot",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "vevisk",
},
channel2: {
username: "clawdbot",
accessToken: "oauth:def456...",
clientId: "uvw012...",
channel: "secondchannel",
},
},
},
},
}
```
## Setup
1. Create a dedicated Twitch account for the bot, then generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
- Select **Bot Token**
- Verify scopes `chat:read` and `chat:write` are selected
- Copy the **Access Token** to `token` property
- Copy the **Client ID** to `clientId` property
2. Start the gateway
## Full documentation
See https://docs.clawd.bot/channels/twitch for:
- Token refresh setup
- Access control patterns
- Multi-account configuration
- Troubleshooting
- Capabilities & limits

View File

@ -0,0 +1,9 @@
{
"id": "twitch",
"channels": ["twitch"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,20 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { twitchPlugin } from "./src/plugin.js";
import { setTwitchRuntime } from "./src/runtime.js";
export { monitorTwitchProvider } from "./src/monitor.js";
const plugin = {
id: "twitch",
name: "Twitch",
description: "Twitch channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setTwitchRuntime(api.runtime);
api.registerChannel({ plugin: twitchPlugin as any });
},
};
export default plugin;

View File

@ -0,0 +1,20 @@
{
"name": "@clawdbot/twitch",
"version": "2026.1.23",
"description": "Clawdbot Twitch channel plugin",
"type": "module",
"dependencies": {
"@twurple/api": "^8.0.3",
"@twurple/auth": "^8.0.3",
"@twurple/chat": "^8.0.3",
"zod": "^4.3.5"
},
"devDependencies": {
"clawdbot": "workspace:*"
},
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,489 @@
import { describe, expect, it } from "vitest";
import { checkTwitchAccessControl, extractMentions } from "./access-control.js";
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
describe("checkTwitchAccessControl", () => {
const mockAccount: TwitchAccountConfig = {
username: "testbot",
token: "oauth:test",
};
const mockMessage: TwitchChatMessage = {
username: "testuser",
userId: "123456",
message: "hello bot",
channel: "testchannel",
};
describe("when no restrictions are configured", () => {
it("allows messages that mention the bot (default requireMention)", () => {
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
};
const result = checkTwitchAccessControl({
message,
account: mockAccount,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
});
describe("requireMention default", () => {
it("defaults to true when undefined", () => {
const message: TwitchChatMessage = {
...mockMessage,
message: "hello bot",
};
const result = checkTwitchAccessControl({
message,
account: mockAccount,
botUsername: "testbot",
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("allows mention when requireMention is undefined", () => {
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
};
const result = checkTwitchAccessControl({
message,
account: mockAccount,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
});
describe("requireMention", () => {
it("allows messages that mention the bot", () => {
const account: TwitchAccountConfig = {
...mockAccount,
requireMention: true,
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
it("blocks messages that don't mention the bot", () => {
const account: TwitchAccountConfig = {
...mockAccount,
requireMention: true,
};
const result = checkTwitchAccessControl({
message: mockMessage,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("is case-insensitive for bot username", () => {
const account: TwitchAccountConfig = {
...mockAccount,
requireMention: true,
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@TestBot hello",
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
});
describe("allowFrom allowlist", () => {
it("allows users in the allowlist", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["123456", "789012"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
expect(result.matchKey).toBe("123456");
expect(result.matchSource).toBe("allowlist");
});
it("allows users not in allowlist via fallback (open access)", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["789012"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
// Falls through to final fallback since allowedRoles is not set
expect(result.allowed).toBe(true);
});
it("blocks messages without userId", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["123456"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
userId: undefined,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("user ID not available");
});
it("bypasses role checks when user is in allowlist", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["123456"],
allowedRoles: ["owner"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isOwner: false,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
it("allows user with role even if not in allowlist", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["789012"],
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
userId: "123456",
isMod: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("role");
});
it("blocks user with neither allowlist nor role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["789012"],
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
userId: "123456",
isMod: false,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not have any of the required roles");
});
});
describe("allowedRoles", () => {
it("allows users with matching role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isMod: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("role");
});
it("allows users with any of multiple roles", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator", "vip", "subscriber"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isVip: true,
isMod: false,
isSub: false,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
it("blocks users without matching role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isMod: false,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not have any of the required roles");
});
it("allows all users when role is 'all'", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["all"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
expect(result.matchKey).toBe("all");
});
it("handles moderator role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isMod: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
it("handles subscriber role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["subscriber"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isSub: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
it("handles owner role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["owner"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isOwner: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
it("handles vip role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["vip"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isVip: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
});
});
describe("combined restrictions", () => {
it("checks requireMention before allowlist", () => {
const account: TwitchAccountConfig = {
...mockAccount,
requireMention: true,
allowFrom: ["123456"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "hello", // No mention
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("checks allowlist before allowedRoles", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["123456"],
allowedRoles: ["owner"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isOwner: false,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
});
expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("allowlist");
});
});
});
describe("extractMentions", () => {
it("extracts single mention", () => {
const mentions = extractMentions("hello @testbot");
expect(mentions).toEqual(["testbot"]);
});
it("extracts multiple mentions", () => {
const mentions = extractMentions("hello @testbot and @otheruser");
expect(mentions).toEqual(["testbot", "otheruser"]);
});
it("returns empty array when no mentions", () => {
const mentions = extractMentions("hello everyone");
expect(mentions).toEqual([]);
});
it("handles mentions at start of message", () => {
const mentions = extractMentions("@testbot hello");
expect(mentions).toEqual(["testbot"]);
});
it("handles mentions at end of message", () => {
const mentions = extractMentions("hello @testbot");
expect(mentions).toEqual(["testbot"]);
});
it("converts mentions to lowercase", () => {
const mentions = extractMentions("hello @TestBot");
expect(mentions).toEqual(["testbot"]);
});
it("extracts alphanumeric usernames", () => {
const mentions = extractMentions("hello @user123");
expect(mentions).toEqual(["user123"]);
});
it("handles underscores in usernames", () => {
const mentions = extractMentions("hello @test_user");
expect(mentions).toEqual(["test_user"]);
});
it("handles empty string", () => {
const mentions = extractMentions("");
expect(mentions).toEqual([]);
});
});

View File

@ -0,0 +1,154 @@
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
/**
* Result of checking access control for a Twitch message
*/
export type TwitchAccessControlResult = {
allowed: boolean;
reason?: string;
matchKey?: string;
matchSource?: string;
};
/**
* Check if a Twitch message should be allowed based on account configuration
*
* This function implements the access control logic for incoming Twitch messages,
* checking allowlists, role-based restrictions, and mention requirements.
*
* Priority order:
* 1. If `requireMention` is true, message must mention the bot
* 2. If `allowFrom` is set, sender must be in the allowlist (by user ID)
* 3. If `allowedRoles` is set, sender must have at least one of the specified roles
*
* Note: You can combine `allowFrom` with `allowedRoles`. If a user is in `allowFrom`,
* they bypass role checks. This is useful for allowing specific users regardless of role.
*
* Available roles:
* - "moderator": Moderators
* - "owner": Channel owner/broadcaster
* - "vip": VIPs
* - "subscriber": Subscribers
* - "all": Anyone in the chat
*/
export function checkTwitchAccessControl(params: {
message: TwitchChatMessage;
account: TwitchAccountConfig;
botUsername: string;
}): TwitchAccessControlResult {
const { message, account, botUsername } = params;
if (account.requireMention ?? true) {
const mentions = extractMentions(message.message);
if (!mentions.includes(botUsername.toLowerCase())) {
return {
allowed: false,
reason: "message does not mention the bot (requireMention is enabled)",
};
}
}
if (account.allowFrom && account.allowFrom.length > 0) {
const allowFrom = account.allowFrom;
const senderId = message.userId;
if (!senderId) {
return {
allowed: false,
reason: "sender user ID not available for allowlist check",
};
}
if (allowFrom.includes(senderId)) {
return {
allowed: true,
matchKey: senderId,
matchSource: "allowlist",
};
}
}
if (account.allowedRoles && account.allowedRoles.length > 0) {
const allowedRoles = account.allowedRoles;
// "all" grants access to everyone
if (allowedRoles.includes("all")) {
return {
allowed: true,
matchKey: "all",
matchSource: "role",
};
}
const hasAllowedRole = checkSenderRoles({
message,
allowedRoles,
});
if (!hasAllowedRole) {
return {
allowed: false,
reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`,
};
}
return {
allowed: true,
matchKey: allowedRoles.join(","),
matchSource: "role",
};
}
return {
allowed: true,
};
}
/**
* Check if the sender has any of the allowed roles
*/
function checkSenderRoles(params: { message: TwitchChatMessage; allowedRoles: string[] }): boolean {
const { message, allowedRoles } = params;
const { isMod, isOwner, isVip, isSub } = message;
for (const role of allowedRoles) {
switch (role) {
case "moderator":
if (isMod) return true;
break;
case "owner":
if (isOwner) return true;
break;
case "vip":
if (isVip) return true;
break;
case "subscriber":
if (isSub) return true;
break;
}
}
return false;
}
/**
* Extract @mentions from a Twitch chat message
*
* Returns a list of lowercase usernames that were mentioned in the message.
* Twitch mentions are in the format @username.
*/
export function extractMentions(message: string): string[] {
const mentionRegex = /@(\w+)/g;
const mentions: string[] = [];
let match: RegExpExecArray | null;
// biome-ignore lint/suspicious/noAssignInExpressions: Standard regex iteration pattern
while ((match = mentionRegex.exec(message)) !== null) {
const username = match[1];
if (username) {
mentions.push(username.toLowerCase());
}
}
return mentions;
}

View File

@ -0,0 +1,173 @@
/**
* Twitch message actions adapter.
*
* Handles tool-based actions for Twitch, such as sending messages.
*/
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import { twitchOutbound } from "./outbound.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
/**
* Create a tool result with error content.
*/
function errorResponse(error: string) {
return {
content: [
{
type: "text",
text: JSON.stringify({ ok: false, error }),
},
],
details: { ok: false },
};
}
/**
* Read a string parameter from action arguments.
*
* @param args - Action arguments
* @param key - Parameter key
* @param options - Options for reading the parameter
* @returns The parameter value or undefined if not found
*/
function readStringParam(
args: Record<string, unknown>,
key: string,
options: { required?: boolean; trim?: boolean } = {},
): string | undefined {
const value = args[key];
if (value === undefined || value === null) {
if (options.required) {
throw new Error(`Missing required parameter: ${key}`);
}
return undefined;
}
// Convert value to string safely
if (typeof value === "string") {
return options.trim !== false ? value.trim() : value;
}
if (typeof value === "number" || typeof value === "boolean") {
const str = String(value);
return options.trim !== false ? str.trim() : str;
}
throw new Error(`Parameter ${key} must be a string, number, or boolean`);
}
/** Supported Twitch actions */
const TWITCH_ACTIONS = new Set(["send" as const]);
type TwitchAction = typeof TWITCH_ACTIONS extends Set<infer U> ? U : never;
/**
* Twitch message actions adapter.
*/
export const twitchMessageActions: ChannelMessageActionAdapter = {
/**
* List available actions for this channel.
*/
listActions: () => [...TWITCH_ACTIONS],
/**
* Check if an action is supported.
*/
supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction),
/**
* Extract tool send parameters from action arguments.
*
* Parses and validates the "to" and "message" parameters for sending.
*
* @param params - Arguments from the tool call
* @returns Parsed send parameters or null if invalid
*
* @example
* const result = twitchMessageActions.extractToolSend!({
* args: { to: "#mychannel", message: "Hello!" }
* });
* // Returns: { to: "#mychannel", message: "Hello!" }
*/
extractToolSend: ({ args }) => {
try {
const to = readStringParam(args, "to", { required: true });
const message = readStringParam(args, "message", { required: true });
if (!to || !message) {
return null;
}
return { to, message };
} catch {
return null;
}
},
/**
* Handle an action execution.
*
* Processes the "send" action to send messages to Twitch.
*
* @param ctx - Action context including action type, parameters, and config
* @returns Tool result with content or null if action not supported
*
* @example
* const result = await twitchMessageActions.handleAction!({
* action: "send",
* params: { message: "Hello Twitch!", to: "#mychannel" },
* cfg: clawdbotConfig,
* accountId: "default",
* });
*/
handleAction: async (
ctx: ChannelMessageActionContext,
): Promise<{ content: Array<{ type: string; text: string }> } | null> => {
if (ctx.action !== "send") {
return null;
}
const message = readStringParam(ctx.params, "message", { required: true });
const to = readStringParam(ctx.params, "to", { required: false });
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
const account = getAccountConfig(ctx.cfg, accountId);
if (!account) {
return errorResponse(
`Account not found: ${accountId}. Available accounts: ${Object.keys(ctx.cfg.channels?.twitch?.accounts ?? {}).join(", ") || "none"}`,
);
}
// Use the channel from account config (or override with `to` parameter)
const targetChannel = to || account.channel;
if (!targetChannel) {
return errorResponse("No channel specified and no default channel in account config");
}
if (!twitchOutbound.sendText) {
return errorResponse("sendText not implemented");
}
try {
const result = await twitchOutbound.sendText({
cfg: ctx.cfg,
to: targetChannel,
text: message ?? "",
accountId,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result),
},
],
details: { ok: true },
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return errorResponse(errorMsg);
}
},
};

View File

@ -0,0 +1,115 @@
/**
* Client manager registry for Twitch plugin.
*
* Manages the lifecycle of TwitchClientManager instances across the plugin,
* ensuring proper cleanup when accounts are stopped or reconfigured.
*/
import { TwitchClientManager } from "./twitch-client.js";
import type { ChannelLogSink } from "./types.js";
/**
* Registry entry tracking a client manager and its associated account.
*/
type RegistryEntry = {
/** The client manager instance */
manager: TwitchClientManager;
/** The account ID this manager is for */
accountId: string;
/** Logger for this entry */
logger: ChannelLogSink;
/** When this entry was created */
createdAt: number;
};
/**
* Global registry of client managers.
* Keyed by account ID.
*/
const registry = new Map<string, RegistryEntry>();
/**
* Get or create a client manager for an account.
*
* @param accountId - The account ID
* @param logger - Logger instance
* @returns The client manager
*/
export function getOrCreateClientManager(
accountId: string,
logger: ChannelLogSink,
): TwitchClientManager {
const existing = registry.get(accountId);
if (existing) {
return existing.manager;
}
const manager = new TwitchClientManager(logger);
registry.set(accountId, {
manager,
accountId,
logger,
createdAt: Date.now(),
});
logger.info(`Registered client manager for account: ${accountId}`);
return manager;
}
/**
* Get an existing client manager for an account.
*
* @param accountId - The account ID
* @returns The client manager, or undefined if not registered
*/
export function getClientManager(accountId: string): TwitchClientManager | undefined {
return registry.get(accountId)?.manager;
}
/**
* Disconnect and remove a client manager from the registry.
*
* @param accountId - The account ID
* @returns Promise that resolves when cleanup is complete
*/
export async function removeClientManager(accountId: string): Promise<void> {
const entry = registry.get(accountId);
if (!entry) {
return;
}
// Disconnect the client manager
await entry.manager.disconnectAll();
// Remove from registry
registry.delete(accountId);
entry.logger.info(`Unregistered client manager for account: ${accountId}`);
}
/**
* Disconnect and remove all client managers from the registry.
*
* @returns Promise that resolves when all cleanup is complete
*/
export async function removeAllClientManagers(): Promise<void> {
const promises = [...registry.keys()].map((accountId) => removeClientManager(accountId));
await Promise.all(promises);
}
/**
* Get the number of registered client managers.
*
* @returns The count of registered managers
*/
export function getRegisteredClientManagerCount(): number {
return registry.size;
}
/**
* Clear all client managers without disconnecting.
*
* This is primarily for testing purposes.
*/
export function _clearAllClientManagersForTest(): void {
registry.clear();
}

View File

@ -0,0 +1,82 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
/**
* Twitch user roles that can be allowed to interact with the bot
*/
const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all"]);
/**
* Twitch account configuration schema
*/
const TwitchAccountSchema = z.object({
/** Twitch username */
username: z.string(),
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
accessToken: z.string(),
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
clientId: z.string().optional(),
/** Channel name to join */
channel: z.string().min(1),
/** Enable this account */
enabled: z.boolean().optional(),
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
allowFrom: z.array(z.string()).optional(),
/** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
allowedRoles: z.array(TwitchRoleSchema).optional(),
/** Require @mention to trigger bot responses */
requireMention: z.boolean().optional(),
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
clientSecret: z.string().optional(),
/** Refresh token (required for automatic token refresh) */
refreshToken: z.string().optional(),
/** Token expiry time in seconds (optional, for token refresh tracking) */
expiresIn: z.number().nullable().optional(),
/** Timestamp when token was obtained (optional, for token refresh tracking) */
obtainmentTimestamp: z.number().optional(),
});
/**
* Base configuration properties shared by both single and multi-account modes
*/
const TwitchConfigBaseSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema.optional(),
});
/**
* Simplified single-account configuration schema
*
* Use this for single-account setups. Properties are at the top level,
* creating an implicit "default" account.
*/
const SimplifiedSchema = z.intersection(TwitchConfigBaseSchema, TwitchAccountSchema);
/**
* Multi-account configuration schema
*
* Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
*/
const MultiAccountSchema = z.intersection(
TwitchConfigBaseSchema,
z
.object({
/** Per-account configuration (for multi-account setups) */
accounts: z.record(z.string(), TwitchAccountSchema),
})
.refine((val) => Object.keys(val.accounts || {}).length > 0, {
message: "accounts must contain at least one entry",
}),
);
/**
* Twitch plugin configuration schema
*
* Supports two mutually exclusive patterns:
* 1. Simplified single-account: username, accessToken, clientId, channel at top level
* 2. Multi-account: accounts object with named account configs
*
* The union ensures clear discrimination between the two modes.
*/
export const TwitchConfigSchema = z.union([SimplifiedSchema, MultiAccountSchema]);

View File

@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { getAccountConfig } from "./config.js";
describe("getAccountConfig", () => {
const mockMultiAccountConfig = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
accessToken: "oauth:test123",
},
secondary: {
username: "secondbot",
accessToken: "oauth:secondary",
},
},
},
},
};
const mockSimplifiedConfig = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123",
},
},
};
it("returns account config for valid account ID (multi-account)", () => {
const result = getAccountConfig(mockMultiAccountConfig, "default");
expect(result).not.toBeNull();
expect(result?.username).toBe("testbot");
});
it("returns account config for default account (simplified config)", () => {
const result = getAccountConfig(mockSimplifiedConfig, "default");
expect(result).not.toBeNull();
expect(result?.username).toBe("testbot");
});
it("returns non-default account from multi-account config", () => {
const result = getAccountConfig(mockMultiAccountConfig, "secondary");
expect(result).not.toBeNull();
expect(result?.username).toBe("secondbot");
});
it("returns null for non-existent account ID", () => {
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
expect(result).toBeNull();
});
it("returns null when core config is null", () => {
const result = getAccountConfig(null, "default");
expect(result).toBeNull();
});
it("returns null when core config is undefined", () => {
const result = getAccountConfig(undefined, "default");
expect(result).toBeNull();
});
it("returns null when channels are not defined", () => {
const result = getAccountConfig({}, "default");
expect(result).toBeNull();
});
it("returns null when twitch is not defined", () => {
const result = getAccountConfig({ channels: {} }, "default");
expect(result).toBeNull();
});
it("returns null when accounts are not defined", () => {
const result = getAccountConfig({ channels: { twitch: {} } }, "default");
expect(result).toBeNull();
});
});

View File

@ -0,0 +1,116 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { TwitchAccountConfig } from "./types.js";
/**
* Default account ID for Twitch
*/
export const DEFAULT_ACCOUNT_ID = "default";
/**
* Get account config from core config
*
* Handles two patterns:
* 1. Simplified single-account: base-level properties create implicit "default" account
* 2. Multi-account: explicit accounts object
*
* For "default" account, base-level properties take precedence over accounts.default
* For other accounts, only the accounts object is checked
*/
export function getAccountConfig(
coreConfig: unknown,
accountId: string,
): TwitchAccountConfig | null {
if (!coreConfig || typeof coreConfig !== "object") {
return null;
}
const cfg = coreConfig as ClawdbotConfig;
const twitch = cfg.channels?.twitch;
// Access accounts via unknown to handle union type (single-account vs multi-account)
const twitchRaw = twitch as Record<string, unknown> | undefined;
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
// For default account, check base-level config first
if (accountId === DEFAULT_ACCOUNT_ID) {
const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID];
// Base-level properties that can form an implicit default account
const baseLevel = {
username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined,
accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined,
clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined,
channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined,
enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined,
allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined,
allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined,
requireMention:
typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined,
clientSecret:
typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined,
refreshToken:
typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined,
expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined,
obtainmentTimestamp:
typeof twitchRaw?.obtainmentTimestamp === "number"
? twitchRaw.obtainmentTimestamp
: undefined,
};
// Merge: base-level takes precedence over accounts.default
const merged: Partial<TwitchAccountConfig> = {
...accountFromAccounts,
...baseLevel,
} as Partial<TwitchAccountConfig>;
// Only return if we have at least username
if (merged.username) {
return merged as TwitchAccountConfig;
}
// Fall through to accounts.default if no base-level username
if (accountFromAccounts) {
return accountFromAccounts;
}
return null;
}
// For non-default accounts, only check accounts object
if (!accounts || !accounts[accountId]) {
return null;
}
return accounts[accountId] as TwitchAccountConfig | null;
}
/**
* List all configured account IDs
*
* Includes both explicit accounts and implicit "default" from base-level config
*/
export function listAccountIds(cfg: ClawdbotConfig): string[] {
const twitch = cfg.channels?.twitch;
// Access accounts via unknown to handle union type (single-account vs multi-account)
const twitchRaw = twitch as Record<string, unknown> | undefined;
const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
const ids: string[] = [];
// Add explicit accounts
if (accountMap) {
ids.push(...Object.keys(accountMap));
}
// Add implicit "default" if base-level config exists and "default" not already present
const hasBaseLevelConfig =
twitchRaw &&
(typeof twitchRaw.username === "string" ||
typeof twitchRaw.accessToken === "string" ||
typeof twitchRaw.channel === "string");
if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) {
ids.push(DEFAULT_ACCOUNT_ID);
}
return ids;
}

View File

@ -0,0 +1,257 @@
/**
* Twitch message monitor - processes incoming messages and routes to agents.
*
* This monitor connects to the Twitch client manager, processes incoming messages,
* resolves agent routes, and handles replies.
*/
import type { ReplyPayload, ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
import { checkTwitchAccessControl } from "./access-control.js";
import { getTwitchRuntime } from "./runtime.js";
import { getOrCreateClientManager } from "./client-manager-registry.js";
import { stripMarkdownForTwitch } from "./utils/markdown.js";
export type TwitchRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
export type TwitchMonitorOptions = {
account: TwitchAccountConfig;
accountId: string;
config: unknown; // ClawdbotConfig
runtime: TwitchRuntimeEnv;
abortSignal: AbortSignal;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export type TwitchMonitorResult = {
stop: () => void;
};
type TwitchCoreRuntime = ReturnType<typeof getTwitchRuntime>;
/**
* Process an incoming Twitch message and dispatch to agent.
*/
async function processTwitchMessage(params: {
message: TwitchChatMessage;
account: TwitchAccountConfig;
accountId: string;
config: unknown;
runtime: TwitchRuntimeEnv;
core: TwitchCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { message, account, accountId, config, runtime, core, statusSink } = params;
const cfg = config as ClawdbotConfig;
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "twitch",
accountId,
peer: {
kind: "group", // Twitch chat is always group-like
id: message.channel,
},
});
const rawBody = message.message;
const body = core.channel.reply.formatAgentEnvelope({
channel: "Twitch",
from: message.displayName ?? message.username,
timestamp: message.timestamp?.getTime(),
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
body: rawBody,
});
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: `twitch:user:${message.userId}`,
To: `twitch:channel:${message.channel}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: "group",
ConversationLabel: message.channel,
SenderName: message.displayName ?? message.username,
SenderId: message.userId,
SenderUsername: message.username,
Provider: "twitch",
Surface: "twitch",
MessageSid: message.id,
OriginatingChannel: "twitch",
OriginatingTo: `twitch:channel:${message.channel}`,
});
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`Failed updating session meta: ${String(err)}`);
},
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "twitch",
accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
deliver: async (payload) => {
await deliverTwitchReply({
payload,
channel: message.channel,
account,
accountId,
config,
tableMode,
runtime,
statusSink,
});
},
},
});
}
/**
* Deliver a reply to Twitch chat.
*/
async function deliverTwitchReply(params: {
payload: ReplyPayload;
channel: string;
account: TwitchAccountConfig;
accountId: string;
config: unknown;
tableMode: "off" | "plain" | "markdown" | "bullets" | "code";
runtime: TwitchRuntimeEnv;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { payload, channel, account, accountId, config, tableMode, runtime, statusSink } = params;
try {
const clientManager = getOrCreateClientManager(accountId, {
info: (msg) => runtime.log?.(msg),
warn: (msg) => runtime.log?.(msg),
error: (msg) => runtime.error?.(msg),
debug: (msg) => runtime.log?.(msg),
});
const client = await clientManager.getClient(
account,
config as Parameters<typeof clientManager.getClient>[1],
accountId,
);
if (!client) {
runtime.error?.(`No client available for sending reply`);
return;
}
// Send the reply
if (!payload.text) {
runtime.error?.(`No text to send in reply payload`);
return;
}
const textToSend = stripMarkdownForTwitch(payload.text);
await client.say(channel, textToSend);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Failed to send reply: ${String(err)}`);
}
}
/**
* Main monitor provider for Twitch.
*
* Sets up message handlers and processes incoming messages.
*/
export async function monitorTwitchProvider(
options: TwitchMonitorOptions,
): Promise<TwitchMonitorResult> {
const { account, accountId, config, runtime, abortSignal, statusSink } = options;
const core = getTwitchRuntime();
let stopped = false;
const coreLogger = core.logging.getChildLogger({ module: "twitch" });
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) return;
coreLogger.debug?.(message);
};
const logger = {
info: (msg: string) => coreLogger.info(msg),
warn: (msg: string) => coreLogger.warn(msg),
error: (msg: string) => coreLogger.error(msg),
debug: logVerboseMessage,
};
const clientManager = getOrCreateClientManager(accountId, logger);
try {
await clientManager.getClient(
account,
config as Parameters<typeof clientManager.getClient>[1],
accountId,
);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
runtime.error?.(`Failed to connect: ${errorMsg}`);
throw error;
}
const unregisterHandler = clientManager.onMessage(account, (message) => {
if (stopped) return;
// Access control check
const botUsername = account.username.toLowerCase();
if (message.username.toLowerCase() === botUsername) {
return; // Ignore own messages
}
const access = checkTwitchAccessControl({
message,
account,
botUsername,
});
if (!access.allowed) {
return;
}
statusSink?.({ lastInboundAt: Date.now() });
// Fire-and-forget: process message without blocking
void processTwitchMessage({
message,
account,
accountId,
config,
runtime,
core,
statusSink,
}).catch((err) => {
runtime.error?.(`Message processing failed: ${String(err)}`);
});
});
const stop = () => {
stopped = true;
unregisterHandler();
};
abortSignal.addEventListener("abort", stop, { once: true });
return { stop };
}

View File

@ -0,0 +1,311 @@
/**
* Tests for onboarding.ts helpers
*
* Tests cover:
* - promptToken helper
* - promptUsername helper
* - promptClientId helper
* - promptChannelName helper
* - promptRefreshTokenSetup helper
* - configureWithEnvToken helper
* - setTwitchAccount config updates
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WizardPrompter } from "clawdbot/plugin-sdk";
import type { TwitchAccountConfig } from "./types.js";
// Mock the helpers we're testing
const mockPromptText = vi.fn();
const mockPromptConfirm = vi.fn();
const mockPrompter: WizardPrompter = {
text: mockPromptText,
confirm: mockPromptConfirm,
} as unknown as WizardPrompter;
const mockAccount: TwitchAccountConfig = {
username: "testbot",
accessToken: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
describe("onboarding helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
// Don't restoreAllMocks as it breaks module-level mocks
});
describe("promptToken", () => {
it("should return existing token when user confirms to keep it", async () => {
const { promptToken } = await import("./onboarding.js");
mockPromptConfirm.mockResolvedValue(true);
const result = await promptToken(mockPrompter, mockAccount, undefined);
expect(result).toBe("oauth:test123");
expect(mockPromptConfirm).toHaveBeenCalledWith({
message: "Access token already configured. Keep it?",
initialValue: true,
});
expect(mockPromptText).not.toHaveBeenCalled();
});
it("should prompt for new token when user doesn't keep existing", async () => {
const { promptToken } = await import("./onboarding.js");
mockPromptConfirm.mockResolvedValue(false);
mockPromptText.mockResolvedValue("oauth:newtoken123");
const result = await promptToken(mockPrompter, mockAccount, undefined);
expect(result).toBe("oauth:newtoken123");
expect(mockPromptText).toHaveBeenCalledWith({
message: "Twitch OAuth token (oauth:...)",
initialValue: "",
validate: expect.any(Function),
});
});
it("should use env token as initial value when provided", async () => {
const { promptToken } = await import("./onboarding.js");
mockPromptConfirm.mockResolvedValue(false);
mockPromptText.mockResolvedValue("oauth:fromenv");
await promptToken(mockPrompter, null, "oauth:fromenv");
expect(mockPromptText).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: "oauth:fromenv",
}),
);
});
it("should validate token format", async () => {
const { promptToken } = await import("./onboarding.js");
// Set up mocks - user doesn't want to keep existing token
mockPromptConfirm.mockResolvedValueOnce(false);
// Track how many times promptText is called
let promptTextCallCount = 0;
let capturedValidate: ((value: string) => string | undefined) | undefined;
mockPromptText.mockImplementationOnce((_args) => {
promptTextCallCount++;
// Capture the validate function from the first argument
if (_args?.validate) {
capturedValidate = _args.validate;
}
return Promise.resolve("oauth:test123");
});
// Call promptToken
const result = await promptToken(mockPrompter, mockAccount, undefined);
// Verify promptText was called
expect(promptTextCallCount).toBe(1);
expect(result).toBe("oauth:test123");
// Test the validate function
expect(capturedValidate).toBeDefined();
expect(capturedValidate!("")).toBe("Required");
expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'");
});
it("should return early when no existing token and no env token", async () => {
const { promptToken } = await import("./onboarding.js");
mockPromptText.mockResolvedValue("oauth:newtoken");
const result = await promptToken(mockPrompter, null, undefined);
expect(result).toBe("oauth:newtoken");
expect(mockPromptConfirm).not.toHaveBeenCalled();
});
});
describe("promptUsername", () => {
it("should prompt for username with validation", async () => {
const { promptUsername } = await import("./onboarding.js");
mockPromptText.mockResolvedValue("mybot");
const result = await promptUsername(mockPrompter, null);
expect(result).toBe("mybot");
expect(mockPromptText).toHaveBeenCalledWith({
message: "Twitch bot username",
initialValue: "",
validate: expect.any(Function),
});
});
it("should use existing username as initial value", async () => {
const { promptUsername } = await import("./onboarding.js");
mockPromptText.mockResolvedValue("testbot");
await promptUsername(mockPrompter, mockAccount);
expect(mockPromptText).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: "testbot",
}),
);
});
});
describe("promptClientId", () => {
it("should prompt for client ID with validation", async () => {
const { promptClientId } = await import("./onboarding.js");
mockPromptText.mockResolvedValue("abc123xyz");
const result = await promptClientId(mockPrompter, null);
expect(result).toBe("abc123xyz");
expect(mockPromptText).toHaveBeenCalledWith({
message: "Twitch Client ID",
initialValue: "",
validate: expect.any(Function),
});
});
});
describe("promptChannelName", () => {
it("should return channel name when provided", async () => {
const { promptChannelName } = await import("./onboarding.js");
mockPromptText.mockResolvedValue("#mychannel");
const result = await promptChannelName(mockPrompter, null);
expect(result).toBe("#mychannel");
});
it("should require a non-empty channel name", async () => {
const { promptChannelName } = await import("./onboarding.js");
mockPromptText.mockResolvedValue("");
await promptChannelName(mockPrompter, null);
const { validate } = mockPromptText.mock.calls[0]?.[0] ?? {};
expect(validate?.("")).toBe("Required");
expect(validate?.(" ")).toBe("Required");
expect(validate?.("#chan")).toBeUndefined();
});
});
describe("promptRefreshTokenSetup", () => {
it("should return empty object when user declines", async () => {
const { promptRefreshTokenSetup } = await import("./onboarding.js");
mockPromptConfirm.mockResolvedValue(false);
const result = await promptRefreshTokenSetup(mockPrompter, mockAccount);
expect(result).toEqual({});
expect(mockPromptConfirm).toHaveBeenCalledWith({
message: "Enable automatic token refresh (requires client secret and refresh token)?",
initialValue: false,
});
});
it("should prompt for credentials when user accepts", async () => {
const { promptRefreshTokenSetup } = await import("./onboarding.js");
mockPromptConfirm
.mockResolvedValueOnce(true) // First call: useRefresh
.mockResolvedValueOnce("secret123") // clientSecret
.mockResolvedValueOnce("refresh123"); // refreshToken
mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123");
const result = await promptRefreshTokenSetup(mockPrompter, null);
expect(result).toEqual({
clientSecret: "secret123",
refreshToken: "refresh123",
});
});
it("should use existing values as initial prompts", async () => {
const { promptRefreshTokenSetup } = await import("./onboarding.js");
const accountWithRefresh = {
...mockAccount,
clientSecret: "existing-secret",
refreshToken: "existing-refresh",
};
mockPromptConfirm.mockResolvedValue(true);
mockPromptText
.mockResolvedValueOnce("existing-secret")
.mockResolvedValueOnce("existing-refresh");
await promptRefreshTokenSetup(mockPrompter, accountWithRefresh);
expect(mockPromptConfirm).toHaveBeenCalledWith(
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
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
const result = await configureWithEnvToken(
{} as Parameters<typeof configureWithEnvToken>[0],
mockPrompter,
null,
"oauth:fromenv",
false,
{} as Parameters<typeof configureWithEnvToken>[5],
);
// Since user declined, should return null without prompting for username/clientId
expect(result).toBeNull();
expect(mockPromptText).not.toHaveBeenCalled();
});
it("should prompt for username and clientId when using env token", async () => {
const { configureWithEnvToken } = await import("./onboarding.js");
// Reset and set up mocks - user accepts env token
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
// Set up mocks for username and clientId prompts
mockPromptText
.mockReset()
.mockResolvedValueOnce("testbot" as never)
.mockResolvedValueOnce("test-client-id" as never);
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");
});
});
});

View File

@ -0,0 +1,411 @@
/**
* Twitch onboarding adapter for CLI setup wizard.
*/
import {
formatDocsLink,
promptChannelAccessConfig,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
} from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import { isAccountConfigured } from "./utils/twitch.js";
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
const channel = "twitch" as const;
/**
* Set Twitch account configuration
*/
function setTwitchAccount(
cfg: ClawdbotConfig,
account: Partial<TwitchAccountConfig>,
): ClawdbotConfig {
const existing = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
const merged: TwitchAccountConfig = {
username: account.username ?? existing?.username ?? "",
accessToken: account.accessToken ?? existing?.accessToken ?? "",
clientId: account.clientId ?? existing?.clientId ?? "",
channel: account.channel ?? existing?.channel ?? "",
enabled: account.enabled ?? existing?.enabled ?? true,
allowFrom: account.allowFrom ?? existing?.allowFrom,
allowedRoles: account.allowedRoles ?? existing?.allowedRoles,
requireMention: account.requireMention ?? existing?.requireMention,
clientSecret: account.clientSecret ?? existing?.clientSecret,
refreshToken: account.refreshToken ?? existing?.refreshToken,
expiresIn: account.expiresIn ?? existing?.expiresIn,
obtainmentTimestamp: account.obtainmentTimestamp ?? existing?.obtainmentTimestamp,
};
return {
...cfg,
channels: {
...cfg.channels,
twitch: {
...((cfg.channels as Record<string, unknown>)?.twitch as
| Record<string, unknown>
| undefined),
enabled: true,
accounts: {
...((
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
)?.accounts as Record<string, unknown> | undefined),
[DEFAULT_ACCOUNT_ID]: merged,
},
},
},
};
}
/**
* Note about Twitch setup
*/
async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"Twitch requires a bot account with OAuth token.",
"1. Create a Twitch application at https://dev.twitch.tv/console",
"2. Generate a token with scopes: chat:read and chat:write",
" Use https://twitchtokengenerator.com/ or https://twitchapps.com/tmi/",
"3. Copy the token (starts with 'oauth:') and Client ID",
"Env vars supported: CLAWDBOT_TWITCH_ACCESS_TOKEN",
`Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`,
].join("\n"),
"Twitch setup",
);
}
/**
* Prompt for Twitch OAuth token with early returns.
*/
async function promptToken(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
envToken: string | undefined,
): Promise<string> {
const existingToken = account?.accessToken ?? "";
// If we have an existing token and no env var, ask if we should keep it
if (existingToken && !envToken) {
const keepToken = await prompter.confirm({
message: "Access token already configured. Keep it?",
initialValue: true,
});
if (keepToken) {
return existingToken;
}
}
// Prompt for new token
return String(
await prompter.text({
message: "Twitch OAuth token (oauth:...)",
initialValue: envToken ?? "",
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!raw.startsWith("oauth:")) {
return "Token should start with 'oauth:'";
}
return undefined;
},
}),
).trim();
}
/**
* Prompt for Twitch username.
*/
async function promptUsername(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<string> {
return String(
await prompter.text({
message: "Twitch bot username",
initialValue: account?.username ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
/**
* Prompt for Twitch Client ID.
*/
async function promptClientId(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<string> {
return String(
await prompter.text({
message: "Twitch Client ID",
initialValue: account?.clientId ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
/**
* Prompt for optional channel name.
*/
async function promptChannelName(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<string> {
const channelName = String(
await prompter.text({
message: "Channel to join",
initialValue: account?.channel ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return channelName;
}
/**
* Prompt for token refresh credentials (client secret and refresh token).
*/
async function promptRefreshTokenSetup(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<{ clientSecret?: string; refreshToken?: string }> {
const useRefresh = await prompter.confirm({
message: "Enable automatic token refresh (requires client secret and refresh token)?",
initialValue: Boolean(account?.clientSecret && account?.refreshToken),
});
if (!useRefresh) {
return {};
}
const clientSecret =
String(
await prompter.text({
message: "Twitch Client Secret (for token refresh)",
initialValue: account?.clientSecret ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim() || undefined;
const refreshToken =
String(
await prompter.text({
message: "Twitch Refresh Token",
initialValue: account?.refreshToken ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim() || undefined;
return { clientSecret, refreshToken };
}
/**
* Configure with env token path (returns early if user chooses env token).
*/
async function configureWithEnvToken(
cfg: ClawdbotConfig,
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
envToken: string,
forceAllowFrom: boolean,
dmPolicy: ChannelOnboardingDmPolicy,
): Promise<{ cfg: ClawdbotConfig } | null> {
const useEnv = await prompter.confirm({
message: "Twitch env var CLAWDBOT_TWITCH_ACCESS_TOKEN detected. Use env token?",
initialValue: true,
});
if (!useEnv) {
return null;
}
const username = await promptUsername(prompter, account);
const clientId = await promptClientId(prompter, account);
const cfgWithAccount = setTwitchAccount(cfg, {
username,
clientId,
accessToken: "", // Will use env var
enabled: true,
});
if (forceAllowFrom && dmPolicy.promptAllowFrom) {
return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) };
}
return { cfg: cfgWithAccount };
}
/**
* Set Twitch access control (role-based)
*/
function setTwitchAccessControl(
cfg: ClawdbotConfig,
allowedRoles: TwitchRole[],
requireMention: boolean,
): ClawdbotConfig {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
if (!account) {
return cfg;
}
return setTwitchAccount(cfg, {
...account,
allowedRoles,
requireMention,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Twitch",
channel,
policyKey: "channels.twitch.allowedRoles", // Twitch uses roles instead of DM policy
allowFromKey: "channels.twitch.accounts.default.allowFrom",
getCurrent: (cfg) => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
// Map allowedRoles to policy equivalent
if (account?.allowedRoles?.includes("all")) return "open";
if (account?.allowFrom && account.allowFrom.length > 0) return "allowlist";
return "disabled";
},
setPolicy: (cfg, policy) => {
const allowedRoles: TwitchRole[] =
policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
return setTwitchAccessControl(cfg as ClawdbotConfig, allowedRoles, true);
},
promptAllowFrom: async ({ cfg, prompter }) => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
const existingAllowFrom = account?.allowFrom ?? [];
const entry = await prompter.text({
message: "Twitch allowFrom (user IDs, one per line, recommended for security)",
placeholder: "123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
});
const allowFrom = String(entry ?? "")
.split(/[\n,;]+/g)
.map((s) => s.trim())
.filter(Boolean);
return setTwitchAccount(cfg as ClawdbotConfig, {
...(account ?? undefined),
allowFrom,
});
},
};
export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
const configured = account ? isAccountConfigured(account) : false;
return {
channel,
configured,
statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`],
selectionHint: configured ? "configured" : "needs setup",
};
},
configure: async ({ cfg, prompter, forceAllowFrom }) => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
if (!account || !isAccountConfigured(account)) {
await noteTwitchSetupHelp(prompter);
}
const envToken = process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN?.trim();
// Check if env var is set and config is empty
if (envToken && !account?.accessToken) {
const envResult = await configureWithEnvToken(
cfg,
prompter,
account,
envToken,
forceAllowFrom,
dmPolicy,
);
if (envResult) {
return envResult;
}
}
// Prompt for credentials
const username = await promptUsername(prompter, account);
const token = await promptToken(prompter, account, envToken);
const clientId = await promptClientId(prompter, account);
const channelName = await promptChannelName(prompter, account);
const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account);
const cfgWithAccount = setTwitchAccount(cfg, {
username,
accessToken: token,
clientId,
channel: channelName,
clientSecret,
refreshToken,
enabled: true,
});
const cfgWithAllowFrom =
forceAllowFrom && dmPolicy.promptAllowFrom
? await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter })
: cfgWithAccount;
// Prompt for access control if allowFrom not set
if (!account?.allowFrom || account.allowFrom.length === 0) {
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Twitch chat",
currentPolicy: account?.allowedRoles?.includes("all")
? "open"
: account?.allowedRoles?.includes("moderator")
? "allowlist"
: "disabled",
currentEntries: [],
placeholder: "",
updatePrompt: false,
});
if (accessConfig) {
const allowedRoles: TwitchRole[] =
accessConfig.policy === "open"
? ["all"]
: accessConfig.policy === "allowlist"
? ["moderator", "vip"]
: [];
const cfgWithAccessControl = setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true);
return { cfg: cfgWithAccessControl };
}
}
return { cfg: cfgWithAllowFrom };
},
dmPolicy,
disable: (cfg) => {
const twitch = (cfg.channels as Record<string, unknown>)?.twitch as
| Record<string, unknown>
| undefined;
return {
...cfg,
channels: {
...cfg.channels,
twitch: { ...twitch, enabled: false },
},
};
},
};
// Export helper functions for testing
export {
promptToken,
promptUsername,
promptClientId,
promptChannelName,
promptRefreshTokenSetup,
configureWithEnvToken,
};

View File

@ -0,0 +1,373 @@
/**
* Tests for outbound.ts module
*
* Tests cover:
* - resolveTarget with various modes (explicit, implicit, heartbeat)
* - sendText with markdown stripping
* - sendMedia delegation to sendText
* - Error handling for missing accounts/channels
* - Abort signal handling
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { twitchOutbound } from "./outbound.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
// Mock dependencies
vi.mock("./config.js", () => ({
DEFAULT_ACCOUNT_ID: "default",
getAccountConfig: vi.fn(),
}));
vi.mock("./send.js", () => ({
sendMessageTwitchInternal: vi.fn(),
}));
vi.mock("./utils/markdown.js", () => ({
chunkTextForTwitch: vi.fn((text) => text.split(/(.{500})/).filter(Boolean)),
}));
vi.mock("./utils/twitch.js", () => ({
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
missingTargetError: (channel: string, hint: string) =>
`Missing target for ${channel}. Provide ${hint}`,
}));
describe("outbound", () => {
const mockAccount = {
username: "testbot",
token: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
const mockConfig = {
channels: {
twitch: {
accounts: {
default: mockAccount,
},
},
},
} as unknown as ClawdbotConfig;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("metadata", () => {
it("should have direct delivery mode", () => {
expect(twitchOutbound.deliveryMode).toBe("direct");
});
it("should have 500 character text chunk limit", () => {
expect(twitchOutbound.textChunkLimit).toBe(500);
});
it("should have chunker function", () => {
expect(twitchOutbound.chunker).toBeDefined();
expect(typeof twitchOutbound.chunker).toBe("function");
});
});
describe("resolveTarget", () => {
it("should normalize and return target in explicit mode", () => {
const result = twitchOutbound.resolveTarget({
to: "#MyChannel",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("mychannel");
});
it("should return target in implicit mode with wildcard allowlist", () => {
const result = twitchOutbound.resolveTarget({
to: "#AnyChannel",
mode: "implicit",
allowFrom: ["*"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("anychannel");
});
it("should return target in implicit mode when in allowlist", () => {
const result = twitchOutbound.resolveTarget({
to: "#allowed",
mode: "implicit",
allowFrom: ["#allowed", "#other"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("allowed");
});
it("should fallback to first allowlist entry when target not in list", () => {
const result = twitchOutbound.resolveTarget({
to: "#notallowed",
mode: "implicit",
allowFrom: ["#primary", "#secondary"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("primary");
});
it("should accept any target when allowlist is empty", () => {
const result = twitchOutbound.resolveTarget({
to: "#anychannel",
mode: "heartbeat",
allowFrom: [],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("anychannel");
});
it("should use first allowlist entry when no target provided", () => {
const result = twitchOutbound.resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: ["#fallback", "#other"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("fallback");
});
it("should return error when no target and no allowlist", () => {
const result = twitchOutbound.resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toContain("Missing target");
});
it("should handle whitespace-only target", () => {
const result = twitchOutbound.resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toContain("Missing target");
});
it("should filter wildcard from allowlist when checking membership", () => {
const result = twitchOutbound.resolveTarget({
to: "#mychannel",
mode: "implicit",
allowFrom: ["*", "#specific"],
});
// With wildcard, any target is accepted
expect(result.ok).toBe(true);
expect(result.to).toBe("mychannel");
});
});
describe("sendText", () => {
it("should send message successfully", async () => {
const { getAccountConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "twitch-msg-123",
});
const result = await twitchOutbound.sendText({
cfg: mockConfig,
to: "#testchannel",
text: "Hello Twitch!",
accountId: "default",
});
expect(result.channel).toBe("twitch");
expect(result.messageId).toBe("twitch-msg-123");
expect(result.to).toBe("testchannel");
expect(result.timestamp).toBeGreaterThan(0);
});
it("should throw when account not found", async () => {
const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(null);
await expect(
twitchOutbound.sendText({
cfg: mockConfig,
to: "#testchannel",
text: "Hello!",
accountId: "nonexistent",
}),
).rejects.toThrow("Twitch account not found: nonexistent");
});
it("should throw when no channel specified", async () => {
const { getAccountConfig } = await import("./config.js");
const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
await expect(
twitchOutbound.sendText({
cfg: mockConfig,
to: undefined,
text: "Hello!",
accountId: "default",
}),
).rejects.toThrow("No channel specified");
});
it("should use account channel when target not provided", async () => {
const { getAccountConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "msg-456",
});
await twitchOutbound.sendText({
cfg: mockConfig,
to: undefined,
text: "Hello!",
accountId: "default",
});
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
"testchannel",
"Hello!",
mockConfig,
"default",
true,
console,
);
});
it("should handle abort signal", async () => {
const abortController = new AbortController();
abortController.abort();
await expect(
twitchOutbound.sendText({
cfg: mockConfig,
to: "#testchannel",
text: "Hello!",
accountId: "default",
signal: abortController.signal,
}),
).rejects.toThrow("Outbound delivery aborted");
});
it("should throw on send failure", async () => {
const { getAccountConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: false,
messageId: "failed-msg",
error: "Connection lost",
});
await expect(
twitchOutbound.sendText({
cfg: mockConfig,
to: "#testchannel",
text: "Hello!",
accountId: "default",
}),
).rejects.toThrow("Connection lost");
});
});
describe("sendMedia", () => {
it("should combine text and media URL", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "media-msg-123",
});
const result = await twitchOutbound.sendMedia({
cfg: mockConfig,
to: "#testchannel",
text: "Check this:",
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
expect(result.channel).toBe("twitch");
expect(result.messageId).toBe("media-msg-123");
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
expect.anything(),
"Check this: https://example.com/image.png",
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
);
});
it("should send media URL only when no text", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "media-only-msg",
});
await twitchOutbound.sendMedia({
cfg: mockConfig,
to: "#testchannel",
text: undefined,
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
expect.anything(),
"https://example.com/image.png",
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
);
});
it("should handle abort signal", async () => {
const abortController = new AbortController();
abortController.abort();
await expect(
twitchOutbound.sendMedia({
cfg: mockConfig,
to: "#testchannel",
text: "Check this:",
mediaUrl: "https://example.com/image.png",
accountId: "default",
signal: abortController.signal,
}),
).rejects.toThrow("Outbound delivery aborted");
});
});
});

View File

@ -0,0 +1,186 @@
/**
* Twitch outbound adapter for sending messages.
*
* Implements the ChannelOutboundAdapter interface for Twitch chat.
* Supports text and media (URL) sending with markdown stripping and chunking.
*/
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import { sendMessageTwitchInternal } from "./send.js";
import type {
ChannelOutboundAdapter,
ChannelOutboundContext,
OutboundDeliveryResult,
} from "./types.js";
import { chunkTextForTwitch } from "./utils/markdown.js";
import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
/**
* Twitch outbound adapter.
*
* Handles sending text and media to Twitch channels with automatic
* markdown stripping and message chunking.
*/
export const twitchOutbound: ChannelOutboundAdapter = {
/** Direct delivery mode - messages are sent immediately */
deliveryMode: "direct",
/** Twitch chat message limit is 500 characters */
textChunkLimit: 500,
/** Word-boundary chunker with markdown stripping */
chunker: chunkTextForTwitch,
/**
* Resolve target from context.
*
* Handles target resolution with allowlist support for implicit/heartbeat modes.
* For explicit mode, accepts any valid channel name.
*
* @param params - Resolution parameters
* @returns Resolved target or error
*/
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
const allowListRaw = (allowFrom ?? [])
.map((entry: unknown) => String(entry).trim())
.filter(Boolean);
const hasWildcard = allowListRaw.includes("*");
const allowList = allowListRaw
.filter((entry: string) => entry !== "*")
.map((entry: string) => normalizeTwitchChannel(entry))
.filter((entry): entry is string => entry.length > 0);
// If target is provided, normalize and validate it
if (trimmed) {
const normalizedTo = normalizeTwitchChannel(trimmed);
// For implicit/heartbeat modes with allowList, check against allowlist
if (mode === "implicit" || mode === "heartbeat") {
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
// Fallback to first allowFrom entry
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
return { ok: true, to: allowList[0]! };
}
// For explicit mode, accept any valid channel name
return { ok: true, to: normalizedTo };
}
// No target provided, use allowFrom fallback
if (allowList.length > 0) {
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
return { ok: true, to: allowList[0]! };
}
// No target and no allowFrom - error
return {
ok: false,
error: missingTargetError(
"Twitch",
"<channel-name> or channels.twitch.accounts.<account>.allowFrom[0]",
),
};
},
/**
* Send a text message to a Twitch channel.
*
* Strips markdown if enabled, validates account configuration,
* and sends the message via the Twitch client.
*
* @param params - Send parameters including target, text, and config
* @returns Delivery result with message ID and status
*
* @example
* const result = await twitchOutbound.sendText({
* cfg: clawdbotConfig,
* to: "#mychannel",
* text: "Hello Twitch!",
* accountId: "default",
* });
*/
sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
const { cfg, to, text, accountId, signal } = params;
if (signal?.aborted) {
throw new Error("Outbound delivery aborted");
}
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
throw new Error(
`Twitch account not found: ${resolvedAccountId}. ` +
`Available accounts: ${availableIds.join(", ") || "none"}`,
);
}
const channel = to || account.channel;
if (!channel) {
throw new Error("No channel specified and no default channel in account config");
}
const result = await sendMessageTwitchInternal(
normalizeTwitchChannel(channel),
text,
cfg,
resolvedAccountId,
true, // stripMarkdown
console,
);
if (!result.ok) {
throw new Error(result.error ?? "Send failed");
}
return {
channel: "twitch",
messageId: result.messageId,
timestamp: Date.now(),
to: normalizeTwitchChannel(channel),
};
},
/**
* Send media to a Twitch channel.
*
* Note: Twitch chat doesn't support direct media uploads.
* This sends the media URL as text instead.
*
* @param params - Send parameters including media URL
* @returns Delivery result with message ID and status
*
* @example
* const result = await twitchOutbound.sendMedia({
* cfg: clawdbotConfig,
* to: "#mychannel",
* text: "Check this out!",
* mediaUrl: "https://example.com/image.png",
* accountId: "default",
* });
*/
sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
const { text, mediaUrl, signal } = params;
if (signal?.aborted) {
throw new Error("Outbound delivery aborted");
}
const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
if (!twitchOutbound.sendText) {
throw new Error("sendText not implemented");
}
return twitchOutbound.sendText({
...params,
text: message,
});
},
};

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { twitchPlugin } from "./plugin.js";
describe("twitchPlugin.status.buildAccountSnapshot", () => {
it("uses the resolved account ID for multi-account configs", async () => {
const secondary = {
channel: "secondary-channel",
username: "secondary",
accessToken: "oauth:secondary-token",
clientId: "secondary-client",
enabled: true,
};
const cfg = {
channels: {
twitch: {
accounts: {
default: {
channel: "default-channel",
username: "default",
accessToken: "oauth:default-token",
clientId: "default-client",
enabled: true,
},
secondary,
},
},
},
} as ClawdbotConfig;
const snapshot = await twitchPlugin.status?.buildAccountSnapshot?.({
account: secondary,
cfg,
});
expect(snapshot?.accountId).toBe("secondary");
});
});

View File

@ -0,0 +1,274 @@
/**
* Twitch channel plugin for Clawdbot.
*
* Main plugin export combining all adapters (outbound, actions, status, gateway).
* This is the primary entry point for the Twitch channel integration.
*/
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
import { twitchMessageActions } from "./actions.js";
import { TwitchConfigSchema } from "./config-schema.js";
import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js";
import { twitchOnboardingAdapter } from "./onboarding.js";
import { twitchOutbound } from "./outbound.js";
import { probeTwitch } from "./probe.js";
import { resolveTwitchTargets } from "./resolver.js";
import { collectTwitchStatusIssues } from "./status.js";
import { removeClientManager } from "./client-manager-registry.js";
import { resolveTwitchToken } from "./token.js";
import { isAccountConfigured } from "./utils/twitch.js";
import type {
ChannelAccountSnapshot,
ChannelCapabilities,
ChannelLogSink,
ChannelMeta,
ChannelPlugin,
ChannelResolveKind,
ChannelResolveResult,
TwitchAccountConfig,
} from "./types.js";
/**
* Twitch channel plugin.
*
* Implements the ChannelPlugin interface to provide Twitch chat integration
* for Clawdbot. Supports message sending, receiving, access control, and
* status monitoring.
*/
export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
/** Plugin identifier */
id: "twitch",
/** Plugin metadata */
meta: {
id: "twitch",
label: "Twitch",
selectionLabel: "Twitch (Chat)",
docsPath: "/channels/twitch",
blurb: "Twitch chat integration",
aliases: ["twitch-chat"],
} satisfies ChannelMeta,
/** Onboarding adapter */
onboarding: twitchOnboardingAdapter,
/** Pairing configuration */
pairing: {
idLabel: "twitchUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(twitch:)?user:?/i, ""),
notifyApproval: async ({ id }) => {
// Note: Twitch doesn't support DMs from bots, so pairing approval is limited
// We'll log the approval instead
console.warn(`Pairing approved for user ${id} (notification sent via chat if possible)`);
},
},
/** Supported chat capabilities */
capabilities: {
chatTypes: ["group"],
} satisfies ChannelCapabilities,
/** Configuration schema for Twitch channel */
configSchema: buildChannelConfigSchema(TwitchConfigSchema),
/** Account configuration management */
config: {
/** List all configured account IDs */
listAccountIds: (cfg: ClawdbotConfig): string[] => listAccountIds(cfg),
/** Resolve an account config by ID */
resolveAccount: (cfg: ClawdbotConfig, accountId?: string | null): TwitchAccountConfig => {
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
if (!account) {
// Return a default/empty account if not configured
return {
username: "",
accessToken: "",
clientId: "",
enabled: false,
} as TwitchAccountConfig;
}
return account;
},
/** Get the default account ID */
defaultAccountId: (): string => DEFAULT_ACCOUNT_ID,
/** Check if an account is configured */
isConfigured: (_account: unknown, cfg: ClawdbotConfig): boolean => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
const tokenResolution = resolveTwitchToken(cfg, { accountId: DEFAULT_ACCOUNT_ID });
return account ? isAccountConfigured(account, tokenResolution.token) : false;
},
/** Check if an account is enabled */
isEnabled: (account: TwitchAccountConfig | undefined): boolean => account?.enabled !== false,
/** Describe account status */
describeAccount: (account: TwitchAccountConfig | undefined) => {
return {
accountId: DEFAULT_ACCOUNT_ID,
enabled: account?.enabled !== false,
configured: account ? isAccountConfigured(account, account?.accessToken) : false,
};
},
},
/** Outbound message adapter */
outbound: twitchOutbound,
/** Message actions adapter */
actions: twitchMessageActions,
/** Resolver adapter for username -> user ID resolution */
resolver: {
resolveTargets: async ({
cfg,
accountId,
inputs,
kind,
runtime,
}: {
cfg: ClawdbotConfig;
accountId?: string | null;
inputs: string[];
kind: ChannelResolveKind;
runtime: import("../../../src/runtime.js").RuntimeEnv;
}): Promise<ChannelResolveResult[]> => {
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
if (!account) {
return inputs.map((input) => ({
input,
resolved: false,
note: "account not configured",
}));
}
// Adapt RuntimeEnv.log to ChannelLogSink
const log: ChannelLogSink = {
info: (msg) => runtime.log(msg),
warn: (msg) => runtime.log(msg),
error: (msg) => runtime.error(msg),
debug: (msg) => runtime.log(msg),
};
return await resolveTwitchTargets(inputs, account, kind, log);
},
},
/** Status monitoring adapter */
status: {
/** Default runtime state */
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
/** Build channel summary from snapshot */
buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
/** Probe account connection */
probeAccount: async ({
account,
timeoutMs,
}: {
account: TwitchAccountConfig;
timeoutMs: number;
}): Promise<unknown> => {
return await probeTwitch(account, timeoutMs);
},
/** Build account snapshot with current status */
buildAccountSnapshot: ({
account,
cfg,
runtime,
probe,
}: {
account: TwitchAccountConfig;
cfg: ClawdbotConfig;
runtime?: ChannelAccountSnapshot;
probe?: unknown;
}): ChannelAccountSnapshot => {
const twitch = (cfg as Record<string, unknown>).channels as
| Record<string, unknown>
| undefined;
const twitchCfg = twitch?.twitch as Record<string, unknown> | undefined;
const accountMap = (twitchCfg?.accounts as Record<string, unknown> | undefined) ?? {};
const resolvedAccountId =
Object.entries(accountMap).find(([, value]) => value === account)?.[0] ??
DEFAULT_ACCOUNT_ID;
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
return {
accountId: resolvedAccountId,
enabled: account?.enabled !== false,
configured: isAccountConfigured(account, tokenResolution.token),
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
};
},
/** Collect status issues for all accounts */
collectStatusIssues: collectTwitchStatusIssues,
},
/** Gateway adapter for connection lifecycle */
gateway: {
/** Start an account connection */
startAccount: async (ctx): Promise<void> => {
const account = ctx.account as TwitchAccountConfig;
const accountId = ctx.accountId;
ctx.setStatus?.({
accountId,
running: true,
lastStartAt: Date.now(),
lastError: null,
});
ctx.log?.info(`Starting Twitch connection for ${account.username}`);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorTwitchProvider } = await import("./monitor.js");
await monitorTwitchProvider({
account,
accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
/** Stop an account connection */
stopAccount: async (ctx): Promise<void> => {
const account = ctx.account as TwitchAccountConfig;
const accountId = ctx.accountId;
// Disconnect and remove client manager from registry
await removeClientManager(accountId);
ctx.setStatus?.({
accountId,
running: false,
lastStopAt: Date.now(),
});
ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
},
},
};

View File

@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { probeTwitch } from "./probe.js";
import type { TwitchAccountConfig } from "./types.js";
// Mock Twurple modules - Vitest v4 compatible mocking
const mockUnbind = vi.fn();
// Event handler storage
let connectHandler: (() => void) | null = null;
let disconnectHandler: ((manually: boolean, reason?: Error) => void) | null = null;
let authFailHandler: (() => void) | null = null;
// Event listener mocks that store handlers and return unbind function
const mockOnConnect = vi.fn((handler: () => void) => {
connectHandler = handler;
return { unbind: mockUnbind };
});
const mockOnDisconnect = vi.fn((handler: (manually: boolean, reason?: Error) => void) => {
disconnectHandler = handler;
return { unbind: mockUnbind };
});
const mockOnAuthenticationFailure = vi.fn((handler: () => void) => {
authFailHandler = handler;
return { unbind: mockUnbind };
});
// Connect mock that triggers the registered handler
const defaultConnectImpl = async () => {
// Simulate successful connection by calling the handler after a delay
if (connectHandler) {
await new Promise((resolve) => setTimeout(resolve, 1));
connectHandler();
}
};
const mockConnect = vi.fn().mockImplementation(defaultConnectImpl);
const mockQuit = vi.fn().mockResolvedValue(undefined);
vi.mock("@twurple/chat", () => ({
ChatClient: class {
connect = mockConnect;
quit = mockQuit;
onConnect = mockOnConnect;
onDisconnect = mockOnDisconnect;
onAuthenticationFailure = mockOnAuthenticationFailure;
},
}));
vi.mock("@twurple/auth", () => ({
StaticAuthProvider: class {},
}));
describe("probeTwitch", () => {
const mockAccount: TwitchAccountConfig = {
username: "testbot",
token: "oauth:test123456789",
channel: "testchannel",
};
beforeEach(() => {
vi.clearAllMocks();
// Reset handlers
connectHandler = null;
disconnectHandler = null;
authFailHandler = null;
});
it("returns error when username is missing", async () => {
const account = { ...mockAccount, username: "" };
const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(false);
expect(result.error).toContain("missing credentials");
});
it("returns error when token is missing", async () => {
const account = { ...mockAccount, token: "" };
const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(false);
expect(result.error).toContain("missing credentials");
});
it("attempts connection regardless of token prefix", async () => {
// 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
const account = { ...mockAccount, token: "raw_token_no_prefix" };
const result = await probeTwitch(account, 5000);
// With mock, connection succeeds even without oauth: prefix
expect(result.ok).toBe(true);
});
it("successfully connects with valid credentials", async () => {
const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(true);
expect(result.connected).toBe(true);
expect(result.username).toBe("testbot");
expect(result.channel).toBe("testchannel"); // uses account's configured channel
});
it("uses custom channel when specified", async () => {
const account: TwitchAccountConfig = {
...mockAccount,
channel: "customchannel",
};
const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(true);
expect(result.channel).toBe("customchannel");
});
it("times out when connection takes too long", async () => {
mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
const result = await probeTwitch(mockAccount, 100);
expect(result.ok).toBe(false);
expect(result.error).toContain("timeout");
// Reset mock
mockConnect.mockImplementation(defaultConnectImpl);
});
it("cleans up client even on failure", async () => {
mockConnect.mockImplementationOnce(async () => {
// Simulate connection failure by calling disconnect handler
// onDisconnect signature: (manually: boolean, reason?: Error) => void
if (disconnectHandler) {
await new Promise((resolve) => setTimeout(resolve, 1));
disconnectHandler(false, new Error("Connection failed"));
}
});
const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(false);
expect(result.error).toContain("Connection failed");
expect(mockQuit).toHaveBeenCalled();
// Reset mocks
mockConnect.mockImplementation(defaultConnectImpl);
});
it("handles connection errors gracefully", async () => {
mockConnect.mockImplementationOnce(async () => {
// Simulate connection failure by calling disconnect handler
// onDisconnect signature: (manually: boolean, reason?: Error) => void
if (disconnectHandler) {
await new Promise((resolve) => setTimeout(resolve, 1));
disconnectHandler(false, new Error("Network error"));
}
});
const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(false);
expect(result.error).toContain("Network error");
// Reset mock
mockConnect.mockImplementation(defaultConnectImpl);
});
it("trims token before validation", async () => {
const account: TwitchAccountConfig = {
...mockAccount,
token: " oauth:test123456789 ",
};
const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(true);
});
it("handles non-Error objects in catch block", async () => {
mockConnect.mockImplementationOnce(async () => {
// Simulate connection failure by calling disconnect handler
// onDisconnect signature: (manually: boolean, reason?: Error) => void
if (disconnectHandler) {
await new Promise((resolve) => setTimeout(resolve, 1));
disconnectHandler(false, "String error" as unknown as Error);
}
});
const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(false);
expect(result.error).toBe("String error");
// Reset mock
mockConnect.mockImplementation(defaultConnectImpl);
});
});

View File

@ -0,0 +1,118 @@
import { StaticAuthProvider } from "@twurple/auth";
import { ChatClient } from "@twurple/chat";
import type { TwitchAccountConfig } from "./types.js";
import { normalizeToken } from "./utils/twitch.js";
/**
* Result of probing a Twitch account
*/
export type ProbeTwitchResult = {
ok: boolean;
error?: string;
username?: string;
elapsedMs: number;
connected?: boolean;
channel?: string;
};
/**
* Probe a Twitch account to verify the connection is working
*
* This tests the Twitch OAuth token by attempting to connect
* to the chat server and verify the bot's username.
*/
export async function probeTwitch(
account: TwitchAccountConfig,
timeoutMs: number,
): Promise<ProbeTwitchResult> {
const started = Date.now();
if (!account.token || !account.username) {
return {
ok: false,
error: "missing credentials (token, username)",
username: account.username,
elapsedMs: Date.now() - started,
};
}
const rawToken = normalizeToken(account.token.trim());
let client: ChatClient | undefined;
try {
const authProvider = new StaticAuthProvider(account.clientId ?? "", rawToken);
client = new ChatClient({
authProvider,
});
// Create a promise that resolves when connected
const connectionPromise = new Promise<void>((resolve, reject) => {
let settled = false;
let connectListener: ReturnType<ChatClient["onConnect"]> | undefined;
let disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined;
let authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined;
const cleanup = () => {
if (settled) return;
settled = true;
connectListener?.unbind();
disconnectListener?.unbind();
authFailListener?.unbind();
};
// Success: connection established
connectListener = client?.onConnect(() => {
cleanup();
resolve();
});
// Failure: disconnected (e.g., auth failed)
disconnectListener = client?.onDisconnect((_manually, reason) => {
cleanup();
reject(reason || new Error("Disconnected"));
});
// Failure: authentication failed
authFailListener = client?.onAuthenticationFailure(() => {
cleanup();
reject(new Error("Authentication failed"));
});
});
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
});
client.connect();
await Promise.race([connectionPromise, timeout]);
client.quit();
client = undefined;
return {
ok: true,
connected: true,
username: account.username,
channel: account.channel,
elapsedMs: Date.now() - started,
};
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
username: account.username,
channel: account.channel,
elapsedMs: Date.now() - started,
};
} finally {
if (client) {
try {
client.quit();
} catch {
// Ignore cleanup errors
}
}
}
}

View File

@ -0,0 +1,137 @@
/**
* Twitch resolver adapter for channel/user name resolution.
*
* This module implements the ChannelResolverAdapter interface to resolve
* Twitch usernames to user IDs via the Twitch Helix API.
*/
import { ApiClient } from "@twurple/api";
import { StaticAuthProvider } from "@twurple/auth";
import type { ChannelResolveKind, ChannelResolveResult } from "./types.js";
import type { ChannelLogSink, TwitchAccountConfig } from "./types.js";
import { normalizeToken } from "./utils/twitch.js";
/**
* Normalize a Twitch username - strip @ prefix and convert to lowercase
*/
function normalizeUsername(input: string): string {
const trimmed = input.trim();
if (trimmed.startsWith("@")) {
return trimmed.slice(1).toLowerCase();
}
return trimmed.toLowerCase();
}
/**
* Create a logger that includes the Twitch prefix
*/
function createLogger(logger?: ChannelLogSink): ChannelLogSink {
return {
info: (msg: string) => logger?.info(msg),
warn: (msg: string) => logger?.warn(msg),
error: (msg: string) => logger?.error(msg),
debug: (msg: string) => logger?.debug?.(msg) ?? (() => {}),
};
}
/**
* Resolve Twitch usernames to user IDs via the Helix API
*
* @param inputs - Array of usernames or user IDs to resolve
* @param account - Twitch account configuration with auth credentials
* @param kind - Type of target to resolve ("user" or "group")
* @param logger - Optional logger
* @returns Promise resolving to array of ChannelResolveResult
*/
export async function resolveTwitchTargets(
inputs: string[],
account: TwitchAccountConfig,
kind: ChannelResolveKind,
logger?: ChannelLogSink,
): Promise<ChannelResolveResult[]> {
const log = createLogger(logger);
if (!account.clientId || !account.token) {
log.error("Missing Twitch client ID or token");
return inputs.map((input) => ({
input,
resolved: false,
note: "missing Twitch credentials",
}));
}
const normalizedToken = normalizeToken(account.token);
const authProvider = new StaticAuthProvider(account.clientId, normalizedToken);
const apiClient = new ApiClient({ authProvider });
const results: ChannelResolveResult[] = [];
for (const input of inputs) {
const normalized = normalizeUsername(input);
if (!normalized) {
results.push({
input,
resolved: false,
note: "empty input",
});
continue;
}
const looksLikeUserId = /^\d+$/.test(normalized);
try {
if (looksLikeUserId) {
const user = await apiClient.users.getUserById(normalized);
if (user) {
results.push({
input,
resolved: true,
id: user.id,
name: user.name,
});
log.debug?.(`Resolved user ID ${normalized} -> ${user.name}`);
} else {
results.push({
input,
resolved: false,
note: "user ID not found",
});
log.warn(`User ID ${normalized} not found`);
}
} else {
const user = await apiClient.users.getUserByName(normalized);
if (user) {
results.push({
input,
resolved: true,
id: user.id,
name: user.name,
note: user.displayName !== user.name ? `display: ${user.displayName}` : undefined,
});
log.debug?.(`Resolved username ${normalized} -> ${user.id} (${user.name})`);
} else {
results.push({
input,
resolved: false,
note: "username not found",
});
log.warn(`Username ${normalized} not found`);
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
results.push({
input,
resolved: false,
note: `API error: ${errorMessage}`,
});
log.error(`Failed to resolve ${input}: ${errorMessage}`);
}
}
return results;
}

View File

@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setTwitchRuntime(next: PluginRuntime) {
runtime = next;
}
export function getTwitchRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Twitch runtime not initialized");
}
return runtime;
}

View File

@ -0,0 +1,289 @@
/**
* Tests for send.ts module
*
* Tests cover:
* - Message sending with valid configuration
* - Account resolution and validation
* - Channel normalization
* - Markdown stripping
* - Error handling for missing/invalid accounts
* - Registry integration
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sendMessageTwitchInternal } from "./send.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
// Mock dependencies
vi.mock("./config.js", () => ({
DEFAULT_ACCOUNT_ID: "default",
getAccountConfig: vi.fn(),
}));
vi.mock("./utils/twitch.js", () => ({
generateMessageId: vi.fn(() => "test-msg-id"),
isAccountConfigured: vi.fn(() => true),
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
}));
vi.mock("./utils/markdown.js", () => ({
stripMarkdownForTwitch: vi.fn((text: string) => text.replace(/\*\*/g, "")),
}));
vi.mock("./client-manager-registry.js", () => ({
getClientManager: vi.fn(),
}));
describe("send", () => {
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const mockAccount = {
username: "testbot",
token: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
const mockConfig = {
channels: {
twitch: {
accounts: {
default: mockAccount,
},
},
},
} as unknown as ClawdbotConfig;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("sendMessageTwitchInternal", () => {
it("should send a message successfully", async () => {
const { getAccountConfig } = await import("./config.js");
const { getClientManager } = await import("./client-manager-registry.js");
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(getClientManager).mockReturnValue({
sendMessage: vi.fn().mockResolvedValue({
ok: true,
messageId: "twitch-msg-123",
}),
} as ReturnType<typeof getClientManager>);
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text);
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello Twitch!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(true);
expect(result.messageId).toBe("twitch-msg-123");
});
it("should strip markdown when enabled", async () => {
const { getAccountConfig } = await import("./config.js");
const { getClientManager } = await import("./client-manager-registry.js");
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(getClientManager).mockReturnValue({
sendMessage: vi.fn().mockResolvedValue({
ok: true,
messageId: "twitch-msg-456",
}),
} as ReturnType<typeof getClientManager>);
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, ""));
await sendMessageTwitchInternal(
"#testchannel",
"**Bold** text",
mockConfig,
"default",
true,
mockLogger as unknown as Console,
);
expect(stripMarkdownForTwitch).toHaveBeenCalledWith("**Bold** text");
});
it("should return error when account not found", async () => {
const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(null);
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello!",
mockConfig,
"nonexistent",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("Account not found: nonexistent");
});
it("should return error when account not configured", async () => {
const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(false);
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("not properly configured");
});
it("should return error when no channel specified", async () => {
const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js");
// Set channel to undefined to trigger the error (bypassing type check)
const accountWithoutChannel = {
...mockAccount,
channel: undefined as unknown as string,
};
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
vi.mocked(isAccountConfigured).mockReturnValue(true);
const result = await sendMessageTwitchInternal(
"",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("No channel specified");
});
it("should skip sending empty message after markdown stripping", async () => {
const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js");
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(true);
vi.mocked(stripMarkdownForTwitch).mockReturnValue("");
const result = await sendMessageTwitchInternal(
"#testchannel",
"**Only markdown**",
mockConfig,
"default",
true,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(true);
expect(result.messageId).toBe("skipped");
});
it("should return error when client manager not found", async () => {
const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js");
const { getClientManager } = await import("./client-manager-registry.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(true);
vi.mocked(getClientManager).mockReturnValue(undefined);
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("Client manager not found");
});
it("should handle send errors gracefully", async () => {
const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js");
const { getClientManager } = await import("./client-manager-registry.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(true);
vi.mocked(getClientManager).mockReturnValue({
sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")),
} as ReturnType<typeof getClientManager>);
const result = await sendMessageTwitchInternal(
"#testchannel",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(result.ok).toBe(false);
expect(result.error).toBe("Connection lost");
expect(mockLogger.error).toHaveBeenCalled();
});
it("should use account channel when channel parameter is empty", async () => {
const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js");
const { getClientManager } = await import("./client-manager-registry.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(true);
const mockSend = vi.fn().mockResolvedValue({
ok: true,
messageId: "twitch-msg-789",
});
vi.mocked(getClientManager).mockReturnValue({
sendMessage: mockSend,
} as ReturnType<typeof getClientManager>);
await sendMessageTwitchInternal(
"",
"Hello!",
mockConfig,
"default",
false,
mockLogger as unknown as Console,
);
expect(mockSend).toHaveBeenCalledWith(
mockAccount,
"testchannel", // normalized account channel
"Hello!",
mockConfig,
"default",
);
});
});
});

View File

@ -0,0 +1,136 @@
/**
* Twitch message sending functions with dependency injection support.
*
* These functions are the primary interface for sending messages to Twitch.
* They support dependency injection via the `deps` parameter for testability.
*/
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveTwitchToken } from "./token.js";
import { stripMarkdownForTwitch } from "./utils/markdown.js";
import { generateMessageId, isAccountConfigured, normalizeTwitchChannel } from "./utils/twitch.js";
/**
* Result from sending a message to Twitch.
*/
export interface SendMessageResult {
/** Whether the send was successful */
ok: boolean;
/** The message ID (generated for tracking) */
messageId: string;
/** Error message if the send failed */
error?: string;
}
/**
* Internal send function used by the outbound adapter.
*
* This function has access to the full Clawdbot config and handles
* account resolution, markdown stripping, and actual message sending.
*
* @param channel - The channel name
* @param text - The message text
* @param cfg - Full Clawdbot configuration
* @param accountId - Account ID to use
* @param stripMarkdown - Whether to strip markdown (default: true)
* @param logger - Logger instance
* @returns Result with message ID and status
*
* @example
* const result = await sendMessageTwitchInternal(
* "#mychannel",
* "Hello Twitch!",
* clawdbotConfig,
* "default",
* true,
* console,
* );
*/
export async function sendMessageTwitchInternal(
channel: string,
text: string,
cfg: ClawdbotConfig,
accountId: string = DEFAULT_ACCOUNT_ID,
stripMarkdown: boolean = true,
logger: Console = console,
): Promise<SendMessageResult> {
const account = getAccountConfig(cfg, accountId);
if (!account) {
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
return {
ok: false,
messageId: generateMessageId(),
error: `Account not found: ${accountId}. Available accounts: ${availableIds.join(", ") || "none"}`,
};
}
const tokenResolution = resolveTwitchToken(cfg, { accountId });
if (!isAccountConfigured(account, tokenResolution.token)) {
return {
ok: false,
messageId: generateMessageId(),
error:
`Account ${accountId} is not properly configured. ` +
"Required: username, clientId, and token (config or env for default account).",
};
}
const normalizedChannel = channel || account.channel;
if (!normalizedChannel) {
return {
ok: false,
messageId: generateMessageId(),
error: "No channel specified and no default channel in account config",
};
}
const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
if (!cleanedText) {
return {
ok: true,
messageId: "skipped",
};
}
const clientManager = getRegistryClientManager(accountId);
if (!clientManager) {
return {
ok: false,
messageId: generateMessageId(),
error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`,
};
}
try {
const result = await clientManager.sendMessage(
account,
normalizeTwitchChannel(normalizedChannel),
cleanedText,
cfg,
accountId,
);
if (!result.ok) {
return {
ok: false,
messageId: result.messageId ?? generateMessageId(),
error: result.error ?? "Send failed",
};
}
return {
ok: true,
messageId: result.messageId ?? generateMessageId(),
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`Failed to send message: ${errorMsg}`);
return {
ok: false,
messageId: generateMessageId(),
error: errorMsg,
};
}
}

View File

@ -0,0 +1,270 @@
/**
* Tests for status.ts module
*
* Tests cover:
* - Detection of unconfigured accounts
* - Detection of disabled accounts
* - Detection of missing clientId
* - Token format warnings
* - Access control warnings
* - Runtime error detection
*/
import { describe, expect, it } from "vitest";
import { collectTwitchStatusIssues } from "./status.js";
import type { ChannelAccountSnapshot } from "./types.js";
describe("status", () => {
describe("collectTwitchStatusIssues", () => {
it("should detect unconfigured accounts", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: false,
enabled: true,
running: false,
},
];
const issues = collectTwitchStatusIssues(snapshots);
expect(issues.length).toBeGreaterThan(0);
expect(issues[0]?.kind).toBe("config");
expect(issues[0]?.message).toContain("not properly configured");
});
it("should detect disabled accounts", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: false,
running: false,
},
];
const issues = collectTwitchStatusIssues(snapshots);
expect(issues.length).toBeGreaterThan(0);
const disabledIssue = issues.find((i) => i.message.includes("disabled"));
expect(disabledIssue).toBeDefined();
});
it("should detect missing clientId when account configured (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123",
// clientId missing
},
},
};
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
const clientIdIssue = issues.find((i) => i.message.includes("client ID"));
expect(clientIdIssue).toBeDefined();
});
it("should warn about oauth: prefix in token (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123", // has prefix
clientId: "test-id",
},
},
};
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
const prefixIssue = issues.find((i) => i.message.includes("oauth:"));
expect(prefixIssue).toBeDefined();
expect(prefixIssue?.kind).toBe("config");
});
it("should detect clientSecret without refreshToken (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123",
clientId: "test-id",
clientSecret: "secret123",
// refreshToken missing
},
},
};
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
const secretIssue = issues.find((i) => i.message.includes("clientSecret"));
expect(secretIssue).toBeDefined();
});
it("should detect empty allowFrom array (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowFrom: [], // empty array
},
},
};
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
const allowFromIssue = issues.find((i) => i.message.includes("allowFrom"));
expect(allowFromIssue).toBeDefined();
});
it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowedRoles: ["all"],
allowFrom: ["123456"], // conflict!
},
},
};
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
const conflictIssue = issues.find((i) => i.kind === "intent");
expect(conflictIssue).toBeDefined();
expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'");
});
it("should detect runtime errors", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
lastError: "Connection timeout",
},
];
const issues = collectTwitchStatusIssues(snapshots);
const runtimeIssue = issues.find((i) => i.kind === "runtime");
expect(runtimeIssue).toBeDefined();
expect(runtimeIssue?.message).toContain("Connection timeout");
});
it("should detect accounts that never connected", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
lastStartAt: undefined,
lastInboundAt: undefined,
lastOutboundAt: undefined,
},
];
const issues = collectTwitchStatusIssues(snapshots);
const neverConnectedIssue = issues.find((i) =>
i.message.includes("never connected successfully"),
);
expect(neverConnectedIssue).toBeDefined();
});
it("should detect long-running connections", () => {
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: true,
lastStartAt: oldDate,
},
];
const issues = collectTwitchStatusIssues(snapshots);
const uptimeIssue = issues.find((i) => i.message.includes("running for"));
expect(uptimeIssue).toBeDefined();
});
it("should handle empty snapshots array", () => {
const issues = collectTwitchStatusIssues([]);
expect(issues).toEqual([]);
});
it("should skip non-Twitch accounts gracefully", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: undefined,
configured: false,
enabled: true,
running: false,
},
];
const issues = collectTwitchStatusIssues(snapshots);
// Should not crash, may return empty or minimal issues
expect(Array.isArray(issues)).toBe(true);
});
});
});

View File

@ -0,0 +1,176 @@
/**
* Twitch status issues collector.
*
* Detects and reports configuration issues for Twitch accounts.
*/
import { getAccountConfig } from "./config.js";
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "./types.js";
import { resolveTwitchToken } from "./token.js";
import { isAccountConfigured } from "./utils/twitch.js";
/**
* Collect status issues for Twitch accounts.
*
* Analyzes account snapshots and detects configuration problems,
* authentication issues, and other potential problems.
*
* @param accounts - Array of account snapshots to analyze
* @param getCfg - Optional function to get full config for additional checks
* @returns Array of detected status issues
*
* @example
* const issues = collectTwitchStatusIssues(accountSnapshots);
* if (issues.length > 0) {
* console.warn("Twitch configuration issues detected:");
* issues.forEach(issue => console.warn(`- ${issue.message}`));
* }
*/
export function collectTwitchStatusIssues(
accounts: ChannelAccountSnapshot[],
getCfg?: () => unknown,
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const accountId = entry.accountId;
if (!accountId) continue;
let account: ReturnType<typeof getAccountConfig> | null = null;
let cfg: Parameters<typeof resolveTwitchToken>[0] | undefined;
if (getCfg) {
try {
cfg = getCfg() as {
channels?: { twitch?: { accounts?: Record<string, unknown> } };
};
account = getAccountConfig(cfg, accountId);
} catch {
// Ignore config access errors
}
}
if (!entry.configured) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "Twitch account is not properly configured",
fix: "Add required fields: username, accessToken, and clientId to your account configuration",
});
continue;
}
if (entry.enabled === false) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "Twitch account is disabled",
fix: "Set enabled: true in your account configuration to enable this account",
});
continue;
}
if (account && account.username && account.accessToken && !account.clientId) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "Twitch client ID is required",
fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
});
}
const tokenResolution = cfg
? resolveTwitchToken(cfg as Parameters<typeof resolveTwitchToken>[0], { accountId })
: { token: "", source: "none" };
if (account && isAccountConfigured(account, tokenResolution.token)) {
if (account.accessToken?.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).",
});
}
if (account.clientSecret && !account.refreshToken) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "clientSecret provided without refreshToken",
fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
});
}
if (account.allowFrom && account.allowFrom.length === 0) {
issues.push({
channel: "twitch",
accountId,
kind: "config",
message: "allowFrom is configured but empty",
fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
});
}
if (
account.allowedRoles?.includes("all") &&
account.allowFrom &&
account.allowFrom.length > 0
) {
issues.push({
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.",
});
}
}
if (entry.lastError) {
issues.push({
channel: "twitch",
accountId,
kind: "runtime",
message: `Last error: ${entry.lastError}`,
fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
});
}
if (
entry.configured &&
!entry.running &&
!entry.lastStartAt &&
!entry.lastInboundAt &&
!entry.lastOutboundAt
) {
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.",
});
}
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;
}

View File

@ -0,0 +1,171 @@
/**
* Tests for token.ts module
*
* Tests cover:
* - Token resolution from config
* - Token resolution from environment variable
* - Fallback behavior when token not found
* - Account ID normalization
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
describe("token", () => {
// Multi-account config for testing non-default accounts
const mockMultiAccountConfig = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
accessToken: "oauth:config-token",
},
other: {
username: "otherbot",
accessToken: "oauth:other-token",
},
},
},
},
} as unknown as ClawdbotConfig;
// Simplified single-account config
const mockSimplifiedConfig = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:config-token",
},
},
} as unknown as ClawdbotConfig;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN;
});
describe("resolveTwitchToken", () => {
it("should resolve token from simplified config for default account", () => {
const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
expect(result.token).toBe("oauth:config-token");
expect(result.source).toBe("config");
});
it("should resolve token from config for non-default account (multi-account)", () => {
const result = resolveTwitchToken(mockMultiAccountConfig, { accountId: "other" });
expect(result.token).toBe("oauth:other-token");
expect(result.source).toBe("config");
});
it("should prioritize config token over env var (simplified config)", () => {
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
// Config token should be used even if env var exists
expect(result.token).toBe("oauth:config-token");
expect(result.source).toBe("config");
});
it("should use env var when config token is empty (simplified config)", () => {
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
const configWithEmptyToken = {
channels: {
twitch: {
username: "testbot",
accessToken: "",
},
},
} as unknown as ClawdbotConfig;
const result = resolveTwitchToken(configWithEmptyToken, { accountId: "default" });
expect(result.token).toBe("oauth:env-token");
expect(result.source).toBe("env");
});
it("should return empty token when neither config nor env has token (simplified config)", () => {
const configWithoutToken = {
channels: {
twitch: {
username: "testbot",
accessToken: "",
},
},
} as unknown as ClawdbotConfig;
const result = resolveTwitchToken(configWithoutToken, { accountId: "default" });
expect(result.token).toBe("");
expect(result.source).toBe("none");
});
it("should not use env var for non-default accounts (multi-account)", () => {
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
const configWithoutToken = {
channels: {
twitch: {
accounts: {
secondary: {
username: "secondary",
accessToken: "",
},
},
},
},
} as unknown as ClawdbotConfig;
const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" });
// Non-default accounts shouldn't use env var
expect(result.token).toBe("");
expect(result.source).toBe("none");
});
it("should handle missing account gracefully", () => {
const configWithoutAccount = {
channels: {
twitch: {
accounts: {},
},
},
} as unknown as ClawdbotConfig;
const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" });
expect(result.token).toBe("");
expect(result.source).toBe("none");
});
it("should handle missing Twitch config section", () => {
const configWithoutSection = {
channels: {},
} as unknown as ClawdbotConfig;
const result = resolveTwitchToken(configWithoutSection, { accountId: "default" });
expect(result.token).toBe("");
expect(result.source).toBe("none");
});
});
describe("TwitchTokenSource type", () => {
it("should have correct values", () => {
const sources: TwitchTokenSource[] = ["env", "config", "none"];
expect(sources).toContain("env");
expect(sources).toContain("config");
expect(sources).toContain("none");
});
});
});

View File

@ -0,0 +1,87 @@
/**
* Twitch access token resolution with environment variable support.
*
* Supports reading Twitch OAuth access tokens from config or environment variable.
* The CLAWDBOT_TWITCH_ACCESS_TOKEN env var is only used for the default account.
*
* Token resolution priority:
* 1. Account access token from merged config (accounts.{id} or base-level for default)
* 2. Environment variable: CLAWDBOT_TWITCH_ACCESS_TOKEN (default account only)
*/
import type { ClawdbotConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
export type TwitchTokenSource = "env" | "config" | "none";
export type TwitchTokenResolution = {
token: string;
source: TwitchTokenSource;
};
/**
* Normalize a Twitch OAuth token - ensure it has the oauth: prefix
*/
function normalizeTwitchToken(raw?: string | null): string | undefined {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
// Twitch tokens should have oauth: prefix
return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`;
}
/**
* Resolve Twitch access token from config or environment variable.
*
* Priority:
* 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
* 2. Environment variable: CLAWDBOT_TWITCH_ACCESS_TOKEN (default account only)
*
* The getAccountConfig function handles merging base-level config with accounts.default,
* so this logic works for both simplified and multi-account patterns.
*
* @param cfg - Clawdbot config
* @param opts - Options including accountId and optional envToken override
* @returns Token resolution with source
*/
export function resolveTwitchToken(
cfg?: ClawdbotConfig,
opts: { accountId?: string | null; envToken?: string | null } = {},
): TwitchTokenResolution {
const accountId = normalizeAccountId(opts.accountId);
// Get merged account config (handles both simplified and multi-account patterns)
const twitchCfg = cfg?.channels?.twitch;
const accountCfg =
accountId === DEFAULT_ACCOUNT_ID
? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record<string, unknown> | undefined)
: (twitchCfg?.accounts?.[accountId as string] as Record<string, unknown> | undefined);
// For default account, also check base-level config
let token: string | undefined;
if (accountId === DEFAULT_ACCOUNT_ID) {
// Base-level config takes precedence
token = normalizeTwitchToken(
(typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : undefined) ||
(accountCfg?.accessToken as string | undefined),
);
} else {
// Non-default accounts only use accounts object
token = normalizeTwitchToken(accountCfg?.accessToken as string | undefined);
}
if (token) {
return { token, source: "config" };
}
// Environment variable (default account only)
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv
? normalizeTwitchToken(opts.envToken ?? process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN)
: undefined;
if (envToken) {
return { token: envToken, source: "env" };
}
return { token: "", source: "none" };
}

View File

@ -0,0 +1,574 @@
/**
* Tests for TwitchClientManager class
*
* Tests cover:
* - Client connection and reconnection
* - Message handling (chat)
* - Message sending with rate limiting
* - Disconnection scenarios
* - Error handling and edge cases
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TwitchClientManager } from "./twitch-client.js";
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
// Mock @twurple dependencies
const mockConnect = vi.fn().mockResolvedValue(undefined);
const mockJoin = vi.fn().mockResolvedValue(undefined);
const mockSay = vi.fn().mockResolvedValue({ messageId: "test-msg-123" });
const mockQuit = vi.fn();
const mockUnbind = vi.fn();
// Event handler storage for testing
const messageHandlers: Array<(channel: string, user: string, message: string, msg: any) => void> =
[];
// Mock functions that track handlers and return unbind objects
const mockOnMessage = vi.fn((handler: any) => {
messageHandlers.push(handler);
return { unbind: mockUnbind };
});
const mockAddUserForToken = vi.fn().mockResolvedValue("123456");
const mockOnRefresh = vi.fn();
const mockOnRefreshFailure = vi.fn();
vi.mock("@twurple/chat", () => ({
ChatClient: class {
onMessage = mockOnMessage;
connect = mockConnect;
join = mockJoin;
say = mockSay;
quit = mockQuit;
},
LogLevel: {
CRITICAL: "CRITICAL",
ERROR: "ERROR",
WARNING: "WARNING",
INFO: "INFO",
DEBUG: "DEBUG",
TRACE: "TRACE",
},
}));
const mockAuthProvider = {
constructor: vi.fn(),
};
vi.mock("@twurple/auth", () => ({
StaticAuthProvider: class {
constructor(...args: unknown[]) {
mockAuthProvider.constructor(...args);
}
},
RefreshingAuthProvider: class {
addUserForToken = mockAddUserForToken;
onRefresh = mockOnRefresh;
onRefreshFailure = mockOnRefreshFailure;
},
}));
// Mock token resolution - must be after @twurple/auth mock
vi.mock("./token.js", () => ({
resolveTwitchToken: vi.fn(() => ({
token: "oauth:mock-token-from-tests",
source: "config" as const,
})),
DEFAULT_ACCOUNT_ID: "default",
}));
describe("TwitchClientManager", () => {
let manager: TwitchClientManager;
let mockLogger: ChannelLogSink;
const testAccount: TwitchAccountConfig = {
username: "testbot",
token: "oauth:test123456",
clientId: "test-client-id",
channel: "testchannel",
enabled: true,
};
const testAccount2: TwitchAccountConfig = {
username: "testbot2",
token: "oauth:test789",
clientId: "test-client-id-2",
channel: "testchannel2",
enabled: true,
};
beforeEach(async () => {
// Clear all mocks first
vi.clearAllMocks();
// Clear handler arrays
messageHandlers.length = 0;
// Re-set up the default token mock implementation after clearing
const { resolveTwitchToken } = await import("./token.js");
vi.mocked(resolveTwitchToken).mockReturnValue({
token: "oauth:mock-token-from-tests",
source: "config" as const,
});
// Create mock logger
mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
// Create manager instance
manager = new TwitchClientManager(mockLogger);
});
afterEach(() => {
// Clean up manager to avoid side effects
manager._clearForTest();
});
describe("getClient", () => {
it("should create a new client connection", async () => {
const _client = await manager.getClient(testAccount);
// New implementation: connect is called, channels are passed to constructor
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining("Connected to Twitch as testbot"),
);
});
it("should use account username as default channel when channel not specified", async () => {
const accountWithoutChannel: TwitchAccountConfig = {
...testAccount,
channel: undefined,
};
await manager.getClient(accountWithoutChannel);
// New implementation: channel (testbot) is passed to constructor, not via join()
expect(mockConnect).toHaveBeenCalledTimes(1);
});
it("should reuse existing client for same account", async () => {
const client1 = await manager.getClient(testAccount);
const client2 = await manager.getClient(testAccount);
expect(client1).toBe(client2);
expect(mockConnect).toHaveBeenCalledTimes(1);
});
it("should create separate clients for different accounts", async () => {
await manager.getClient(testAccount);
await manager.getClient(testAccount2);
expect(mockConnect).toHaveBeenCalledTimes(2);
});
it("should normalize token by removing oauth: prefix", async () => {
const accountWithPrefix: TwitchAccountConfig = {
...testAccount,
token: "oauth:actualtoken123",
};
// Override the mock to return a specific token for this test
const { resolveTwitchToken } = await import("./token.js");
vi.mocked(resolveTwitchToken).mockReturnValue({
token: "oauth:actualtoken123",
source: "config" as const,
});
await manager.getClient(accountWithPrefix);
expect(mockAuthProvider.constructor).toHaveBeenCalledWith("test-client-id", "actualtoken123");
});
it("should use token directly when no oauth: prefix", async () => {
// Override the mock to return a token without oauth: prefix
const { resolveTwitchToken } = await import("./token.js");
vi.mocked(resolveTwitchToken).mockReturnValue({
token: "oauth:mock-token-from-tests",
source: "config" as const,
});
await manager.getClient(testAccount);
// Implementation strips oauth: prefix from all tokens
expect(mockAuthProvider.constructor).toHaveBeenCalledWith(
"test-client-id",
"mock-token-from-tests",
);
});
it("should throw error when clientId is missing", async () => {
const accountWithoutClientId: TwitchAccountConfig = {
...testAccount,
clientId: undefined,
};
await expect(manager.getClient(accountWithoutClientId)).rejects.toThrow(
"Missing Twitch client ID",
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining("Missing Twitch client ID"),
);
});
it("should throw error when token is missing", async () => {
// Override the mock to return empty token
const { resolveTwitchToken } = await import("./token.js");
vi.mocked(resolveTwitchToken).mockReturnValue({
token: "",
source: "none" as const,
});
await expect(manager.getClient(testAccount)).rejects.toThrow("Missing Twitch token");
});
it("should set up message handlers on client connection", async () => {
await manager.getClient(testAccount);
expect(mockOnMessage).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Set up handlers for"));
});
it("should create separate clients for same account with different channels", async () => {
const account1: TwitchAccountConfig = {
...testAccount,
channel: "channel1",
};
const account2: TwitchAccountConfig = {
...testAccount,
channel: "channel2",
};
await manager.getClient(account1);
await manager.getClient(account2);
expect(mockConnect).toHaveBeenCalledTimes(2);
});
});
describe("onMessage", () => {
it("should register message handler for account", () => {
const handler = vi.fn();
manager.onMessage(testAccount, handler);
expect(handler).not.toHaveBeenCalled();
});
it("should replace existing handler for same account", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
manager.onMessage(testAccount, handler1);
manager.onMessage(testAccount, handler2);
// Check the stored handler is handler2
const key = manager.getAccountKey(testAccount);
expect((manager as any).messageHandlers.get(key)).toBe(handler2);
});
});
describe("disconnect", () => {
it("should disconnect a connected client", async () => {
await manager.getClient(testAccount);
await manager.disconnect(testAccount);
expect(mockQuit).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Disconnected"));
});
it("should clear client and message handler", async () => {
const handler = vi.fn();
await manager.getClient(testAccount);
manager.onMessage(testAccount, handler);
await manager.disconnect(testAccount);
const key = manager.getAccountKey(testAccount);
expect((manager as any).clients.has(key)).toBe(false);
expect((manager as any).messageHandlers.has(key)).toBe(false);
});
it("should handle disconnecting non-existent client gracefully", async () => {
// disconnect doesn't throw, just does nothing
await manager.disconnect(testAccount);
expect(mockQuit).not.toHaveBeenCalled();
});
it("should only disconnect specified account when multiple accounts exist", async () => {
await manager.getClient(testAccount);
await manager.getClient(testAccount2);
await manager.disconnect(testAccount);
expect(mockQuit).toHaveBeenCalledTimes(1);
const key2 = manager.getAccountKey(testAccount2);
expect((manager as any).clients.has(key2)).toBe(true);
});
});
describe("disconnectAll", () => {
it("should disconnect all connected clients", async () => {
await manager.getClient(testAccount);
await manager.getClient(testAccount2);
await manager.disconnectAll();
expect(mockQuit).toHaveBeenCalledTimes(2);
expect((manager as any).clients.size).toBe(0);
expect((manager as any).messageHandlers.size).toBe(0);
});
it("should handle empty client list gracefully", async () => {
// disconnectAll doesn't throw, just does nothing
await manager.disconnectAll();
expect(mockQuit).not.toHaveBeenCalled();
});
});
describe("sendMessage", () => {
beforeEach(async () => {
await manager.getClient(testAccount);
});
it("should send message successfully", async () => {
const result = await manager.sendMessage(testAccount, "testchannel", "Hello, world!");
expect(result.ok).toBe(true);
expect(result.messageId).toBeDefined();
expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!");
});
it("should generate unique message ID for each message", async () => {
const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");
expect(result1.messageId).not.toBe(result2.messageId);
});
it("should handle sending to account's default channel", async () => {
const result = await manager.sendMessage(
testAccount,
testAccount.channel || testAccount.username,
"Test message",
);
// Should use the account's channel or username
expect(result.ok).toBe(true);
expect(mockSay).toHaveBeenCalled();
});
it("should return error on send failure", async () => {
mockSay.mockRejectedValueOnce(new Error("Rate limited"));
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
expect(result.ok).toBe(false);
expect(result.error).toBe("Rate limited");
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to send message"),
);
});
it("should handle unknown error types", async () => {
mockSay.mockRejectedValueOnce("String error");
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
expect(result.ok).toBe(false);
expect(result.error).toBe("String error");
});
it("should create client if not already connected", async () => {
// Clear the existing client
(manager as any).clients.clear();
// Reset connect call count for this specific test
const connectCallCountBefore = mockConnect.mock.calls.length;
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
expect(result.ok).toBe(true);
expect(mockConnect.mock.calls.length).toBeGreaterThan(connectCallCountBefore);
});
});
describe("message handling integration", () => {
let capturedMessage: TwitchChatMessage | null = null;
beforeEach(() => {
capturedMessage = null;
// Set up message handler before connecting
manager.onMessage(testAccount, (message) => {
capturedMessage = message;
});
});
it("should handle incoming chat messages", async () => {
await manager.getClient(testAccount);
// Get the onMessage callback
const onMessageCallback = messageHandlers[0];
if (!onMessageCallback) throw new Error("onMessageCallback not found");
// Simulate Twitch message
onMessageCallback("#testchannel", "testuser", "Hello bot!", {
userInfo: {
userName: "testuser",
displayName: "TestUser",
userId: "12345",
isMod: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
},
id: "msg123",
});
expect(capturedMessage).not.toBeNull();
expect(capturedMessage?.username).toBe("testuser");
expect(capturedMessage?.displayName).toBe("TestUser");
expect(capturedMessage?.userId).toBe("12345");
expect(capturedMessage?.message).toBe("Hello bot!");
expect(capturedMessage?.channel).toBe("testchannel");
expect(capturedMessage?.chatType).toBe("group");
});
it("should normalize channel names without # prefix", async () => {
await manager.getClient(testAccount);
const onMessageCallback = messageHandlers[0];
onMessageCallback("testchannel", "testuser", "Test", {
userInfo: {
userName: "testuser",
displayName: "TestUser",
userId: "123",
isMod: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
},
id: "msg1",
});
expect(capturedMessage?.channel).toBe("testchannel");
});
it("should include user role flags in message", async () => {
await manager.getClient(testAccount);
const onMessageCallback = messageHandlers[0];
onMessageCallback("#testchannel", "moduser", "Test", {
userInfo: {
userName: "moduser",
displayName: "ModUser",
userId: "456",
isMod: true,
isBroadcaster: false,
isVip: true,
isSubscriber: true,
},
id: "msg2",
});
expect(capturedMessage?.isMod).toBe(true);
expect(capturedMessage?.isVip).toBe(true);
expect(capturedMessage?.isSub).toBe(true);
expect(capturedMessage?.isOwner).toBe(false);
});
it("should handle broadcaster messages", async () => {
await manager.getClient(testAccount);
const onMessageCallback = messageHandlers[0];
onMessageCallback("#testchannel", "broadcaster", "Test", {
userInfo: {
userName: "broadcaster",
displayName: "Broadcaster",
userId: "789",
isMod: false,
isBroadcaster: true,
isVip: false,
isSubscriber: false,
},
id: "msg3",
});
expect(capturedMessage?.isOwner).toBe(true);
});
});
describe("edge cases", () => {
it("should handle multiple message handlers for different accounts", async () => {
const messages1: TwitchChatMessage[] = [];
const messages2: TwitchChatMessage[] = [];
manager.onMessage(testAccount, (msg) => messages1.push(msg));
manager.onMessage(testAccount2, (msg) => messages2.push(msg));
await manager.getClient(testAccount);
await manager.getClient(testAccount2);
// Simulate message for first account
const onMessage1 = messageHandlers[0];
if (!onMessage1) throw new Error("onMessage1 not found");
onMessage1("#testchannel", "user1", "msg1", {
userInfo: {
userName: "user1",
displayName: "User1",
userId: "1",
isMod: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
},
id: "1",
});
// Simulate message for second account
const onMessage2 = messageHandlers[1];
if (!onMessage2) throw new Error("onMessage2 not found");
onMessage2("#testchannel2", "user2", "msg2", {
userInfo: {
userName: "user2",
displayName: "User2",
userId: "2",
isMod: false,
isBroadcaster: false,
isVip: false,
isSubscriber: false,
},
id: "2",
});
expect(messages1).toHaveLength(1);
expect(messages2).toHaveLength(1);
expect(messages1[0]?.message).toBe("msg1");
expect(messages2[0]?.message).toBe("msg2");
});
it("should handle rapid client creation requests", async () => {
const promises = [
manager.getClient(testAccount),
manager.getClient(testAccount),
manager.getClient(testAccount),
];
await Promise.all(promises);
// Note: The implementation doesn't handle concurrent getClient calls,
// so multiple connections may be created. This is expected behavior.
expect(mockConnect).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,277 @@
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
import { ChatClient, LogLevel } from "@twurple/chat";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
import { resolveTwitchToken } from "./token.js";
import { normalizeToken } from "./utils/twitch.js";
/**
* Manages Twitch chat client connections
*/
export class TwitchClientManager {
private clients = new Map<string, ChatClient>();
private messageHandlers = new Map<string, (message: TwitchChatMessage) => void>();
constructor(private logger: ChannelLogSink) {}
/**
* Create an auth provider for the account.
*/
private async createAuthProvider(
account: TwitchAccountConfig,
normalizedToken: string,
): Promise<StaticAuthProvider | RefreshingAuthProvider> {
if (!account.clientId) {
throw new Error("Missing Twitch client ID");
}
if (account.clientSecret) {
const authProvider = new RefreshingAuthProvider({
clientId: account.clientId,
clientSecret: account.clientSecret,
});
await authProvider
.addUserForToken({
accessToken: normalizedToken,
refreshToken: account.refreshToken ?? null,
expiresIn: account.expiresIn ?? null,
obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(),
})
.then((userId) => {
this.logger.info(
`Added user ${userId} to RefreshingAuthProvider for ${account.username}`,
);
})
.catch((err) => {
this.logger.error(
`Failed to add user to RefreshingAuthProvider: ${err instanceof Error ? err.message : String(err)}`,
);
});
authProvider.onRefresh((userId, token) => {
this.logger.info(
`Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`,
);
});
authProvider.onRefreshFailure((userId, error) => {
this.logger.error(`Failed to refresh access token for user ${userId}: ${error.message}`);
});
const refreshStatus = account.refreshToken
? "automatic token refresh enabled"
: "token refresh disabled (no refresh token)";
this.logger.info(`Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`);
return authProvider;
}
this.logger.info(`Using StaticAuthProvider for ${account.username} (no clientSecret provided)`);
return new StaticAuthProvider(account.clientId, normalizedToken);
}
/**
* Get or create a chat client for an account
*/
async getClient(
account: TwitchAccountConfig,
cfg?: ClawdbotConfig,
accountId?: string,
): Promise<ChatClient> {
const key = this.getAccountKey(account);
const existing = this.clients.get(key);
if (existing) {
return existing;
}
const tokenResolution = resolveTwitchToken(cfg, {
accountId,
});
if (!tokenResolution.token) {
this.logger.error(
`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");
}
this.logger.debug?.(`Using ${tokenResolution.source} token source for ${account.username}`);
if (!account.clientId) {
this.logger.error(`Missing Twitch client ID for account ${account.username}`);
throw new Error("Missing Twitch client ID");
}
const normalizedToken = normalizeToken(tokenResolution.token);
const authProvider = await this.createAuthProvider(account, normalizedToken);
const client = new ChatClient({
authProvider,
channels: [account.channel],
rejoinChannelsOnReconnect: true,
requestMembershipEvents: true,
logger: {
minLevel: LogLevel.WARNING,
custom: {
log: (level, message) => {
switch (level) {
case LogLevel.CRITICAL:
this.logger.error(`${message}`);
break;
case LogLevel.ERROR:
this.logger.error(`${message}`);
break;
case LogLevel.WARNING:
this.logger.warn(`${message}`);
break;
case LogLevel.INFO:
this.logger.info(`${message}`);
break;
case LogLevel.DEBUG:
this.logger.debug?.(`${message}`);
break;
case LogLevel.TRACE:
this.logger.debug?.(`${message}`);
break;
}
},
},
},
});
this.setupClientHandlers(client, account);
client.connect();
this.clients.set(key, client);
this.logger.info(`Connected to Twitch as ${account.username}`);
return client;
}
/**
* Set up message and event handlers for a client
*/
private setupClientHandlers(client: ChatClient, account: TwitchAccountConfig): void {
const key = this.getAccountKey(account);
// Handle incoming messages
client.onMessage((channelName, _user, messageText, msg) => {
const handler = this.messageHandlers.get(key);
if (handler) {
const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
const from = `twitch:${msg.userInfo.userName}`;
const preview = messageText.slice(0, 100).replace(/\n/g, "\\n");
this.logger.debug?.(
`twitch inbound: channel=${normalizedChannel} from=${from} len=${messageText.length} preview="${preview}"`,
);
handler({
username: msg.userInfo.userName,
displayName: msg.userInfo.displayName,
userId: msg.userInfo.userId,
message: messageText,
channel: normalizedChannel,
id: msg.id,
timestamp: new Date(),
isMod: msg.userInfo.isMod,
isOwner: msg.userInfo.isBroadcaster,
isVip: msg.userInfo.isVip,
isSub: msg.userInfo.isSubscriber,
chatType: "group",
});
}
});
this.logger.info(`Set up handlers for ${key}`);
}
/**
* Set a message handler for an account
* @returns A function that removes the handler when called
*/
onMessage(
account: TwitchAccountConfig,
handler: (message: TwitchChatMessage) => void,
): () => void {
const key = this.getAccountKey(account);
this.messageHandlers.set(key, handler);
return () => {
this.messageHandlers.delete(key);
};
}
/**
* Disconnect a client
*/
async disconnect(account: TwitchAccountConfig): Promise<void> {
const key = this.getAccountKey(account);
const client = this.clients.get(key);
if (client) {
client.quit();
this.clients.delete(key);
this.messageHandlers.delete(key);
this.logger.info(`Disconnected ${key}`);
}
}
/**
* Disconnect all clients
*/
async disconnectAll(): Promise<void> {
this.clients.forEach((client) => client.quit());
this.clients.clear();
this.messageHandlers.clear();
this.logger.info(" Disconnected all clients");
}
/**
* Send a message to a channel
*/
async sendMessage(
account: TwitchAccountConfig,
channel: string,
message: string,
cfg?: ClawdbotConfig,
accountId?: string,
): Promise<{ ok: boolean; error?: string; messageId?: string }> {
try {
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)
const messageId = crypto.randomUUID();
// Send message (Twurple handles rate limiting)
await client.say(channel, message);
return { ok: true, messageId };
} catch (error) {
this.logger.error(
`Failed to send message: ${error instanceof Error ? error.message : String(error)}`,
);
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Generate a unique key for an account
*/
public getAccountKey(account: TwitchAccountConfig): string {
return `${account.username}:${account.channel}`;
}
/**
* Clear all clients and handlers (for testing)
*/
_clearForTest(): void {
this.clients.clear();
this.messageHandlers.clear();
}
}

View File

@ -0,0 +1,141 @@
/**
* Twitch channel plugin types.
*
* This file defines Twitch-specific types. Generic channel types are imported
* from Clawdbot core.
*/
import type {
ChannelAccountSnapshot,
ChannelCapabilities,
ChannelLogSink,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMeta,
} from "../../../src/channels/plugins/types.core.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
import type {
ChannelGatewayContext,
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelResolveKind,
ChannelResolveResult,
ChannelStatusAdapter,
} from "../../../src/channels/plugins/types.adapters.js";
import type { ClawdbotConfig } from "../../../src/config/config.js";
import type { OutboundDeliveryResult } from "../../../src/infra/outbound/deliver.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
// ============================================================================
// Twitch-Specific Types
// ============================================================================
/**
* Twitch user roles that can be allowed to interact with the bot
*/
export type TwitchRole = "moderator" | "owner" | "vip" | "subscriber" | "all";
/**
* Account configuration for a Twitch channel
*/
export interface TwitchAccountConfig {
/** Twitch username */
username: string;
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
accessToken: string;
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
clientId: string;
/** Channel name to join (required) */
channel: string;
/** Enable this account */
enabled?: boolean;
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
allowFrom?: Array<string>;
/** Roles allowed to interact with the bot (e.g., ["mod", "vip", "sub"]) */
allowedRoles?: TwitchRole[];
/** Require @mention to trigger bot responses */
requireMention?: boolean;
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
clientSecret?: string;
/** Refresh token (required for automatic token refresh) */
refreshToken?: string;
/** Token expiry time in seconds (optional, for token refresh tracking) */
expiresIn?: number | null;
/** Timestamp when token was obtained (optional, for token refresh tracking) */
obtainmentTimestamp?: number;
}
/**
* Message target for Twitch
*/
export interface TwitchTarget {
/** Account ID */
accountId: string;
/** Channel name (defaults to account's channel) */
channel?: string;
}
/**
* Twitch message from chat
*/
export interface TwitchChatMessage {
/** Username of sender */
username: string;
/** Twitch user ID of sender (unique, persistent identifier) */
userId?: string;
/** Message text */
message: string;
/** Channel name */
channel: string;
/** Display name (may include special characters) */
displayName?: string;
/** Message ID */
id?: string;
/** Timestamp */
timestamp?: Date;
/** Whether the sender is a moderator */
isMod?: boolean;
/** Whether the sender is the channel owner/broadcaster */
isOwner?: boolean;
/** Whether the sender is a VIP */
isVip?: boolean;
/** Whether the sender is a subscriber */
isSub?: boolean;
/** Chat type */
chatType?: "group";
}
/**
* Send result from Twitch client
*/
export interface SendResult {
ok: boolean;
error?: string;
messageId?: string;
}
// Re-export core types for convenience
export type {
ChannelAccountSnapshot,
ChannelGatewayContext,
ChannelLogSink,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMeta,
ChannelOutboundAdapter,
ChannelStatusAdapter,
ChannelCapabilities,
ChannelResolveKind,
ChannelResolveResult,
ChannelPlugin,
ChannelOutboundContext,
OutboundDeliveryResult,
};
// Import and re-export the schema type
import type { TwitchConfigSchema } from "./config-schema.js";
import type { z } from "zod";
export type TwitchConfig = z.infer<typeof TwitchConfigSchema>;
export type { ClawdbotConfig };
export type { RuntimeEnv };

View File

@ -0,0 +1,92 @@
/**
* Markdown utilities for Twitch chat
*
* Twitch chat doesn't support markdown formatting, so we strip it before sending.
* Based on Clawdbot's markdownToText in src/agents/tools/web-fetch-utils.ts.
*/
/**
* Strip markdown formatting from text for Twitch compatibility.
*
* Removes images, links, bold, italic, strikethrough, code blocks, inline code,
* headers, and list formatting. Replaces newlines with spaces since Twitch
* is a single-line chat medium.
*
* @param markdown - The markdown text to strip
* @returns Plain text with markdown removed
*/
export function stripMarkdownForTwitch(markdown: string): string {
return (
markdown
// Images
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
// Links
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
// Bold (**text**)
.replace(/\*\*([^*]+)\*\*/g, "$1")
// Bold (__text__)
.replace(/__([^_]+)__/g, "$1")
// Italic (*text*)
.replace(/\*([^*]+)\*/g, "$1")
// Italic (_text_)
.replace(/_([^_]+)_/g, "$1")
// Strikethrough (~~text~~)
.replace(/~~([^~]+)~~/g, "$1")
// Code blocks
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""))
// Inline code
.replace(/`([^`]+)`/g, "$1")
// Headers
.replace(/^#{1,6}\s+/gm, "")
// Lists
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
// Normalize whitespace
.replace(/\r/g, "") // Remove carriage returns
.replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines
.replace(/\n/g, " ") // Replace newlines with spaces (for Twitch)
.replace(/[ \t]{2,}/g, " ") // Reduce multiple spaces to single
.trim()
);
}
/**
* Simple word-boundary chunker for Twitch (500 char limit).
* Strips markdown before chunking to avoid breaking markdown patterns.
*
* @param text - The text to chunk
* @param limit - Maximum characters per chunk (Twitch limit is 500)
* @returns Array of text chunks
*/
export function chunkTextForTwitch(text: string, limit: number): string[] {
// First, strip markdown
const cleaned = stripMarkdownForTwitch(text);
if (!cleaned) return [];
if (limit <= 0) return [cleaned];
if (cleaned.length <= limit) return [cleaned];
const chunks: string[] = [];
let remaining = cleaned;
while (remaining.length > limit) {
// Find the last space before the limit
const window = remaining.slice(0, limit);
const lastSpaceIndex = window.lastIndexOf(" ");
if (lastSpaceIndex === -1) {
// No space found, hard split at limit
chunks.push(window);
remaining = remaining.slice(limit);
} else {
// Split at the last space
chunks.push(window.slice(0, lastSpaceIndex));
remaining = remaining.slice(lastSpaceIndex + 1);
}
}
if (remaining) {
chunks.push(remaining);
}
return chunks;
}

View File

@ -0,0 +1,78 @@
/**
* Twitch-specific utility functions
*/
/**
* Normalize Twitch channel names.
*
* Removes the '#' prefix if present, converts to lowercase, and trims whitespace.
* Twitch channel names are case-insensitive and don't use the '#' prefix in the API.
*
* @param channel - The channel name to normalize
* @returns Normalized channel name
*
* @example
* normalizeTwitchChannel("#TwitchChannel") // "twitchchannel"
* normalizeTwitchChannel("MyChannel") // "mychannel"
*/
export function normalizeTwitchChannel(channel: string): string {
const trimmed = channel.trim().toLowerCase();
return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
}
/**
* Create a standardized error message for missing target.
*
* @param provider - The provider name (e.g., "Twitch")
* @param hint - Optional hint for how to fix the issue
* @returns Error object with descriptive message
*/
export function missingTargetError(provider: string, hint?: string): Error {
return new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
}
/**
* Generate a unique message ID for Twitch messages.
*
* Twurple's say() doesn't return the message ID, so we generate one
* for tracking purposes.
*
* @returns A unique message ID
*/
export function generateMessageId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
}
/**
* Normalize OAuth token by removing the "oauth:" prefix if present.
*
* Twurple doesn't require the "oauth:" prefix, so we strip it for consistency.
*
* @param token - The OAuth token to normalize
* @returns Normalized token without "oauth:" prefix
*
* @example
* normalizeToken("oauth:abc123") // "abc123"
* normalizeToken("abc123") // "abc123"
*/
export function normalizeToken(token: string): string {
return token.startsWith("oauth:") ? token.slice(6) : token;
}
/**
* Check if an account is properly configured with required credentials.
*
* @param account - The Twitch account config to check
* @returns true if the account has required credentials
*/
export function isAccountConfigured(
account: {
username?: string;
accessToken?: string;
clientId?: string;
},
resolvedToken?: string | null,
): boolean {
const token = resolvedToken ?? account?.accessToken;
return Boolean(account?.username && token && account?.clientId);
}

View File

@ -0,0 +1,7 @@
/**
* Vitest setup file for Twitch plugin tests.
*
* Re-exports the root test setup to avoid duplication.
*/
export * from "../../../test/setup.js";

207
pnpm-lock.yaml generated
View File

@ -172,6 +172,13 @@ importers:
zod:
specifier: ^4.3.6
version: 4.3.6
optionalDependencies:
'@napi-rs/canvas':
specifier: ^0.1.88
version: 0.1.88
node-llama-cpp:
specifier: 3.15.0
version: 3.15.0(typescript@5.9.3)
devDependencies:
'@grammyjs/types':
specifier: ^3.23.0
@ -254,13 +261,6 @@ importers:
wireit:
specifier: ^0.14.12
version: 0.14.12
optionalDependencies:
'@napi-rs/canvas':
specifier: ^0.1.88
version: 0.1.88
node-llama-cpp:
specifier: 3.15.0
version: 3.15.0(typescript@5.9.3)
extensions/bluebubbles: {}
@ -424,6 +424,25 @@ importers:
specifier: ^3.0.0
version: 3.0.0
extensions/twitch:
dependencies:
'@twurple/api':
specifier: ^8.0.3
version: 8.0.3(@twurple/auth@8.0.3)
'@twurple/auth':
specifier: ^8.0.3
version: 8.0.3
'@twurple/chat':
specifier: ^8.0.3
version: 8.0.3(@twurple/auth@8.0.3)
zod:
specifier: ^4.3.5
version: 4.3.6
devDependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/voice-call:
dependencies:
'@sinclair/typebox':
@ -810,6 +829,39 @@ packages:
'@cloudflare/workers-types@4.20260120.0':
resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==}
'@d-fischer/cache-decorators@4.0.1':
resolution: {integrity: sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==}
'@d-fischer/connection@9.0.0':
resolution: {integrity: sha512-Mljp/EbaE+eYWfsFXUOk+RfpbHgrWGL/60JkAvjYixw6KREfi5r17XdUiXe54ByAQox6jwgdN2vebdmW1BT+nQ==}
'@d-fischer/deprecate@2.0.2':
resolution: {integrity: sha512-wlw3HwEanJFJKctwLzhfOM6LKwR70FPfGZGoKOhWBKyOPXk+3a9Cc6S9zhm6tka7xKtpmfxVIReGUwPnMbIaZg==}
'@d-fischer/detect-node@3.0.1':
resolution: {integrity: sha512-0Rf3XwTzuTh8+oPZW9SfxTIiL+26RRJ0BRPwj5oVjZFyFKmsj9RGfN2zuTRjOuA3FCK/jYm06HOhwNK+8Pfv8w==}
'@d-fischer/escape-string-regexp@5.0.0':
resolution: {integrity: sha512-7eoxnxcto5eVPW5h1T+ePnVFukmI9f/ZR9nlBLh1t3kyzJDUNor2C+YW9H/Terw3YnbZSDgDYrpCJCHtOtAQHw==}
engines: {node: '>=10'}
'@d-fischer/isomorphic-ws@7.0.2':
resolution: {integrity: sha512-xK+qIJUF0ne3dsjq5Y3BviQ4M+gx9dzkN+dPP7abBMje4YRfow+X9jBgeEoTe5e+Q6+8hI9R0b37Okkk8Vf0hQ==}
peerDependencies:
ws: ^8.2.0
'@d-fischer/logger@4.2.4':
resolution: {integrity: sha512-TFMZ/SVW8xyQtyJw9Rcuci4betSKy0qbQn2B5+1+72vVXeO8Qb1pYvuwF5qr0vDGundmSWq7W8r19nVPnXXSvA==}
'@d-fischer/rate-limiter@1.1.0':
resolution: {integrity: sha512-O5HgACwApyCZhp4JTEBEtbv/W3eAwEkrARFvgWnEsDmXgCMWjIHwohWoHre5BW6IYXFSHBGsuZB/EvNL3942kQ==}
'@d-fischer/shared-utils@3.6.4':
resolution: {integrity: sha512-BPkVLHfn2Lbyo/ENDBwtEB8JVQ+9OzkjJhUunLaxkw4k59YFlQxUUwlDBejVSFcpQT0t+D3CQlX+ySZnQj0wxw==}
'@d-fischer/typed-event-emitter@3.3.3':
resolution: {integrity: sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ==}
'@discordjs/voice@0.19.0':
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
engines: {node: '>=22.12.0'}
@ -1264,7 +1316,6 @@ packages:
'@lancedb/lancedb@0.23.0':
resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==}
engines: {node: '>= 18'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
@ -2585,6 +2636,25 @@ packages:
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
'@twurple/api-call@8.0.3':
resolution: {integrity: sha512-/5DBTqFjpYB+qqOkkFzoTWE79a7+I8uLXmBIIIYjGoq/CIPxKcHnlemXlU8cQhTr87PVa3th8zJXGYiNkpRx8w==}
'@twurple/api@8.0.3':
resolution: {integrity: sha512-vnqVi9YlNDbCqgpUUvTIq4sDitKCY0dkTw9zPluZvRNqUB1eCsuoaRNW96HQDhKtA9P4pRzwZ8xU7v/1KU2ytg==}
peerDependencies:
'@twurple/auth': 8.0.3
'@twurple/auth@8.0.3':
resolution: {integrity: sha512-Xlv+WNXmGQir4aBXYeRCqdno5XurA6jzYTIovSEHa7FZf3AMHMFqtzW7yqTCUn4iOahfUSA2TIIxmxFM0wis0g==}
'@twurple/chat@8.0.3':
resolution: {integrity: sha512-rhm6xhWKp+4zYFimaEj5fPm6lw/yjrAOsGXXSvPDsEqFR+fc0cVXzmHmglTavkmEELRajFiqNBKZjg73JZWhTQ==}
peerDependencies:
'@twurple/auth': 8.0.3
'@twurple/common@8.0.3':
resolution: {integrity: sha512-JQ2lb5qSFT21Y9qMfIouAILb94ppedLHASq49Fe/AP8oq0k3IC9Q7tX2n6tiMzGWqn+n8MnONUpMSZ6FhulMXA==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@ -3775,6 +3845,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
ircv3@0.33.0:
resolution: {integrity: sha512-7rK1Aial3LBiFycE8w3MHiBBFb41/2GG2Ll/fR2IJj1vx0pLpn1s+78K+z/I4PZTqCCSp/Sb4QgKMh3NMhx0Kg==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@ -3944,6 +4017,10 @@ packages:
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
klona@2.0.6:
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
engines: {node: '>= 8'}
leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
@ -6383,6 +6460,54 @@ snapshots:
'@cloudflare/workers-types@4.20260120.0':
optional: true
'@d-fischer/cache-decorators@4.0.1':
dependencies:
'@d-fischer/shared-utils': 3.6.4
tslib: 2.8.1
'@d-fischer/connection@9.0.0':
dependencies:
'@d-fischer/isomorphic-ws': 7.0.2(ws@8.19.0)
'@d-fischer/logger': 4.2.4
'@d-fischer/shared-utils': 3.6.4
'@d-fischer/typed-event-emitter': 3.3.3
'@types/ws': 8.18.1
tslib: 2.8.1
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@d-fischer/deprecate@2.0.2': {}
'@d-fischer/detect-node@3.0.1': {}
'@d-fischer/escape-string-regexp@5.0.0': {}
'@d-fischer/isomorphic-ws@7.0.2(ws@8.19.0)':
dependencies:
ws: 8.19.0
'@d-fischer/logger@4.2.4':
dependencies:
'@d-fischer/detect-node': 3.0.1
'@d-fischer/shared-utils': 3.6.4
tslib: 2.8.1
'@d-fischer/rate-limiter@1.1.0':
dependencies:
'@d-fischer/logger': 4.2.4
'@d-fischer/shared-utils': 3.6.4
tslib: 2.8.1
'@d-fischer/shared-utils@3.6.4':
dependencies:
tslib: 2.8.1
'@d-fischer/typed-event-emitter@3.3.3':
dependencies:
tslib: 2.8.1
'@discordjs/voice@0.19.0':
dependencies:
'@types/ws': 8.18.1
@ -8225,6 +8350,57 @@ snapshots:
'@tokenizer/token@0.3.0': {}
'@twurple/api-call@8.0.3':
dependencies:
'@d-fischer/shared-utils': 3.6.4
'@twurple/common': 8.0.3
tslib: 2.8.1
'@twurple/api@8.0.3(@twurple/auth@8.0.3)':
dependencies:
'@d-fischer/cache-decorators': 4.0.1
'@d-fischer/detect-node': 3.0.1
'@d-fischer/logger': 4.2.4
'@d-fischer/rate-limiter': 1.1.0
'@d-fischer/shared-utils': 3.6.4
'@d-fischer/typed-event-emitter': 3.3.3
'@twurple/api-call': 8.0.3
'@twurple/auth': 8.0.3
'@twurple/common': 8.0.3
retry: 0.13.1
tslib: 2.8.1
'@twurple/auth@8.0.3':
dependencies:
'@d-fischer/logger': 4.2.4
'@d-fischer/shared-utils': 3.6.4
'@d-fischer/typed-event-emitter': 3.3.3
'@twurple/api-call': 8.0.3
'@twurple/common': 8.0.3
tslib: 2.8.1
'@twurple/chat@8.0.3(@twurple/auth@8.0.3)':
dependencies:
'@d-fischer/cache-decorators': 4.0.1
'@d-fischer/deprecate': 2.0.2
'@d-fischer/logger': 4.2.4
'@d-fischer/rate-limiter': 1.1.0
'@d-fischer/shared-utils': 3.6.4
'@d-fischer/typed-event-emitter': 3.3.3
'@twurple/auth': 8.0.3
'@twurple/common': 8.0.3
ircv3: 0.33.0
tslib: 2.8.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@twurple/common@8.0.3':
dependencies:
'@d-fischer/shared-utils': 3.6.4
klona: 2.0.6
tslib: 2.8.1
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@ -9644,6 +9820,19 @@ snapshots:
'@reflink/reflink': 0.1.19
optional: true
ircv3@0.33.0:
dependencies:
'@d-fischer/connection': 9.0.0
'@d-fischer/escape-string-regexp': 5.0.0
'@d-fischer/logger': 4.2.4
'@d-fischer/shared-utils': 3.6.4
'@d-fischer/typed-event-emitter': 3.3.3
klona: 2.0.6
tslib: 2.8.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@ -9814,6 +10003,8 @@ snapshots:
dependencies:
'@keyv/serialize': 1.1.1
klona@2.0.6: {}
leac@0.6.0: {}
lie@3.3.0:

View File

@ -96,13 +96,28 @@ export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolP
type ToolPolicyConfig = {
allow?: string[];
alsoAllow?: string[];
deny?: string[];
profile?: string;
};
function unionAllow(base?: string[], extra?: string[]) {
if (!Array.isArray(extra) || extra.length === 0) return base;
// If the user is using alsoAllow without an allowlist, treat it as additive on top of
// an implicit allow-all policy.
if (!Array.isArray(base) || base.length === 0) {
return Array.from(new Set(["*", ...extra]));
}
return Array.from(new Set([...base, ...extra]));
}
function pickToolPolicy(config?: ToolPolicyConfig): SandboxToolPolicy | undefined {
if (!config) return undefined;
const allow = Array.isArray(config.allow) ? config.allow : undefined;
const allow = Array.isArray(config.allow)
? unionAllow(config.allow, config.alsoAllow)
: Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
? unionAllow(undefined, config.alsoAllow)
: undefined;
const deny = Array.isArray(config.deny) ? config.deny : undefined;
if (!allow && !deny) return undefined;
return { allow, deny };
@ -195,6 +210,17 @@ export function resolveEffectiveToolPolicy(params: {
agentProviderPolicy: pickToolPolicy(agentProviderPolicy),
profile,
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
// alsoAllow is applied at the profile stage (to avoid being filtered out early).
profileAlsoAllow: Array.isArray(agentTools?.alsoAllow)
? agentTools?.alsoAllow
: Array.isArray(globalTools?.alsoAllow)
? globalTools?.alsoAllow
: undefined,
providerProfileAlsoAllow: Array.isArray(agentProviderPolicy?.alsoAllow)
? agentProviderPolicy?.alsoAllow
: Array.isArray(providerPolicy?.alsoAllow)
? providerPolicy?.alsoAllow
: undefined,
};
}

View File

@ -157,6 +157,8 @@ export function createClawdbotCodingTools(options?: {
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
@ -175,14 +177,25 @@ export function createClawdbotCodingTools(options?: {
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => {
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
};
const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow);
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow(
providerProfilePolicy,
providerProfileAlsoAllow,
);
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
? resolveSubagentToolPolicy(options.config)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicy,
providerProfilePolicy,
profilePolicyWithAlsoAllow,
providerProfilePolicyWithAlsoAllow,
globalPolicy,
globalProviderPolicy,
agentPolicy,
@ -333,18 +346,18 @@ export function createClawdbotCodingTools(options?: {
if (resolved.unknownAllowlist.length > 0) {
const entries = resolved.unknownAllowlist.join(", ");
const suffix = resolved.strippedAllowlist
? "Ignoring allowlist so core tools remain available."
? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement."
: "These entries won't match any tool unless the plugin is enabled.";
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
}
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
};
const profilePolicyExpanded = resolvePolicy(
profilePolicy,
profilePolicyWithAlsoAllow,
profile ? `tools.profile (${profile})` : "tools.profile",
);
const providerProfileExpanded = resolvePolicy(
providerProfilePolicy,
providerProfilePolicyWithAlsoAllow,
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
);
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");

View File

@ -209,6 +209,12 @@ export function stripPluginOnlyAllowlist(
if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry);
}
const strippedAllowlist = !hasCoreEntry;
// When an allowlist contains only plugin tools, we strip it to avoid accidentally
// disabling core tools. Users who want additive behavior should prefer `tools.alsoAllow`.
if (strippedAllowlist) {
// Note: logging happens in the caller (pi-tools/tools-invoke) after this function returns.
// We keep this note here for future maintainers.
}
return {
policy: strippedAllowlist ? { ...policy, allow: undefined } : policy,
unknownAllowlist: Array.from(new Set(unknownAllowlist)),

View File

@ -59,6 +59,7 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
silent: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional(

View File

@ -176,6 +176,7 @@ export async function handleTelegramAction(
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
silent: typeof params.silent === "boolean" ? params.silent : undefined,
});
return jsonResult({
ok: true,

View File

@ -1,152 +1,49 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { runCommandWithTimeout } from "../process/exec.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
filterBootstrapFilesForSession,
DEFAULT_MEMORY_ALT_FILENAME,
DEFAULT_MEMORY_FILENAME,
loadWorkspaceBootstrapFiles,
} from "./workspace.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
describe("ensureAgentWorkspace", () => {
it("creates directory and bootstrap files when missing", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
const nested = path.join(dir, "nested");
const result = await ensureAgentWorkspace({
dir: nested,
ensureBootstrapFiles: true,
});
expect(result.dir).toBe(path.resolve(nested));
expect(result.agentsPath).toBe(path.join(path.resolve(nested), "AGENTS.md"));
expect(result.agentsPath).toBeDefined();
if (!result.agentsPath) throw new Error("agentsPath missing");
const content = await fs.readFile(result.agentsPath, "utf-8");
expect(content).toContain("# AGENTS.md");
describe("loadWorkspaceBootstrapFiles", () => {
it("includes MEMORY.md when present", async () => {
const tempDir = await makeTempWorkspace("clawdbot-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: "MEMORY.md", content: "memory" });
const identity = path.join(path.resolve(nested), "IDENTITY.md");
const user = path.join(path.resolve(nested), "USER.md");
const heartbeat = path.join(path.resolve(nested), "HEARTBEAT.md");
const bootstrap = path.join(path.resolve(nested), "BOOTSTRAP.md");
await expect(fs.stat(identity)).resolves.toBeDefined();
await expect(fs.stat(user)).resolves.toBeDefined();
await expect(fs.stat(heartbeat)).resolves.toBeDefined();
await expect(fs.stat(bootstrap)).resolves.toBeDefined();
const files = await loadWorkspaceBootstrapFiles(tempDir);
const memoryEntries = files.filter((file) =>
[DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name),
);
expect(memoryEntries).toHaveLength(1);
expect(memoryEntries[0]?.missing).toBe(false);
expect(memoryEntries[0]?.content).toBe("memory");
});
it("initializes a git repo for brand-new workspaces when git is available", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
const nested = path.join(dir, "nested");
const gitAvailable = await runCommandWithTimeout(["git", "--version"], { timeoutMs: 2_000 })
.then((res) => res.code === 0)
.catch(() => false);
if (!gitAvailable) return;
it("includes memory.md when MEMORY.md is absent", async () => {
const tempDir = await makeTempWorkspace("clawdbot-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: "memory.md", content: "alt" });
await ensureAgentWorkspace({
dir: nested,
ensureBootstrapFiles: true,
});
const files = await loadWorkspaceBootstrapFiles(tempDir);
const memoryEntries = files.filter((file) =>
[DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name),
);
await expect(fs.stat(path.join(nested, ".git"))).resolves.toBeDefined();
expect(memoryEntries).toHaveLength(1);
expect(memoryEntries[0]?.missing).toBe(false);
expect(memoryEntries[0]?.content).toBe("alt");
});
it("does not initialize git when workspace already exists", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
await fs.writeFile(path.join(dir, "AGENTS.md"), "custom", "utf-8");
it("omits memory entries when no memory files exist", async () => {
const tempDir = await makeTempWorkspace("clawdbot-workspace-");
await ensureAgentWorkspace({
dir,
ensureBootstrapFiles: true,
});
const files = await loadWorkspaceBootstrapFiles(tempDir);
const memoryEntries = files.filter((file) =>
[DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name),
);
await expect(fs.stat(path.join(dir, ".git"))).rejects.toBeDefined();
});
it("does not overwrite existing AGENTS.md", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
const agentsPath = path.join(dir, "AGENTS.md");
await fs.writeFile(agentsPath, "custom", "utf-8");
await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true });
expect(await fs.readFile(agentsPath, "utf-8")).toBe("custom");
});
it("does not recreate BOOTSTRAP.md once workspace exists", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
const agentsPath = path.join(dir, "AGENTS.md");
const bootstrapPath = path.join(dir, "BOOTSTRAP.md");
await fs.writeFile(agentsPath, "custom", "utf-8");
await fs.rm(bootstrapPath, { force: true });
await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true });
await expect(fs.stat(bootstrapPath)).rejects.toBeDefined();
});
});
describe("filterBootstrapFilesForSession", () => {
const files: WorkspaceBootstrapFile[] = [
{
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "agents",
missing: false,
},
{
name: DEFAULT_SOUL_FILENAME,
path: "/tmp/SOUL.md",
content: "soul",
missing: false,
},
{
name: DEFAULT_TOOLS_FILENAME,
path: "/tmp/TOOLS.md",
content: "tools",
missing: false,
},
{
name: DEFAULT_IDENTITY_FILENAME,
path: "/tmp/IDENTITY.md",
content: "identity",
missing: false,
},
{
name: DEFAULT_USER_FILENAME,
path: "/tmp/USER.md",
content: "user",
missing: false,
},
{
name: DEFAULT_HEARTBEAT_FILENAME,
path: "/tmp/HEARTBEAT.md",
content: "heartbeat",
missing: false,
},
{
name: DEFAULT_BOOTSTRAP_FILENAME,
path: "/tmp/BOOTSTRAP.md",
content: "bootstrap",
missing: false,
},
];
it("keeps full bootstrap set for non-subagent sessions", () => {
const result = filterBootstrapFilesForSession(files, "agent:main:session:abc");
expect(result.map((file) => file.name)).toEqual(files.map((file) => file.name));
});
it("limits bootstrap files for subagent sessions", () => {
const result = filterBootstrapFilesForSession(files, "agent:main:subagent:abc");
expect(result.map((file) => file.name)).toEqual([
DEFAULT_AGENTS_FILENAME,
DEFAULT_TOOLS_FILENAME,
]);
expect(memoryEntries).toHaveLength(0);
});
});

View File

@ -26,6 +26,8 @@ export const DEFAULT_IDENTITY_FILENAME = "IDENTITY.md";
export const DEFAULT_USER_FILENAME = "USER.md";
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
export const DEFAULT_MEMORY_FILENAME = "MEMORY.md";
export const DEFAULT_MEMORY_ALT_FILENAME = "memory.md";
const TEMPLATE_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@ -61,7 +63,9 @@ export type WorkspaceBootstrapFileName =
| typeof DEFAULT_IDENTITY_FILENAME
| typeof DEFAULT_USER_FILENAME
| typeof DEFAULT_HEARTBEAT_FILENAME
| typeof DEFAULT_BOOTSTRAP_FILENAME;
| typeof DEFAULT_BOOTSTRAP_FILENAME
| typeof DEFAULT_MEMORY_FILENAME
| typeof DEFAULT_MEMORY_ALT_FILENAME;
export type WorkspaceBootstrapFile = {
name: WorkspaceBootstrapFileName;
@ -184,6 +188,39 @@ export async function ensureAgentWorkspace(params?: {
};
}
async function resolveMemoryBootstrapEntries(
resolvedDir: string,
): Promise<Array<{ name: WorkspaceBootstrapFileName; filePath: string }>> {
const candidates: WorkspaceBootstrapFileName[] = [
DEFAULT_MEMORY_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
];
const entries: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = [];
for (const name of candidates) {
const filePath = path.join(resolvedDir, name);
try {
await fs.access(filePath);
entries.push({ name, filePath });
} catch {
// optional
}
}
if (entries.length <= 1) return entries;
const seen = new Set<string>();
const deduped: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = [];
for (const entry of entries) {
let key = entry.filePath;
try {
key = await fs.realpath(entry.filePath);
} catch {}
if (seen.has(key)) continue;
seen.add(key);
deduped.push(entry);
}
return deduped;
}
export async function loadWorkspaceBootstrapFiles(dir: string): Promise<WorkspaceBootstrapFile[]> {
const resolvedDir = resolveUserPath(dir);
@ -221,6 +258,8 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
},
];
entries.push(...(await resolveMemoryBootstrapEntries(resolvedDir)));
const result: WorkspaceBootstrapFile[] = [];
for (const entry of entries) {
try {

View File

@ -127,4 +127,30 @@ describe("handleDiscordMessageAction", () => {
}),
);
});
it("accepts threadId for thread replies (tool compatibility)", async () => {
sendMessageDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "thread-reply",
params: {
// The `message` tool uses `threadId`.
threadId: "999",
// Include a conflicting channelId to ensure threadId takes precedence.
channelId: "123",
message: "hi",
},
cfg: {} as ClawdbotConfig,
accountId: "ops",
});
expect(sendMessageDiscord).toHaveBeenCalledWith(
"channel:999",
"hi",
expect.objectContaining({
accountId: "ops",
}),
);
});
});

View File

@ -393,11 +393,17 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
});
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
const replyTo = readStringParam(actionParams, "replyTo");
// `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`.
// Prefer `threadId` when present to avoid accidentally replying in the parent channel.
const threadId = readStringParam(actionParams, "threadId");
const channelId = threadId ?? resolveChannelId();
return await handleDiscordAction(
{
action: "threadReply",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
channelId,
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,

View File

@ -36,4 +36,30 @@ describe("telegramMessageActions", () => {
cfg,
);
});
it("passes silent flag for silent sends", async () => {
handleTelegramAction.mockClear();
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
await telegramMessageActions.handleAction({
action: "send",
params: {
to: "456",
message: "Silent notification test",
silent: true,
},
cfg,
accountId: undefined,
});
expect(handleTelegramAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
to: "456",
content: "Silent notification test",
silent: true,
}),
cfg,
);
});
});

View File

@ -20,6 +20,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
return {
to,
content,
@ -28,6 +29,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
messageThreadId: threadId ?? undefined,
buttons,
asVoice,
silent,
};
}

View File

@ -22,7 +22,8 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false),
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false)
.option("--silent", "Send message silently without notification (Telegram only)", false),
)
.action(async (opts) => {
await helpers.runMessageAction("send", opts);

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./validation.js";
// NOTE: These tests ensure allow + alsoAllow cannot be set in the same scope.
describe("config: tools.alsoAllow", () => {
it("rejects tools.allow + tools.alsoAllow together", () => {
const res = validateConfigObject({
tools: {
allow: ["group:fs"],
alsoAllow: ["lobster"],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path === "tools")).toBe(true);
}
});
it("rejects agents.list[].tools.allow + alsoAllow together", () => {
const res = validateConfigObject({
agents: {
list: [
{
id: "main",
tools: {
allow: ["group:fs"],
alsoAllow: ["lobster"],
},
},
],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path.includes("agents.list"))).toBe(true);
}
});
it("allows profile + alsoAllow", () => {
const res = validateConfigObject({
tools: {
profile: "coding",
alsoAllow: ["lobster"],
},
});
expect(res.ok).toBe(true);
});
});

View File

@ -9,6 +9,10 @@ import {
resolveDefaultAgentIdFromRaw,
} from "./legacy.shared.js";
// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
id: "auth.anthropic-claude-cli-mode-oauth",
@ -24,6 +28,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
},
},
// tools.alsoAllow migration removed (field not shipped in prod; enforce via schema instead).
{
id: "tools.bash->tools.exec",
describe: "Move tools.bash to tools.exec",

View File

@ -165,7 +165,9 @@ const FIELD_LABELS: Record<string, string> = {
"tools.links.models": "Link Understanding Models",
"tools.links.scope": "Link Understanding Scope",
"tools.profile": "Tool Profile",
"tools.alsoAllow": "Tool Allowlist Additions",
"agents.list[].tools.profile": "Agent Tool Profile",
"agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
"tools.byProvider": "Tool Policy by Provider",
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
"tools.exec.applyPatch.enabled": "Enable apply_patch",

View File

@ -140,12 +140,21 @@ export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
export type ToolPolicyConfig = {
allow?: string[];
/**
* Additional allowlist entries merged into the effective allowlist.
*
* Intended for additive configuration (e.g., "also allow lobster") without forcing
* users to replace/duplicate an existing allowlist or profile.
*/
alsoAllow?: string[];
deny?: string[];
profile?: ToolProfileId;
};
export type GroupToolPolicyConfig = {
allow?: string[];
/** Additional allowlist entries merged into allow. */
alsoAllow?: string[];
deny?: string[];
};
@ -188,6 +197,8 @@ export type AgentToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
/** Additional allowlist entries merged into allow and/or profile allowlist. */
alsoAllow?: string[];
deny?: string[];
/** Optional tool policy overrides keyed by provider id or "provider/model". */
byProvider?: Record<string, ToolPolicyConfig>;
@ -312,6 +323,8 @@ export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
/** Additional allowlist entries merged into allow and/or profile allowlist. */
alsoAllow?: string[];
deny?: string[];
/** Optional tool policy overrides keyed by provider id or "provider/model". */
byProvider?: Record<string, ToolPolicyConfig>;

View File

@ -147,13 +147,23 @@ export const SandboxPruneSchema = z
.strict()
.optional();
export const ToolPolicySchema = z
const ToolPolicyBaseSchema = z
.object({
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.strict()
.optional();
.strict();
export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => {
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
});
}
}).optional();
export const ToolsWebSearchSchema = z
.object({
@ -202,10 +212,20 @@ export const ToolProfileSchema = z
export const ToolPolicyWithProfileSchema = z
.object({
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
profile: ToolProfileSchema,
})
.strict();
.strict()
.superRefine((value, ctx) => {
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"tools.byProvider policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
});
}
});
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
export const ElevatedAllowFromSchema = z
@ -231,6 +251,7 @@ export const AgentToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
elevated: z
@ -271,6 +292,15 @@ export const AgentToolsSchema = z
.optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"agent tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
});
}
})
.optional();
export const MemorySearchSchema = z
@ -425,6 +455,7 @@ export const ToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
web: ToolsWebSchema,
@ -507,4 +538,13 @@ export const ToolsSchema = z
.optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
});
}
})
.optional();

View File

@ -1,12 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
installGatewayTestHooks({ scope: "suite" });
beforeEach(() => {
// Ensure these tests are not affected by host env vars.
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
});
const resolveGatewayToken = (): string => {
const token = (testState.gatewayAuth as { token?: string } | undefined)?.token;
if (!token) throw new Error("test gateway token missing");
@ -47,6 +57,64 @@ describe("POST /tools/invoke", () => {
await server.close();
});
it("supports tools.alsoAllow as additive allowlist (profile stage)", async () => {
// No explicit tool allowlist; rely on profile + alsoAllow.
testState.agentsConfig = {
list: [{ id: "main" }],
} as any;
// minimal profile does NOT include sessions_list, but alsoAllow should.
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
tools: { profile: "minimal", alsoAllow: ["sessions_list"] },
} as any);
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
await server.close();
});
it("supports tools.alsoAllow without allow/profile (implicit allow-all)", async () => {
testState.agentsConfig = {
list: [{ id: "main" }],
} as any;
await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true });
await fs.writeFile(
CONFIG_PATH_CLAWDBOT,
JSON.stringify({ tools: { alsoAllow: ["sessions_list"] } }, null, 2),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
await server.close();
});
it("accepts password auth when bearer token matches", async () => {
testState.agentsConfig = {
list: [

View File

@ -130,9 +130,22 @@ export async function handleToolsInvokeHttpRequest(
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({ config: cfg, sessionKey });
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => {
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
};
const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow);
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow(
providerProfilePolicy,
providerProfileAlsoAllow,
);
const groupPolicy = resolveGroupToolPolicy({
config: cfg,
sessionKey,
@ -176,18 +189,18 @@ export async function handleToolsInvokeHttpRequest(
if (resolved.unknownAllowlist.length > 0) {
const entries = resolved.unknownAllowlist.join(", ");
const suffix = resolved.strippedAllowlist
? "Ignoring allowlist so core tools remain available."
? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement."
: "These entries won't match any tool unless the plugin is enabled.";
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
}
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
};
const profilePolicyExpanded = resolvePolicy(
profilePolicy,
profilePolicyWithAlsoAllow,
profile ? `tools.profile (${profile})` : "tools.profile",
);
const providerProfileExpanded = resolvePolicy(
providerProfilePolicy,
providerProfilePolicyWithAlsoAllow,
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
);
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");

View File

@ -862,12 +862,33 @@ describe("security audit", () => {
await fs.chmod(configPath, 0o600);
const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" } };
const user = "DESKTOP-TEST\\Tester";
const execIcacls = isWindows
? async (_cmd: string, args: string[]) => {
const target = args[0];
if (target === includePath) {
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\n ${user}:(F)\n`,
stderr: "",
};
}
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
stderr: "",
};
}
: undefined;
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath,
platform: isWindows ? "win32" : undefined,
env: isWindows
? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" }
: undefined,
execIcacls,
});
const expectedCheckId = isWindows

View File

@ -476,6 +476,28 @@ describe("sendMessageTelegram", () => {
});
});
it("sets disable_notification when silent is true", async () => {
const chatId = "123";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 1,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await sendMessageTelegram(chatId, "hi", {
token: "tok",
api,
silent: true,
});
expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", {
parse_mode: "HTML",
disable_notification: true,
});
});
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
const chatId = "-1001234567890";
const sendMessage = vi.fn().mockResolvedValue({

View File

@ -40,6 +40,8 @@ type TelegramSendOpts = {
plainText?: string;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
asVoice?: boolean;
/** Send message silently (no notification). Defaults to false. */
silent?: boolean;
/** Message ID to reply to (for threading) */
replyToMessageId?: number;
/** Forum topic thread ID (for forum supergroups) */
@ -245,6 +247,7 @@ export async function sendMessageTelegram(
const sendParams = {
parse_mode: "HTML" as const,
...baseParams,
...(opts.silent === true ? { disable_notification: true } : {}),
};
const res = await requestWithDiag(
() => api.sendMessage(chatId, htmlText, sendParams),
@ -298,6 +301,7 @@ export async function sendMessageTelegram(
caption: htmlCaption,
...(htmlCaption ? { parse_mode: "HTML" as const } : {}),
...baseMediaParams,
...(opts.silent === true ? { disable_notification: true } : {}),
};
let result:
| Awaited<ReturnType<typeof api.sendPhoto>>

View File

@ -21,6 +21,22 @@ export type DebugProps = {
};
export function renderDebug(props: DebugProps) {
const securityAudit =
props.status && typeof props.status === "object"
? (props.status as { securityAudit?: { summary?: Record<string, number> } }).securityAudit
: null;
const securitySummary = securityAudit?.summary ?? null;
const critical = securitySummary?.critical ?? 0;
const warn = securitySummary?.warn ?? 0;
const info = securitySummary?.info ?? 0;
const securityTone = critical > 0 ? "danger" : warn > 0 ? "warn" : "success";
const securityLabel =
critical > 0
? `${critical} critical`
: warn > 0
? `${warn} warnings`
: "No critical issues";
return html`
<section class="grid grid-cols-2">
<div class="card">
@ -36,6 +52,12 @@ export function renderDebug(props: DebugProps) {
<div class="stack" style="margin-top: 12px;">
<div>
<div class="muted">Status</div>
${securitySummary
? html`<div class="callout ${securityTone}" style="margin-top: 8px;">
Security audit: ${securityLabel}${info > 0 ? ` · ${info} info` : ""}. Run
<span class="mono">clawdbot security audit --deep</span> for details.
</div>`
: nothing}
<pre class="code-block">${JSON.stringify(props.status ?? {}, null, 2)}</pre>
</div>
<div>