@gakr-gakr/twitch 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -0
- package/api.ts +21 -0
- package/autobot.plugin.json +15 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +16 -0
- package/package.json +50 -0
- package/runtime-api.ts +22 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/access-control.ts +195 -0
- package/src/actions.ts +175 -0
- package/src/client-manager-registry.ts +109 -0
- package/src/config-schema.ts +88 -0
- package/src/config.ts +177 -0
- package/src/monitor.ts +311 -0
- package/src/outbound.ts +242 -0
- package/src/plugin.ts +220 -0
- package/src/probe.ts +130 -0
- package/src/resolver.ts +139 -0
- package/src/runtime.ts +9 -0
- package/src/send.ts +191 -0
- package/src/setup-surface.ts +526 -0
- package/src/status.ts +179 -0
- package/src/token.ts +93 -0
- package/src/twitch-client.ts +281 -0
- package/src/types.ts +104 -0
- package/src/utils/markdown.ts +98 -0
- package/src/utils/twitch.ts +81 -0
- package/tsconfig.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# @gakr-gakr/twitch
|
|
2
|
+
|
|
3
|
+
Twitch channel plugin for AutoBot.
|
|
4
|
+
|
|
5
|
+
## Install (local checkout)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
autobot plugins install ./path/to/local/twitch-plugin
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install (npm)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
autobot plugins install @gakr-gakr/twitch
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Onboarding: select Twitch and confirm the install prompt to fetch the plugin automatically.
|
|
18
|
+
|
|
19
|
+
## Config
|
|
20
|
+
|
|
21
|
+
Minimal config (simplified single-account):
|
|
22
|
+
|
|
23
|
+
**⚠️ Important:** `requireMention` defaults to `true`. Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot.
|
|
24
|
+
|
|
25
|
+
```json5
|
|
26
|
+
{
|
|
27
|
+
channels: {
|
|
28
|
+
twitch: {
|
|
29
|
+
enabled: true,
|
|
30
|
+
username: "autobot",
|
|
31
|
+
accessToken: "oauth:abc123...", // OAuth Access Token (add oauth: prefix)
|
|
32
|
+
clientId: "xyz789...", // Client ID from Token Generator
|
|
33
|
+
channel: "vevisk", // Channel to join (required)
|
|
34
|
+
allowFrom: ["123456789"], // (recommended) Your Twitch user ID only (Convert your twitch username to ID at https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/)
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Access control options:**
|
|
41
|
+
|
|
42
|
+
- `requireMention: false` - Disable the default mention requirement to respond to all messages
|
|
43
|
+
- `allowFrom: ["your_user_id"]` - Restrict to your Twitch user ID only (find your ID at https://www.twitchangles.com/xqc or similar)
|
|
44
|
+
- `allowedRoles: ["moderator", "vip", "subscriber"]` - Restrict to specific roles
|
|
45
|
+
|
|
46
|
+
Multi-account config (advanced):
|
|
47
|
+
|
|
48
|
+
```json5
|
|
49
|
+
{
|
|
50
|
+
channels: {
|
|
51
|
+
twitch: {
|
|
52
|
+
enabled: true,
|
|
53
|
+
accounts: {
|
|
54
|
+
default: {
|
|
55
|
+
username: "autobot",
|
|
56
|
+
accessToken: "oauth:abc123...",
|
|
57
|
+
clientId: "xyz789...",
|
|
58
|
+
channel: "vevisk",
|
|
59
|
+
},
|
|
60
|
+
channel2: {
|
|
61
|
+
username: "autobot",
|
|
62
|
+
accessToken: "oauth:def456...",
|
|
63
|
+
clientId: "uvw012...",
|
|
64
|
+
channel: "secondchannel",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Setup
|
|
73
|
+
|
|
74
|
+
1. Create a dedicated Twitch account for the bot, then generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
|
|
75
|
+
- Select **Bot Token**
|
|
76
|
+
- Verify scopes `chat:read` and `chat:write` are selected
|
|
77
|
+
- Copy the **Access Token** to `token` property
|
|
78
|
+
- Copy the **Client ID** to `clientId` property
|
|
79
|
+
2. Start the gateway
|
|
80
|
+
|
|
81
|
+
## Full documentation
|
|
82
|
+
|
|
83
|
+
See https://docs.openclaw.ai/channels/twitch for:
|
|
84
|
+
|
|
85
|
+
- Token refresh setup
|
|
86
|
+
- Access control patterns
|
|
87
|
+
- Multi-account configuration
|
|
88
|
+
- Troubleshooting
|
|
89
|
+
- Capabilities & limits
|
package/api.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ChannelAccountSnapshot,
|
|
3
|
+
type ChannelCapabilities,
|
|
4
|
+
type ChannelGatewayContext,
|
|
5
|
+
type ChannelLogSink,
|
|
6
|
+
type ChannelMessageActionAdapter,
|
|
7
|
+
type ChannelMessageActionContext,
|
|
8
|
+
type ChannelMeta,
|
|
9
|
+
type ChannelOutboundAdapter,
|
|
10
|
+
type ChannelOutboundContext,
|
|
11
|
+
type ChannelPlugin,
|
|
12
|
+
type ChannelResolveKind,
|
|
13
|
+
type ChannelResolveResult,
|
|
14
|
+
type ChannelStatusAdapter,
|
|
15
|
+
type AutoBotConfig,
|
|
16
|
+
type OutboundDeliveryResult,
|
|
17
|
+
type RuntimeEnv,
|
|
18
|
+
type WizardPrompter,
|
|
19
|
+
} from "./runtime-api.js";
|
|
20
|
+
export { twitchPlugin } from "./src/plugin.js";
|
|
21
|
+
export { setTwitchRuntime } from "./src/runtime.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "twitch",
|
|
3
|
+
"activation": {
|
|
4
|
+
"onStartup": false
|
|
5
|
+
},
|
|
6
|
+
"channels": ["twitch"],
|
|
7
|
+
"channelEnvVars": {
|
|
8
|
+
"twitch": ["AUTOBOT_TWITCH_ACCESS_TOKEN"]
|
|
9
|
+
},
|
|
10
|
+
"configSchema": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": false,
|
|
13
|
+
"properties": {}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { twitchPlugin } from "./src/plugin.js";
|
package/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineBundledChannelEntry } from "autobot/plugin-sdk/channel-entry-contract";
|
|
2
|
+
|
|
3
|
+
export default defineBundledChannelEntry({
|
|
4
|
+
id: "twitch",
|
|
5
|
+
name: "Twitch",
|
|
6
|
+
description: "Twitch IRC chat channel plugin",
|
|
7
|
+
importMetaUrl: import.meta.url,
|
|
8
|
+
plugin: {
|
|
9
|
+
specifier: "./channel-plugin-api.js",
|
|
10
|
+
exportName: "twitchPlugin",
|
|
11
|
+
},
|
|
12
|
+
runtime: {
|
|
13
|
+
specifier: "./api.js",
|
|
14
|
+
exportName: "setTwitchRuntime",
|
|
15
|
+
},
|
|
16
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gakr-gakr/twitch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AutoBot Twitch channel plugin",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/autobot/autobot"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@twurple/api": "8.1.4",
|
|
12
|
+
"@twurple/auth": "8.1.4",
|
|
13
|
+
"@twurple/chat": "8.1.4",
|
|
14
|
+
"zod": "4.4.3"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@gakr-gakr/plugin-sdk": "workspace:*"
|
|
18
|
+
},
|
|
19
|
+
"autobot": {
|
|
20
|
+
"extensions": [
|
|
21
|
+
"./index.ts"
|
|
22
|
+
],
|
|
23
|
+
"setupEntry": "./setup-entry.ts",
|
|
24
|
+
"install": {
|
|
25
|
+
"npmSpec": "@gakr-gakr/twitch",
|
|
26
|
+
"defaultChoice": "npm",
|
|
27
|
+
"minHostVersion": ">=2026.4.10"
|
|
28
|
+
},
|
|
29
|
+
"compat": {
|
|
30
|
+
"pluginApi": ">=2026.5.19"
|
|
31
|
+
},
|
|
32
|
+
"build": {
|
|
33
|
+
"autobotVersion": "2026.5.19"
|
|
34
|
+
},
|
|
35
|
+
"channel": {
|
|
36
|
+
"id": "twitch",
|
|
37
|
+
"label": "Twitch",
|
|
38
|
+
"selectionLabel": "Twitch (Chat)",
|
|
39
|
+
"docsPath": "/channels/twitch",
|
|
40
|
+
"blurb": "Twitch chat integration",
|
|
41
|
+
"aliases": [
|
|
42
|
+
"twitch-chat"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"release": {
|
|
46
|
+
"publishToClawHub": true,
|
|
47
|
+
"publishToNpm": true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/runtime-api.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Private runtime barrel for the bundled Twitch extension.
|
|
2
|
+
// Keep this barrel thin and aligned with the local extension surface.
|
|
3
|
+
|
|
4
|
+
export type {
|
|
5
|
+
ChannelAccountSnapshot,
|
|
6
|
+
ChannelCapabilities,
|
|
7
|
+
ChannelGatewayContext,
|
|
8
|
+
ChannelLogSink,
|
|
9
|
+
ChannelMessageActionAdapter,
|
|
10
|
+
ChannelMessageActionContext,
|
|
11
|
+
ChannelMeta,
|
|
12
|
+
ChannelOutboundAdapter,
|
|
13
|
+
ChannelOutboundContext,
|
|
14
|
+
ChannelResolveKind,
|
|
15
|
+
ChannelResolveResult,
|
|
16
|
+
ChannelStatusAdapter,
|
|
17
|
+
} from "autobot/plugin-sdk/channel-contract";
|
|
18
|
+
export type { ChannelPlugin } from "autobot/plugin-sdk/channel-core";
|
|
19
|
+
export type { OutboundDeliveryResult } from "autobot/plugin-sdk/channel-send-result";
|
|
20
|
+
export type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
21
|
+
export type { RuntimeEnv } from "autobot/plugin-sdk/runtime";
|
|
22
|
+
export type { WizardPrompter } from "autobot/plugin-sdk/setup";
|
package/setup-entry.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineBundledChannelSetupEntry } from "autobot/plugin-sdk/channel-entry-contract";
|
|
2
|
+
|
|
3
|
+
export default defineBundledChannelSetupEntry({
|
|
4
|
+
importMetaUrl: import.meta.url,
|
|
5
|
+
plugin: {
|
|
6
|
+
specifier: "./setup-plugin-api.js",
|
|
7
|
+
exportName: "twitchSetupPlugin",
|
|
8
|
+
},
|
|
9
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createChannelIngressResolver,
|
|
3
|
+
defineStableChannelIngressIdentity,
|
|
4
|
+
type ChannelIngressIdentitySubjectInput,
|
|
5
|
+
type IngressReasonCode,
|
|
6
|
+
} from "autobot/plugin-sdk/channel-ingress-runtime";
|
|
7
|
+
import { normalizeLowercaseStringOrEmpty } from "autobot/plugin-sdk/string-coerce-runtime";
|
|
8
|
+
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
|
9
|
+
|
|
10
|
+
type TwitchAccessControlResult = {
|
|
11
|
+
allowed: boolean;
|
|
12
|
+
reason?: string;
|
|
13
|
+
matchKey?: string;
|
|
14
|
+
matchSource?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type TwitchPolicyKind = "open" | "allowFrom" | "role";
|
|
18
|
+
|
|
19
|
+
const twitchUserIdentity = defineStableChannelIngressIdentity({
|
|
20
|
+
key: "sender-id",
|
|
21
|
+
entryIdPrefix: "twitch-user-entry",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const twitchRoleIdentity = defineStableChannelIngressIdentity({
|
|
25
|
+
key: "role-moderator",
|
|
26
|
+
kind: "role",
|
|
27
|
+
normalizeEntry: normalizeTwitchRole,
|
|
28
|
+
normalizeSubject: normalizeTwitchRole,
|
|
29
|
+
aliases: ["owner", "vip", "subscriber"].map((role) => ({
|
|
30
|
+
key: `role-${role}`,
|
|
31
|
+
kind: "role",
|
|
32
|
+
normalizeEntry: () => null,
|
|
33
|
+
normalizeSubject: normalizeTwitchRole,
|
|
34
|
+
})),
|
|
35
|
+
isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all",
|
|
36
|
+
resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}`,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export async function checkTwitchAccessControl(params: {
|
|
40
|
+
message: TwitchChatMessage;
|
|
41
|
+
account: TwitchAccountConfig;
|
|
42
|
+
botUsername: string;
|
|
43
|
+
}): Promise<TwitchAccessControlResult> {
|
|
44
|
+
const { message, account, botUsername } = params;
|
|
45
|
+
const policyKind = resolveTwitchPolicyKind(account);
|
|
46
|
+
const resolved = await createChannelIngressResolver({
|
|
47
|
+
channelId: "twitch",
|
|
48
|
+
accountId: "default",
|
|
49
|
+
identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity,
|
|
50
|
+
}).message({
|
|
51
|
+
subject:
|
|
52
|
+
policyKind === "role"
|
|
53
|
+
? twitchRoleSubject(message)
|
|
54
|
+
: ({ stableId: message.userId } satisfies ChannelIngressIdentitySubjectInput),
|
|
55
|
+
conversation: {
|
|
56
|
+
kind: "group",
|
|
57
|
+
id: message.channel,
|
|
58
|
+
},
|
|
59
|
+
event: { mayPair: false },
|
|
60
|
+
mentionFacts: {
|
|
61
|
+
canDetectMention: true,
|
|
62
|
+
wasMentioned: mentionsBot(message.message, botUsername),
|
|
63
|
+
},
|
|
64
|
+
dmPolicy: "open",
|
|
65
|
+
groupPolicy: policyKind === "open" ? "open" : "allowlist",
|
|
66
|
+
policy: {
|
|
67
|
+
activation: {
|
|
68
|
+
requireMention: account.requireMention ?? true,
|
|
69
|
+
allowTextCommands: false,
|
|
70
|
+
order: "before-sender",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
groupAllowFrom:
|
|
74
|
+
policyKind === "allowFrom"
|
|
75
|
+
? account.allowFrom
|
|
76
|
+
: policyKind === "role"
|
|
77
|
+
? account.allowedRoles
|
|
78
|
+
: undefined,
|
|
79
|
+
});
|
|
80
|
+
const decision = resolved.ingress;
|
|
81
|
+
|
|
82
|
+
if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") {
|
|
83
|
+
return {
|
|
84
|
+
allowed: false,
|
|
85
|
+
reason: "message does not mention the bot (requireMention is enabled)",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (decision.admission === "dispatch") {
|
|
90
|
+
if (policyKind === "allowFrom") {
|
|
91
|
+
return {
|
|
92
|
+
allowed: true,
|
|
93
|
+
matchKey: params.message.userId,
|
|
94
|
+
matchSource: "allowlist",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (policyKind === "role") {
|
|
98
|
+
return {
|
|
99
|
+
allowed: true,
|
|
100
|
+
matchKey: params.account.allowedRoles?.join(","),
|
|
101
|
+
matchSource: "role",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
allowed: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (policyKind === "allowFrom") {
|
|
110
|
+
if (!params.message.userId) {
|
|
111
|
+
return {
|
|
112
|
+
allowed: false,
|
|
113
|
+
reason: "sender user ID not available for allowlist check",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
allowed: false,
|
|
118
|
+
reason: "sender is not in allowFrom allowlist",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (policyKind === "role") {
|
|
123
|
+
return {
|
|
124
|
+
allowed: false,
|
|
125
|
+
reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
allowed: false,
|
|
131
|
+
reason: reasonForTwitchIngressDecision(decision),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveTwitchPolicyKind(account: TwitchAccountConfig): TwitchPolicyKind {
|
|
136
|
+
if (account.allowFrom !== undefined) {
|
|
137
|
+
return "allowFrom";
|
|
138
|
+
}
|
|
139
|
+
if (account.allowedRoles && account.allowedRoles.length > 0) {
|
|
140
|
+
return "role";
|
|
141
|
+
}
|
|
142
|
+
return "open";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function twitchRoleSubject(message: TwitchChatMessage): ChannelIngressIdentitySubjectInput {
|
|
146
|
+
return {
|
|
147
|
+
stableId: message.isMod ? "moderator" : undefined,
|
|
148
|
+
aliases: {
|
|
149
|
+
"role-owner": message.isOwner ? "owner" : undefined,
|
|
150
|
+
"role-vip": message.isVip ? "vip" : undefined,
|
|
151
|
+
"role-subscriber": message.isSub ? "subscriber" : undefined,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeTwitchRole(value: string): string | null {
|
|
157
|
+
const role = normalizeLowercaseStringOrEmpty(value);
|
|
158
|
+
if (role === "*") {
|
|
159
|
+
return "all";
|
|
160
|
+
}
|
|
161
|
+
return role === "moderator" ||
|
|
162
|
+
role === "owner" ||
|
|
163
|
+
role === "vip" ||
|
|
164
|
+
role === "subscriber" ||
|
|
165
|
+
role === "all"
|
|
166
|
+
? role
|
|
167
|
+
: null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function reasonForTwitchIngressDecision(decision: { reasonCode: IngressReasonCode }): string {
|
|
171
|
+
switch (decision.reasonCode) {
|
|
172
|
+
case "activation_skipped":
|
|
173
|
+
return "message does not mention the bot (requireMention is enabled)";
|
|
174
|
+
case "group_policy_empty_allowlist":
|
|
175
|
+
case "group_policy_not_allowlisted":
|
|
176
|
+
return "sender is not in allowFrom allowlist";
|
|
177
|
+
default:
|
|
178
|
+
return decision.reasonCode;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function mentionsBot(message: string, botUsername: string): boolean {
|
|
183
|
+
const expected = normalizeLowercaseStringOrEmpty(botUsername);
|
|
184
|
+
const mentionRegex = /@(\w+)/g;
|
|
185
|
+
let match: RegExpExecArray | null;
|
|
186
|
+
|
|
187
|
+
while ((match = mentionRegex.exec(message)) !== null) {
|
|
188
|
+
const username = match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : "";
|
|
189
|
+
if (username === expected) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return false;
|
|
195
|
+
}
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitch message actions adapter.
|
|
3
|
+
*
|
|
4
|
+
* Handles tool-based actions for Twitch, such as sending messages.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { formatErrorMessage } from "autobot/plugin-sdk/error-runtime";
|
|
8
|
+
import { resolveTwitchAccountContext } from "./config.js";
|
|
9
|
+
import { twitchOutbound } from "./outbound.js";
|
|
10
|
+
import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a tool result with error content.
|
|
14
|
+
*/
|
|
15
|
+
function errorResponse(error: string) {
|
|
16
|
+
return {
|
|
17
|
+
content: [
|
|
18
|
+
{
|
|
19
|
+
type: "text" as const,
|
|
20
|
+
text: JSON.stringify({ ok: false, error }),
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
details: { ok: false },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read a string parameter from action arguments.
|
|
29
|
+
*
|
|
30
|
+
* @param args - Action arguments
|
|
31
|
+
* @param key - Parameter key
|
|
32
|
+
* @param options - Options for reading the parameter
|
|
33
|
+
* @returns The parameter value or undefined if not found
|
|
34
|
+
*/
|
|
35
|
+
function readStringParam(
|
|
36
|
+
args: Record<string, unknown>,
|
|
37
|
+
key: string,
|
|
38
|
+
options: { required?: boolean; trim?: boolean } = {},
|
|
39
|
+
): string | undefined {
|
|
40
|
+
const value = args[key];
|
|
41
|
+
if (value === undefined || value === null) {
|
|
42
|
+
if (options.required) {
|
|
43
|
+
throw new Error(`Missing required parameter: ${key}`);
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Convert value to string safely
|
|
49
|
+
if (typeof value === "string") {
|
|
50
|
+
return options.trim !== false ? value.trim() : value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
54
|
+
const str = String(value);
|
|
55
|
+
return options.trim !== false ? str.trim() : str;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(`Parameter ${key} must be a string, number, or boolean`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Supported Twitch actions */
|
|
62
|
+
const TWITCH_ACTIONS = new Set(["send" as const]);
|
|
63
|
+
type TwitchAction = typeof TWITCH_ACTIONS extends Set<infer U> ? U : never;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Twitch message actions adapter.
|
|
67
|
+
*/
|
|
68
|
+
export const twitchMessageActions: ChannelMessageActionAdapter = {
|
|
69
|
+
/**
|
|
70
|
+
* List available actions for this channel.
|
|
71
|
+
*/
|
|
72
|
+
describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if an action is supported.
|
|
76
|
+
*/
|
|
77
|
+
supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction),
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract tool send parameters from action arguments.
|
|
81
|
+
*
|
|
82
|
+
* Parses and validates the "to" and "message" parameters for sending.
|
|
83
|
+
*
|
|
84
|
+
* @param params - Arguments from the tool call
|
|
85
|
+
* @returns Parsed send parameters or null if invalid
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* const result = twitchMessageActions.extractToolSend!({
|
|
89
|
+
* args: { to: "#mychannel", message: "Hello!" }
|
|
90
|
+
* });
|
|
91
|
+
* // Returns: { to: "#mychannel", message: "Hello!" }
|
|
92
|
+
*/
|
|
93
|
+
extractToolSend: ({ args }) => {
|
|
94
|
+
try {
|
|
95
|
+
const to = readStringParam(args, "to", { required: true });
|
|
96
|
+
const message = readStringParam(args, "message", { required: true });
|
|
97
|
+
|
|
98
|
+
if (!to || !message) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { to, message };
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Handle an action execution.
|
|
110
|
+
*
|
|
111
|
+
* Processes the "send" action to send messages to Twitch.
|
|
112
|
+
*
|
|
113
|
+
* @param ctx - Action context including action type, parameters, and config
|
|
114
|
+
* @returns Tool result with content or null if action not supported
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* const result = await twitchMessageActions.handleAction!({
|
|
118
|
+
* action: "send",
|
|
119
|
+
* params: { message: "Hello Twitch!", to: "#mychannel" },
|
|
120
|
+
* cfg: autobotConfig,
|
|
121
|
+
* accountId: "default",
|
|
122
|
+
* });
|
|
123
|
+
*/
|
|
124
|
+
handleAction: async (ctx: ChannelMessageActionContext) => {
|
|
125
|
+
if (ctx.action !== "send") {
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: "text" as const, text: "Unsupported action" }],
|
|
128
|
+
details: { ok: false, error: "Unsupported action" },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const message = readStringParam(ctx.params, "message", { required: true });
|
|
133
|
+
const to = readStringParam(ctx.params, "to", { required: false });
|
|
134
|
+
const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
|
|
135
|
+
|
|
136
|
+
const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
|
|
137
|
+
if (!account) {
|
|
138
|
+
return errorResponse(
|
|
139
|
+
`Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Use the channel from account config (or override with `to` parameter)
|
|
144
|
+
const targetChannel = to || account.channel;
|
|
145
|
+
if (!targetChannel) {
|
|
146
|
+
return errorResponse("No channel specified and no default channel in account config");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!twitchOutbound.sendText) {
|
|
150
|
+
return errorResponse("sendText not implemented");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const result = await twitchOutbound.sendText({
|
|
155
|
+
cfg: ctx.cfg,
|
|
156
|
+
to: targetChannel,
|
|
157
|
+
text: message ?? "",
|
|
158
|
+
accountId,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
content: [
|
|
163
|
+
{
|
|
164
|
+
type: "text" as const,
|
|
165
|
+
text: JSON.stringify(result),
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
details: { ok: true },
|
|
169
|
+
};
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const errorMsg = formatErrorMessage(error);
|
|
172
|
+
return errorResponse(errorMsg);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|