@openclaw/twitch 2026.5.7 → 2026.5.10-beta.1

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/dist/api.js CHANGED
@@ -1,3 +1,3 @@
1
- import { t as twitchPlugin } from "./plugin-BQX9GiIn.js";
1
+ import { t as twitchPlugin } from "./plugin-BLMSYnzs.js";
2
2
  import { n as setTwitchRuntime } from "./runtime-QZ5I3GlI.js";
3
3
  export { setTwitchRuntime, twitchPlugin };
@@ -1,2 +1,2 @@
1
- import { t as twitchPlugin } from "./plugin-BQX9GiIn.js";
1
+ import { t as twitchPlugin } from "./plugin-BLMSYnzs.js";
2
2
  export { twitchPlugin };
@@ -1,4 +1,4 @@
1
- import { i as normalizeToken, o as resolveTwitchToken } from "./twitch-CklAMZL5.js";
1
+ import { i as normalizeToken, o as resolveTwitchToken } from "./twitch-D0A1Bjui.js";
2
2
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
3
  import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
4
4
  import { ChatClient, LogLevel } from "@twurple/chat";
@@ -1,128 +1,132 @@
1
- import { i as getOrCreateClientManager, n as stripMarkdownForTwitch } from "./markdown-MRdI1sR7.js";
1
+ import { i as getOrCreateClientManager, n as stripMarkdownForTwitch } from "./markdown-CLNO0I2v.js";
2
2
  import { t as getTwitchRuntime } from "./runtime-QZ5I3GlI.js";
3
3
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4
4
  import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
5
- import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
5
+ import { createChannelIngressResolver, defineStableChannelIngressIdentity } from "openclaw/plugin-sdk/channel-ingress-runtime";
6
6
  //#region extensions/twitch/src/access-control.ts
7
- /**
8
- * Check if a Twitch message should be allowed based on account configuration
9
- *
10
- * This function implements the access control logic for incoming Twitch messages,
11
- * checking allowlists, role-based restrictions, and mention requirements.
12
- *
13
- * Priority order:
14
- * 1. If `requireMention` is true, message must mention the bot
15
- * 2. If `allowFrom` is set, sender must be in the allowlist (by user ID)
16
- * 3. If `allowedRoles` is set (and `allowFrom` is not), sender must have at least one role
17
- *
18
- * Note: `allowFrom` is a hard allowlist. When set, only those user IDs are allowed.
19
- * Use `allowedRoles` as an alternative when you don't want to maintain an allowlist.
20
- *
21
- * Available roles:
22
- * - "moderator": Moderators
23
- * - "owner": Channel owner/broadcaster
24
- * - "vip": VIPs
25
- * - "subscriber": Subscribers
26
- * - "all": Anyone in the chat
27
- */
28
- function checkTwitchAccessControl(params) {
7
+ const twitchUserIdentity = defineStableChannelIngressIdentity({
8
+ key: "sender-id",
9
+ entryIdPrefix: "twitch-user-entry"
10
+ });
11
+ const twitchRoleIdentity = defineStableChannelIngressIdentity({
12
+ key: "role-moderator",
13
+ kind: "role",
14
+ normalizeEntry: normalizeTwitchRole,
15
+ normalizeSubject: normalizeTwitchRole,
16
+ aliases: [
17
+ "owner",
18
+ "vip",
19
+ "subscriber"
20
+ ].map((role) => ({
21
+ key: `role-${role}`,
22
+ kind: "role",
23
+ normalizeEntry: () => null,
24
+ normalizeSubject: normalizeTwitchRole
25
+ })),
26
+ isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all",
27
+ resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}`
28
+ });
29
+ async function checkTwitchAccessControl(params) {
29
30
  const { message, account, botUsername } = params;
30
- if (account.requireMention ?? true) {
31
- if (!extractMentions(message.message).includes(normalizeLowercaseStringOrEmpty(botUsername))) return {
32
- allowed: false,
33
- reason: "message does not mention the bot (requireMention is enabled)"
34
- };
35
- }
36
- if (account.allowFrom !== void 0) {
37
- const allowFrom = account.allowFrom;
38
- if (allowFrom.length === 0) return {
39
- allowed: false,
40
- reason: "sender is not in allowFrom allowlist"
41
- };
42
- const senderId = message.userId;
43
- if (!senderId) return {
44
- allowed: false,
45
- reason: "sender user ID not available for allowlist check"
46
- };
47
- if (allowFrom.includes(senderId)) return {
31
+ const policyKind = resolveTwitchPolicyKind(account);
32
+ const decision = (await createChannelIngressResolver({
33
+ channelId: "twitch",
34
+ accountId: "default",
35
+ identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity
36
+ }).message({
37
+ subject: policyKind === "role" ? twitchRoleSubject(message) : { stableId: message.userId },
38
+ conversation: {
39
+ kind: "group",
40
+ id: message.channel
41
+ },
42
+ event: { mayPair: false },
43
+ mentionFacts: {
44
+ canDetectMention: true,
45
+ wasMentioned: mentionsBot(message.message, botUsername)
46
+ },
47
+ dmPolicy: "open",
48
+ groupPolicy: policyKind === "open" ? "open" : "allowlist",
49
+ policy: { activation: {
50
+ requireMention: account.requireMention ?? true,
51
+ allowTextCommands: false,
52
+ order: "before-sender"
53
+ } },
54
+ groupAllowFrom: policyKind === "allowFrom" ? account.allowFrom : policyKind === "role" ? account.allowedRoles : void 0
55
+ })).ingress;
56
+ if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") return {
57
+ allowed: false,
58
+ reason: "message does not mention the bot (requireMention is enabled)"
59
+ };
60
+ if (decision.admission === "dispatch") {
61
+ if (policyKind === "allowFrom") return {
48
62
  allowed: true,
49
- matchKey: senderId,
63
+ matchKey: params.message.userId,
50
64
  matchSource: "allowlist"
51
65
  };
52
- return {
53
- allowed: false,
54
- reason: "sender is not in allowFrom allowlist"
55
- };
56
- }
57
- if (account.allowedRoles && account.allowedRoles.length > 0) {
58
- const allowedRoles = account.allowedRoles;
59
- if (allowedRoles.includes("all")) return {
66
+ if (policyKind === "role") return {
60
67
  allowed: true,
61
- matchKey: "all",
68
+ matchKey: params.account.allowedRoles?.join(","),
62
69
  matchSource: "role"
63
70
  };
64
- if (!checkSenderRoles({
65
- message,
66
- allowedRoles
67
- })) return {
71
+ return { allowed: true };
72
+ }
73
+ if (policyKind === "allowFrom") {
74
+ if (!params.message.userId) return {
68
75
  allowed: false,
69
- reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`
76
+ reason: "sender user ID not available for allowlist check"
70
77
  };
71
78
  return {
72
- allowed: true,
73
- matchKey: allowedRoles.join(","),
74
- matchSource: "role"
79
+ allowed: false,
80
+ reason: "sender is not in allowFrom allowlist"
75
81
  };
76
82
  }
77
- return { allowed: true };
83
+ if (policyKind === "role") return {
84
+ allowed: false,
85
+ reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}`
86
+ };
87
+ return {
88
+ allowed: false,
89
+ reason: reasonForTwitchIngressDecision(decision)
90
+ };
78
91
  }
79
- /**
80
- * Check if the sender has any of the allowed roles
81
- */
82
- function checkSenderRoles(params) {
83
- const { message, allowedRoles } = params;
84
- const { isMod, isOwner, isVip, isSub } = message;
85
- for (const role of allowedRoles) switch (role) {
86
- case "moderator":
87
- if (isMod) return true;
88
- break;
89
- case "owner":
90
- if (isOwner) return true;
91
- break;
92
- case "vip":
93
- if (isVip) return true;
94
- break;
95
- case "subscriber":
96
- if (isSub) return true;
97
- break;
92
+ function resolveTwitchPolicyKind(account) {
93
+ if (account.allowFrom !== void 0) return "allowFrom";
94
+ if (account.allowedRoles && account.allowedRoles.length > 0) return "role";
95
+ return "open";
96
+ }
97
+ function twitchRoleSubject(message) {
98
+ return {
99
+ stableId: message.isMod ? "moderator" : void 0,
100
+ aliases: {
101
+ "role-owner": message.isOwner ? "owner" : void 0,
102
+ "role-vip": message.isVip ? "vip" : void 0,
103
+ "role-subscriber": message.isSub ? "subscriber" : void 0
104
+ }
105
+ };
106
+ }
107
+ function normalizeTwitchRole(value) {
108
+ const role = normalizeLowercaseStringOrEmpty(value);
109
+ if (role === "*") return "all";
110
+ return role === "moderator" || role === "owner" || role === "vip" || role === "subscriber" || role === "all" ? role : null;
111
+ }
112
+ function reasonForTwitchIngressDecision(decision) {
113
+ switch (decision.reasonCode) {
114
+ case "activation_skipped": return "message does not mention the bot (requireMention is enabled)";
115
+ case "group_policy_empty_allowlist":
116
+ case "group_policy_not_allowlisted": return "sender is not in allowFrom allowlist";
117
+ default: return decision.reasonCode;
98
118
  }
99
- return false;
100
119
  }
101
- /**
102
- * Extract @mentions from a Twitch chat message
103
- *
104
- * Returns a list of lowercase usernames that were mentioned in the message.
105
- * Twitch mentions are in the format @username.
106
- */
107
- function extractMentions(message) {
120
+ function mentionsBot(message, botUsername) {
121
+ const expected = normalizeLowercaseStringOrEmpty(botUsername);
108
122
  const mentionRegex = /@(\w+)/g;
109
- const mentions = [];
110
123
  let match;
111
- while ((match = mentionRegex.exec(message)) !== null) {
112
- const username = match[1];
113
- if (username) mentions.push(normalizeLowercaseStringOrEmpty(username));
114
- }
115
- return mentions;
124
+ while ((match = mentionRegex.exec(message)) !== null) if ((match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : "") === expected) return true;
125
+ return false;
116
126
  }
117
127
  //#endregion
118
128
  //#region extensions/twitch/src/monitor.ts
119
129
  /**
120
- * Twitch message monitor - processes incoming messages and routes to agents.
121
- *
122
- * This monitor connects to the Twitch client manager, processes incoming messages,
123
- * resolves agent routes, and handles replies.
124
- */
125
- /**
126
130
  * Process an incoming Twitch message and dispatch to agent.
127
131
  */
128
132
  async function processTwitchMessage(params) {
@@ -203,12 +207,6 @@ async function processTwitchMessage(params) {
203
207
  channel: "twitch",
204
208
  accountId
205
209
  });
206
- const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
207
- cfg,
208
- agentId: route.agentId,
209
- channel: "twitch",
210
- accountId
211
- });
212
210
  return {
213
211
  cfg,
214
212
  channel: "twitch",
@@ -220,24 +218,26 @@ async function processTwitchMessage(params) {
220
218
  recordInboundSession: core.channel.session.recordInboundSession,
221
219
  dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
222
220
  delivery: {
221
+ durable: () => ({ to: `twitch:channel:${message.channel}` }),
223
222
  deliver: async (payload) => {
224
- await deliverTwitchReply({
223
+ return await deliverTwitchReply({
225
224
  payload,
226
225
  channel: message.channel,
227
226
  account,
228
227
  accountId,
229
228
  config,
230
229
  tableMode,
231
- runtime,
232
- statusSink
230
+ runtime
233
231
  });
234
232
  },
233
+ onDelivered: (_payload, _info, result) => {
234
+ if (result?.visibleReplySent !== false) statusSink?.({ lastOutboundAt: Date.now() });
235
+ },
235
236
  onError: (err, info) => {
236
237
  runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
237
238
  }
238
239
  },
239
- dispatcherOptions: replyPipeline,
240
- replyOptions: { onModelSelected },
240
+ replyPipeline: {},
241
241
  record: { onRecordError: (err) => {
242
242
  runtime.error?.(`Failed updating session meta: ${String(err)}`);
243
243
  } }
@@ -250,7 +250,7 @@ async function processTwitchMessage(params) {
250
250
  * Deliver a reply to Twitch chat.
251
251
  */
252
252
  async function deliverTwitchReply(params) {
253
- const { payload, channel, account, accountId, config, runtime, statusSink } = params;
253
+ const { payload, channel, account, accountId, config, runtime } = params;
254
254
  try {
255
255
  const client = await getOrCreateClientManager(accountId, {
256
256
  info: (msg) => runtime.log?.(msg),
@@ -260,17 +260,18 @@ async function deliverTwitchReply(params) {
260
260
  }).getClient(account, config, accountId);
261
261
  if (!client) {
262
262
  runtime.error?.(`No client available for sending reply`);
263
- return;
263
+ return { visibleReplySent: false };
264
264
  }
265
265
  if (!payload.text) {
266
266
  runtime.error?.(`No text to send in reply payload`);
267
- return;
267
+ return { visibleReplySent: false };
268
268
  }
269
269
  const textToSend = stripMarkdownForTwitch(payload.text);
270
270
  await client.say(channel, textToSend);
271
- statusSink?.({ lastOutboundAt: Date.now() });
271
+ return { visibleReplySent: true };
272
272
  } catch (err) {
273
273
  runtime.error?.(`Failed to send reply: ${String(err)}`);
274
+ return { visibleReplySent: false };
274
275
  }
275
276
  }
276
277
  /**
@@ -302,23 +303,26 @@ async function monitorTwitchProvider(options) {
302
303
  }
303
304
  const unregisterHandler = clientManager.onMessage(account, (message) => {
304
305
  if (stopped) return;
305
- const botUsername = normalizeLowercaseStringOrEmpty(account.username);
306
- if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) return;
307
- if (!checkTwitchAccessControl({
308
- message,
309
- account,
310
- botUsername
311
- }).allowed) return;
312
- statusSink?.({ lastInboundAt: Date.now() });
313
- processTwitchMessage({
314
- message,
315
- account,
316
- accountId,
317
- config,
318
- runtime,
319
- core,
320
- statusSink
321
- }).catch((err) => {
306
+ (async () => {
307
+ const botUsername = normalizeLowercaseStringOrEmpty(account.username);
308
+ if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) return;
309
+ const access = await checkTwitchAccessControl({
310
+ message,
311
+ account,
312
+ botUsername
313
+ });
314
+ if (stopped || !access.allowed) return;
315
+ statusSink?.({ lastInboundAt: Date.now() });
316
+ await processTwitchMessage({
317
+ message,
318
+ account,
319
+ accountId,
320
+ config,
321
+ runtime,
322
+ core,
323
+ statusSink
324
+ });
325
+ })().catch((err) => {
322
326
  runtime.error?.(`Message processing failed: ${String(err)}`);
323
327
  });
324
328
  });
@@ -1,6 +1,6 @@
1
- import { a as normalizeTwitchChannel, i as normalizeToken, n as isAccountConfigured, o as resolveTwitchToken, r as missingTargetError, t as generateMessageId } from "./twitch-CklAMZL5.js";
2
- import { a as getAccountConfig, c as resolveTwitchAccountContext, i as DEFAULT_ACCOUNT_ID, l as resolveTwitchSnapshotAccountId, o as listAccountIds, r as twitchSetupWizard, s as resolveDefaultTwitchAccountId, t as twitchSetupAdapter } from "./setup-surface-yArVgckI.js";
3
- import { a as removeClientManager, n as stripMarkdownForTwitch, r as getClientManager, t as chunkTextForTwitch } from "./markdown-MRdI1sR7.js";
1
+ import { a as normalizeTwitchChannel, i as normalizeToken, n as isAccountConfigured, o as resolveTwitchToken, r as missingTargetError, t as generateMessageId } from "./twitch-D0A1Bjui.js";
2
+ import { a as getAccountConfig, c as resolveTwitchAccountContext, i as DEFAULT_ACCOUNT_ID, l as resolveTwitchSnapshotAccountId, o as listAccountIds, r as twitchSetupWizard, s as resolveDefaultTwitchAccountId, t as twitchSetupAdapter } from "./setup-surface-CDhELE-4.js";
3
+ import { a as removeClientManager, n as stripMarkdownForTwitch, r as getClientManager, t as chunkTextForTwitch } from "./markdown-CLNO0I2v.js";
4
4
  import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
5
5
  import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
6
6
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
@@ -8,14 +8,33 @@ import { createLoggedPairingApprovalNotifier, createPairingPrefixStripper } from
8
8
  import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
9
9
  import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
10
10
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
11
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
12
+ import { createMessageReceiptFromOutboundResults, defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message";
11
13
  import { StaticAuthProvider } from "@twurple/auth";
12
14
  import { ChatClient } from "@twurple/chat";
13
15
  import { MarkdownConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
14
16
  import { z } from "openclaw/plugin-sdk/zod";
15
17
  import { ApiClient } from "@twurple/api";
16
- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
17
18
  //#region extensions/twitch/src/send.ts
18
19
  /**
20
+ * Twitch message sending functions with dependency injection support.
21
+ *
22
+ * These functions are the primary interface for sending messages to Twitch.
23
+ * They support dependency injection via the `deps` parameter for testability.
24
+ */
25
+ function createTwitchSendReceipt(params) {
26
+ const messageId = params.messageId.trim();
27
+ const conversationId = params.channel?.trim();
28
+ return createMessageReceiptFromOutboundResults({
29
+ results: params.visible === true && messageId && messageId !== "skipped" ? [{
30
+ channel: "twitch",
31
+ messageId,
32
+ ...conversationId ? { conversationId } : {}
33
+ }] : [],
34
+ kind: "text"
35
+ });
36
+ }
37
+ /**
19
38
  * Internal send function used by the outbound adapter.
20
39
  *
21
40
  * This function has access to the full OpenClaw config and handles
@@ -44,47 +63,93 @@ async function sendMessageTwitchInternal(channel, text, cfg, accountId, stripMar
44
63
  if (!account) return {
45
64
  ok: false,
46
65
  messageId: generateMessageId(),
66
+ receipt: createTwitchSendReceipt({
67
+ messageId: "",
68
+ channel,
69
+ visible: false
70
+ }),
47
71
  error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`
48
72
  };
49
73
  if (!configured) return {
50
74
  ok: false,
51
75
  messageId: generateMessageId(),
76
+ receipt: createTwitchSendReceipt({
77
+ messageId: "",
78
+ channel,
79
+ visible: false
80
+ }),
52
81
  error: `Account ${resolvedAccountId} is not properly configured. Required: username, clientId, and token (config or env for default account).`
53
82
  };
54
83
  const normalizedChannel = channel || account.channel;
55
84
  if (!normalizedChannel) return {
56
85
  ok: false,
57
86
  messageId: generateMessageId(),
87
+ receipt: createTwitchSendReceipt({
88
+ messageId: "",
89
+ channel: normalizedChannel,
90
+ visible: false
91
+ }),
58
92
  error: "No channel specified and no default channel in account config"
59
93
  };
94
+ const deliveryChannel = normalizeTwitchChannel(normalizedChannel);
60
95
  const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
61
96
  if (!cleanedText) return {
62
97
  ok: true,
63
- messageId: "skipped"
98
+ messageId: "skipped",
99
+ receipt: createTwitchSendReceipt({
100
+ messageId: "skipped",
101
+ channel: deliveryChannel,
102
+ visible: false
103
+ })
64
104
  };
65
105
  const clientManager = getClientManager(resolvedAccountId);
66
106
  if (!clientManager) return {
67
107
  ok: false,
68
108
  messageId: generateMessageId(),
109
+ receipt: createTwitchSendReceipt({
110
+ messageId: "",
111
+ channel: deliveryChannel,
112
+ visible: false
113
+ }),
69
114
  error: `Client manager not found for account: ${resolvedAccountId}. Please start the Twitch gateway first.`
70
115
  };
71
116
  try {
72
- const result = await clientManager.sendMessage(account, normalizeTwitchChannel(normalizedChannel), cleanedText, cfg, resolvedAccountId);
73
- if (!result.ok) return {
74
- ok: false,
75
- messageId: result.messageId ?? generateMessageId(),
76
- error: result.error ?? "Send failed"
77
- };
117
+ const result = await clientManager.sendMessage(account, deliveryChannel, cleanedText, cfg, resolvedAccountId);
118
+ if (!result.ok) {
119
+ const messageId = result.messageId ?? generateMessageId();
120
+ return {
121
+ ok: false,
122
+ messageId,
123
+ receipt: createTwitchSendReceipt({
124
+ messageId,
125
+ channel: deliveryChannel,
126
+ visible: false
127
+ }),
128
+ error: result.error ?? "Send failed"
129
+ };
130
+ }
131
+ const messageId = result.messageId ?? generateMessageId();
78
132
  return {
79
133
  ok: true,
80
- messageId: result.messageId ?? generateMessageId()
134
+ messageId,
135
+ receipt: createTwitchSendReceipt({
136
+ messageId,
137
+ channel: deliveryChannel,
138
+ visible: true
139
+ })
81
140
  };
82
141
  } catch (error) {
83
142
  const errorMsg = formatErrorMessage(error);
143
+ const messageId = generateMessageId();
84
144
  logger.error(`Failed to send message: ${errorMsg}`);
85
145
  return {
86
146
  ok: false,
87
- messageId: generateMessageId(),
147
+ messageId,
148
+ receipt: createTwitchSendReceipt({
149
+ messageId,
150
+ channel: deliveryChannel,
151
+ visible: false
152
+ }),
88
153
  error: errorMsg
89
154
  };
90
155
  }
@@ -106,6 +171,11 @@ async function sendMessageTwitchInternal(channel, text, cfg, accountId, stripMar
106
171
  const twitchOutbound = {
107
172
  /** Direct delivery mode - messages are sent immediately */
108
173
  deliveryMode: "direct",
174
+ deliveryCapabilities: { durableFinal: {
175
+ text: true,
176
+ media: true,
177
+ messageSendingHooks: true
178
+ } },
109
179
  /** Twitch chat message limit is 500 characters */
110
180
  textChunkLimit: 500,
111
181
  /** Word-boundary chunker with markdown stripping */
@@ -184,6 +254,7 @@ const twitchOutbound = {
184
254
  return {
185
255
  channel: "twitch",
186
256
  messageId: result.messageId,
257
+ receipt: result.receipt,
187
258
  timestamp: Date.now()
188
259
  };
189
260
  },
@@ -216,6 +287,37 @@ const twitchOutbound = {
216
287
  });
217
288
  }
218
289
  };
290
+ function toTwitchMessageSendResult(result, kind) {
291
+ const receipt = result.receipt ?? createMessageReceiptFromOutboundResults({
292
+ results: result.messageId ? [{
293
+ channel: "twitch",
294
+ messageId: result.messageId
295
+ }] : [],
296
+ kind
297
+ });
298
+ return {
299
+ messageId: result.messageId || receipt.primaryPlatformMessageId,
300
+ receipt
301
+ };
302
+ }
303
+ const twitchMessageAdapter = defineChannelMessageAdapter({
304
+ id: "twitch",
305
+ durableFinal: { capabilities: {
306
+ text: true,
307
+ media: true,
308
+ messageSendingHooks: true
309
+ } },
310
+ send: {
311
+ text: async (ctx) => {
312
+ if (!twitchOutbound.sendText) throw new Error("Twitch text sending is not available.");
313
+ return toTwitchMessageSendResult(await twitchOutbound.sendText(ctx), "text");
314
+ },
315
+ media: async (ctx) => {
316
+ if (!twitchOutbound.sendMedia) throw new Error("Twitch media sending is not available.");
317
+ return toTwitchMessageSendResult(await twitchOutbound.sendMedia(ctx), "media");
318
+ }
319
+ }
320
+ });
219
321
  //#endregion
220
322
  //#region extensions/twitch/src/actions.ts
221
323
  /**
@@ -779,6 +881,7 @@ const twitchPlugin = createChatChannelPlugin({
779
881
  setup: twitchSetupAdapter,
780
882
  setupWizard: twitchSetupWizard,
781
883
  capabilities: { chatTypes: ["group"] },
884
+ message: twitchMessageAdapter,
782
885
  configSchema: buildChannelConfigSchema(TwitchConfigSchema),
783
886
  config: {
784
887
  listAccountIds: (cfg) => listAccountIds(cfg),
@@ -851,7 +954,7 @@ const twitchPlugin = createChatChannelPlugin({
851
954
  lastError: null
852
955
  });
853
956
  ctx.log?.info(`Starting Twitch connection for ${account.username}`);
854
- const { monitorTwitchProvider } = await import("./monitor-DS0YTAPB.js");
957
+ const { monitorTwitchProvider } = await import("./monitor-DvbgvvL4.js");
855
958
  await monitorTwitchProvider({
856
959
  account,
857
960
  accountId,
@@ -1,2 +1,2 @@
1
- import { n as twitchSetupPlugin } from "./setup-surface-yArVgckI.js";
1
+ import { n as twitchSetupPlugin } from "./setup-surface-CDhELE-4.js";
2
2
  export { twitchSetupPlugin };
@@ -1,4 +1,4 @@
1
- import { n as isAccountConfigured, o as resolveTwitchToken } from "./twitch-CklAMZL5.js";
1
+ import { n as isAccountConfigured, o as resolveTwitchToken } from "./twitch-D0A1Bjui.js";
2
2
  import { listCombinedAccountIds, normalizeAccountId, resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution";
3
3
  import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
4
4
  import { getChatChannelMeta } from "openclaw/plugin-sdk/core";
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
3
4
  //#region extensions/twitch/src/token.ts
4
5
  /**
5
6
  * Twitch access token resolution with environment variable support.
@@ -61,9 +62,6 @@ function resolveTwitchToken(cfg, opts = {}) {
61
62
  /**
62
63
  * Twitch-specific utility functions
63
64
  */
64
- function normalizeLowercaseStringOrEmpty(value) {
65
- return typeof value === "string" ? value.trim().toLowerCase() : "";
66
- }
67
65
  /**
68
66
  * Normalize Twitch channel names.
69
67
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/twitch",
3
- "version": "2026.5.7",
3
+ "version": "2026.5.10-beta.1",
4
4
  "description": "OpenClaw Twitch channel plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,10 +26,10 @@
26
26
  "minHostVersion": ">=2026.4.10"
27
27
  },
28
28
  "compat": {
29
- "pluginApi": ">=2026.5.7"
29
+ "pluginApi": ">=2026.5.10-beta.1"
30
30
  },
31
31
  "build": {
32
- "openclawVersion": "2026.5.7"
32
+ "openclawVersion": "2026.5.10-beta.1"
33
33
  },
34
34
  "channel": {
35
35
  "id": "twitch",
@@ -56,7 +56,7 @@
56
56
  "README.md"
57
57
  ],
58
58
  "peerDependencies": {
59
- "openclaw": ">=2026.5.7"
59
+ "openclaw": ">=2026.5.10-beta.1"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "openclaw": {