@openclaw/twitch 2026.2.21 → 2026.5.2-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.
Files changed (45) hide show
  1. package/README.md +2 -2
  2. package/api.ts +21 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/index.test.ts +13 -0
  5. package/index.ts +12 -16
  6. package/openclaw.plugin.json +224 -1
  7. package/package.json +36 -7
  8. package/runtime-api.ts +22 -0
  9. package/setup-entry.ts +9 -0
  10. package/setup-plugin-api.ts +3 -0
  11. package/src/access-control.test.ts +136 -239
  12. package/src/access-control.ts +11 -4
  13. package/src/actions.test.ts +74 -0
  14. package/src/actions.ts +7 -6
  15. package/src/client-manager-registry.ts +0 -28
  16. package/src/config-schema.test.ts +46 -0
  17. package/src/config-schema.ts +25 -21
  18. package/src/config.test.ts +147 -1
  19. package/src/config.ts +76 -15
  20. package/src/monitor.ts +126 -85
  21. package/src/outbound.test.ts +140 -75
  22. package/src/outbound.ts +4 -5
  23. package/src/plugin.test.ts +39 -1
  24. package/src/plugin.ts +176 -242
  25. package/src/probe.test.ts +1 -1
  26. package/src/probe.ts +16 -5
  27. package/src/resolver.ts +5 -3
  28. package/src/runtime.ts +8 -13
  29. package/src/send.test.ts +92 -59
  30. package/src/send.ts +18 -15
  31. package/src/setup-surface.test.ts +511 -0
  32. package/src/setup-surface.ts +520 -0
  33. package/src/status.test.ts +120 -153
  34. package/src/status.ts +1 -1
  35. package/src/test-fixtures.ts +1 -1
  36. package/src/token.test.ts +22 -1
  37. package/src/token.ts +8 -6
  38. package/src/twitch-client.test.ts +18 -25
  39. package/src/twitch-client.ts +5 -6
  40. package/src/types.ts +7 -46
  41. package/src/utils/twitch.ts +8 -2
  42. package/tsconfig.json +16 -0
  43. package/CHANGELOG.md +0 -21
  44. package/src/onboarding.test.ts +0 -316
  45. package/src/onboarding.ts +0 -417
@@ -0,0 +1,46 @@
1
+ import AjvPkg from "ajv";
2
+ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
3
+ import { describe, expect, it } from "vitest";
4
+ import { TwitchConfigSchema } from "./config-schema.js";
5
+
6
+ function validateTwitchConfig(value: unknown): boolean {
7
+ const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
8
+ const schema = buildChannelConfigSchema(TwitchConfigSchema).schema;
9
+ const validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
10
+ const ok = validate(value);
11
+ if (!ok) {
12
+ throw new Error(`expected valid Twitch config: ${JSON.stringify(validate.errors)}`);
13
+ }
14
+ return true;
15
+ }
16
+
17
+ describe("TwitchConfigSchema JSON schema", () => {
18
+ it("accepts single-account channel config with base fields", () => {
19
+ expect(
20
+ validateTwitchConfig({
21
+ enabled: false,
22
+ username: "openclaw",
23
+ accessToken: "oauth:test",
24
+ clientId: "test-client-id",
25
+ channel: "openclaw-test",
26
+ }),
27
+ ).toBe(true);
28
+ });
29
+
30
+ it("accepts multi-account channel config with defaultAccount", () => {
31
+ expect(
32
+ validateTwitchConfig({
33
+ enabled: true,
34
+ defaultAccount: "stream",
35
+ accounts: {
36
+ stream: {
37
+ username: "openclaw",
38
+ accessToken: "oauth:test",
39
+ clientId: "test-client-id",
40
+ channel: "openclaw-test",
41
+ },
42
+ },
43
+ }),
44
+ ).toBe(true);
45
+ });
46
+ });
@@ -1,15 +1,12 @@
1
- import { MarkdownConfigSchema } from "openclaw/plugin-sdk";
2
- import { z } from "zod";
1
+ import { MarkdownConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
2
+ import { z } from "openclaw/plugin-sdk/zod";
3
3
 
4
4
  /**
5
5
  * Twitch user roles that can be allowed to interact with the bot
6
6
  */
7
7
  const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all"]);
8
8
 
9
- /**
10
- * Twitch account configuration schema
11
- */
12
- const TwitchAccountSchema = z.object({
9
+ const TwitchAccountShape = {
13
10
  /** Twitch username */
14
11
  username: z.string(),
15
12
  /** Twitch OAuth access token (requires chat:read and chat:write scopes) */
@@ -36,16 +33,22 @@ const TwitchAccountSchema = z.object({
36
33
  expiresIn: z.number().nullable().optional(),
37
34
  /** Timestamp when token was obtained (optional, for token refresh tracking) */
38
35
  obtainmentTimestamp: z.number().optional(),
39
- });
36
+ };
37
+
38
+ /**
39
+ * Twitch account configuration schema
40
+ */
41
+ const TwitchAccountSchema = z.object(TwitchAccountShape);
40
42
 
41
43
  /**
42
44
  * Base configuration properties shared by both single and multi-account modes
43
45
  */
44
- const TwitchConfigBaseSchema = z.object({
46
+ const TwitchConfigBaseShape = {
45
47
  name: z.string().optional(),
46
48
  enabled: z.boolean().optional(),
47
49
  markdown: MarkdownConfigSchema.optional(),
48
- });
50
+ defaultAccount: z.string().optional(),
51
+ };
49
52
 
50
53
  /**
51
54
  * Simplified single-account configuration schema
@@ -53,24 +56,25 @@ const TwitchConfigBaseSchema = z.object({
53
56
  * Use this for single-account setups. Properties are at the top level,
54
57
  * creating an implicit "default" account.
55
58
  */
56
- const SimplifiedSchema = z.intersection(TwitchConfigBaseSchema, TwitchAccountSchema);
59
+ const SimplifiedSchema = z.object({
60
+ ...TwitchConfigBaseShape,
61
+ ...TwitchAccountShape,
62
+ });
57
63
 
58
64
  /**
59
65
  * Multi-account configuration schema
60
66
  *
61
67
  * Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
62
68
  */
63
- const MultiAccountSchema = z.intersection(
64
- TwitchConfigBaseSchema,
65
- z
66
- .object({
67
- /** Per-account configuration (for multi-account setups) */
68
- accounts: z.record(z.string(), TwitchAccountSchema),
69
- })
70
- .refine((val) => Object.keys(val.accounts || {}).length > 0, {
71
- message: "accounts must contain at least one entry",
72
- }),
73
- );
69
+ const MultiAccountSchema = z
70
+ .object({
71
+ ...TwitchConfigBaseShape,
72
+ /** Per-account configuration (for multi-account setups) */
73
+ accounts: z.record(z.string(), TwitchAccountSchema),
74
+ })
75
+ .refine((val) => Object.keys(val.accounts || {}).length > 0, {
76
+ message: "accounts must contain at least one entry",
77
+ });
74
78
 
75
79
  /**
76
80
  * Twitch plugin configuration schema
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { getAccountConfig } from "./config.js";
2
+ import {
3
+ getAccountConfig,
4
+ listAccountIds,
5
+ resolveDefaultTwitchAccountId,
6
+ resolveTwitchAccountContext,
7
+ } from "./config.js";
3
8
 
4
9
  describe("getAccountConfig", () => {
5
10
  const mockMultiAccountConfig = {
@@ -49,6 +54,30 @@ describe("getAccountConfig", () => {
49
54
  expect(result?.username).toBe("secondbot");
50
55
  });
51
56
 
57
+ it("normalizes account ids without reading inherited account properties", () => {
58
+ const accounts = Object.create({
59
+ inherited: {
60
+ username: "inherited-bot",
61
+ accessToken: "oauth:inherited",
62
+ },
63
+ }) as Record<string, unknown>;
64
+ accounts.Secondary = {
65
+ username: "secondbot",
66
+ accessToken: "oauth:secondary",
67
+ };
68
+
69
+ const cfg = {
70
+ channels: {
71
+ twitch: {
72
+ accounts,
73
+ },
74
+ },
75
+ };
76
+
77
+ expect(getAccountConfig(cfg, "SECONDARY\r\n")).toMatchObject({ username: "secondbot" });
78
+ expect(getAccountConfig(cfg, "inherited")).toBeNull();
79
+ });
80
+
52
81
  it("returns null for non-existent account ID", () => {
53
82
  const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
54
83
 
@@ -85,3 +114,120 @@ describe("getAccountConfig", () => {
85
114
  expect(result).toBeNull();
86
115
  });
87
116
  });
117
+
118
+ describe("listAccountIds", () => {
119
+ it("includes the implicit default account from simplified config", () => {
120
+ expect(
121
+ listAccountIds({
122
+ channels: {
123
+ twitch: {
124
+ username: "testbot",
125
+ accessToken: "oauth:test123",
126
+ },
127
+ },
128
+ } as Parameters<typeof listAccountIds>[0]),
129
+ ).toEqual(["default"]);
130
+ });
131
+
132
+ it("combines explicit accounts with the implicit default account once", () => {
133
+ expect(
134
+ listAccountIds({
135
+ channels: {
136
+ twitch: {
137
+ username: "testbot",
138
+ accounts: {
139
+ default: { username: "testbot" },
140
+ secondary: { username: "secondbot" },
141
+ },
142
+ },
143
+ },
144
+ } as Parameters<typeof listAccountIds>[0]),
145
+ ).toEqual(["default", "secondary"]);
146
+ });
147
+
148
+ it("normalizes configured account ids", () => {
149
+ expect(
150
+ listAccountIds({
151
+ channels: {
152
+ twitch: {
153
+ accounts: {
154
+ Secondary: { username: "secondbot" },
155
+ "Alerts\r\n\u001b[31m": { username: "alerts" },
156
+ },
157
+ },
158
+ },
159
+ } as Parameters<typeof listAccountIds>[0]),
160
+ ).toEqual(["alerts-31m", "secondary"]);
161
+ });
162
+ });
163
+
164
+ describe("resolveDefaultTwitchAccountId", () => {
165
+ it("prefers channels.twitch.defaultAccount when configured", () => {
166
+ expect(
167
+ resolveDefaultTwitchAccountId({
168
+ channels: {
169
+ twitch: {
170
+ defaultAccount: "secondary",
171
+ accounts: {
172
+ default: { username: "default" },
173
+ secondary: { username: "secondary" },
174
+ },
175
+ },
176
+ },
177
+ } as Parameters<typeof resolveDefaultTwitchAccountId>[0]),
178
+ ).toBe("secondary");
179
+ });
180
+ });
181
+
182
+ describe("resolveTwitchAccountContext", () => {
183
+ it("uses configured defaultAccount when accountId is omitted", () => {
184
+ const context = resolveTwitchAccountContext({
185
+ channels: {
186
+ twitch: {
187
+ defaultAccount: "secondary",
188
+ accounts: {
189
+ default: {
190
+ username: "default-bot",
191
+ accessToken: "oauth:default-token",
192
+ },
193
+ secondary: {
194
+ username: "second-bot",
195
+ accessToken: "oauth:second-token",
196
+ },
197
+ },
198
+ },
199
+ },
200
+ } as Parameters<typeof resolveTwitchAccountContext>[0]);
201
+
202
+ expect(context.accountId).toBe("secondary");
203
+ expect(context.account?.username).toBe("second-bot");
204
+ });
205
+
206
+ it("keeps account and token lookup aligned after account id normalization", () => {
207
+ const context = resolveTwitchAccountContext(
208
+ {
209
+ channels: {
210
+ twitch: {
211
+ accounts: {
212
+ Secondary: {
213
+ username: "second-bot",
214
+ accessToken: "oauth:second-token",
215
+ clientId: "second-client",
216
+ channel: "#second",
217
+ },
218
+ },
219
+ },
220
+ },
221
+ } as Parameters<typeof resolveTwitchAccountContext>[0],
222
+ "secondary",
223
+ );
224
+
225
+ expect(context.accountId).toBe("secondary");
226
+ expect(context.account?.username).toBe("second-bot");
227
+ expect(context.tokenResolution).toEqual({
228
+ token: "oauth:second-token",
229
+ source: "config",
230
+ });
231
+ expect(context.configured).toBe(true);
232
+ });
233
+ });
package/src/config.ts CHANGED
@@ -1,11 +1,26 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import {
2
+ listCombinedAccountIds,
3
+ normalizeAccountId,
4
+ resolveNormalizedAccountEntry,
5
+ } from "openclaw/plugin-sdk/account-resolution";
6
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
7
+ import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
2
8
  import type { TwitchAccountConfig } from "./types.js";
9
+ import { isAccountConfigured } from "./utils/twitch.js";
3
10
 
4
11
  /**
5
12
  * Default account ID for Twitch
6
13
  */
7
14
  export const DEFAULT_ACCOUNT_ID = "default";
8
15
 
16
+ export type ResolvedTwitchAccountContext = {
17
+ accountId: string;
18
+ account: TwitchAccountConfig | null;
19
+ tokenResolution: TwitchTokenResolution;
20
+ configured: boolean;
21
+ availableAccountIds: string[];
22
+ };
23
+
9
24
  /**
10
25
  * Get account config from core config
11
26
  *
@@ -25,14 +40,19 @@ export function getAccountConfig(
25
40
  }
26
41
 
27
42
  const cfg = coreConfig as OpenClawConfig;
43
+ const normalizedAccountId = normalizeAccountId(accountId);
28
44
  const twitch = cfg.channels?.twitch;
29
45
  // Access accounts via unknown to handle union type (single-account vs multi-account)
30
46
  const twitchRaw = twitch as Record<string, unknown> | undefined;
31
47
  const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
32
48
 
33
49
  // For default account, check base-level config first
34
- if (accountId === DEFAULT_ACCOUNT_ID) {
35
- const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID];
50
+ if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
51
+ const accountFromAccounts = resolveNormalizedAccountEntry(
52
+ accounts,
53
+ DEFAULT_ACCOUNT_ID,
54
+ normalizeAccountId,
55
+ );
36
56
 
37
57
  // Base-level properties that can form an implicit default account
38
58
  const baseLevel = {
@@ -76,11 +96,12 @@ export function getAccountConfig(
76
96
  }
77
97
 
78
98
  // For non-default accounts, only check accounts object
79
- if (!accounts || !accounts[accountId]) {
99
+ const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
100
+ if (!account) {
80
101
  return null;
81
102
  }
82
103
 
83
- return accounts[accountId] as TwitchAccountConfig | null;
104
+ return account;
84
105
  }
85
106
 
86
107
  /**
@@ -94,13 +115,6 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
94
115
  const twitchRaw = twitch as Record<string, unknown> | undefined;
95
116
  const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
96
117
 
97
- const ids: string[] = [];
98
-
99
- // Add explicit accounts
100
- if (accountMap) {
101
- ids.push(...Object.keys(accountMap));
102
- }
103
-
104
118
  // Add implicit "default" if base-level config exists and "default" not already present
105
119
  const hasBaseLevelConfig =
106
120
  twitchRaw &&
@@ -108,9 +122,56 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
108
122
  typeof twitchRaw.accessToken === "string" ||
109
123
  typeof twitchRaw.channel === "string");
110
124
 
111
- if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) {
112
- ids.push(DEFAULT_ACCOUNT_ID);
125
+ return listCombinedAccountIds({
126
+ configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
127
+ normalizeAccountId(accountId),
128
+ ),
129
+ implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
130
+ });
131
+ }
132
+
133
+ export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string {
134
+ const preferredRaw =
135
+ typeof cfg.channels?.twitch?.defaultAccount === "string"
136
+ ? cfg.channels.twitch.defaultAccount.trim()
137
+ : "";
138
+ const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
139
+ const ids = listAccountIds(cfg);
140
+ if (preferred && ids.includes(preferred)) {
141
+ return preferred;
142
+ }
143
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) {
144
+ return DEFAULT_ACCOUNT_ID;
113
145
  }
146
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
147
+ }
148
+
149
+ export function resolveTwitchAccountContext(
150
+ cfg: OpenClawConfig,
151
+ accountId?: string | null,
152
+ ): ResolvedTwitchAccountContext {
153
+ const resolvedAccountId = accountId?.trim()
154
+ ? normalizeAccountId(accountId)
155
+ : resolveDefaultTwitchAccountId(cfg);
156
+ const account = getAccountConfig(cfg, resolvedAccountId);
157
+ const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
158
+ return {
159
+ accountId: resolvedAccountId,
160
+ account,
161
+ tokenResolution,
162
+ configured: account ? isAccountConfigured(account, tokenResolution.token) : false,
163
+ availableAccountIds: listAccountIds(cfg),
164
+ };
165
+ }
114
166
 
115
- return ids;
167
+ export function resolveTwitchSnapshotAccountId(
168
+ cfg: OpenClawConfig,
169
+ account: TwitchAccountConfig,
170
+ ): string {
171
+ const twitch = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
172
+ const twitchCfg = twitch?.twitch as Record<string, unknown> | undefined;
173
+ const accountMap = (twitchCfg?.accounts as Record<string, unknown> | undefined) ?? {};
174
+ return (
175
+ Object.entries(accountMap).find(([, value]) => value === account)?.[0] ?? DEFAULT_ACCOUNT_ID
176
+ );
116
177
  }
package/src/monitor.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  * resolves agent routes, and handles replies.
6
6
  */
7
7
 
8
- import type { ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk";
9
- import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
8
+ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
9
+ import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
10
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
11
+ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
12
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
10
13
  import { checkTwitchAccessControl } from "./access-control.js";
11
14
  import { getOrCreateClientManager } from "./client-manager-registry.js";
12
15
  import { getTwitchRuntime } from "./runtime.js";
@@ -48,91 +51,129 @@ async function processTwitchMessage(params: {
48
51
  const { message, account, accountId, config, runtime, core, statusSink } = params;
49
52
  const cfg = config as OpenClawConfig;
50
53
 
51
- const route = core.channel.routing.resolveAgentRoute({
52
- cfg,
54
+ await core.channel.turn.run({
53
55
  channel: "twitch",
54
56
  accountId,
55
- peer: {
56
- kind: "group", // Twitch chat is always group-like
57
- id: message.channel,
58
- },
59
- });
60
-
61
- const rawBody = message.message;
62
- const body = core.channel.reply.formatAgentEnvelope({
63
- channel: "Twitch",
64
- from: message.displayName ?? message.username,
65
- timestamp: message.timestamp?.getTime(),
66
- envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
67
- body: rawBody,
68
- });
69
-
70
- const ctxPayload = core.channel.reply.finalizeInboundContext({
71
- Body: body,
72
- BodyForAgent: rawBody,
73
- RawBody: rawBody,
74
- CommandBody: rawBody,
75
- From: `twitch:user:${message.userId}`,
76
- To: `twitch:channel:${message.channel}`,
77
- SessionKey: route.sessionKey,
78
- AccountId: route.accountId,
79
- ChatType: "group",
80
- ConversationLabel: message.channel,
81
- SenderName: message.displayName ?? message.username,
82
- SenderId: message.userId,
83
- SenderUsername: message.username,
84
- Provider: "twitch",
85
- Surface: "twitch",
86
- MessageSid: message.id,
87
- OriginatingChannel: "twitch",
88
- OriginatingTo: `twitch:channel:${message.channel}`,
89
- });
90
-
91
- const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
92
- agentId: route.agentId,
93
- });
94
- await core.channel.session.recordInboundSession({
95
- storePath,
96
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
97
- ctx: ctxPayload,
98
- onRecordError: (err) => {
99
- runtime.error?.(`Failed updating session meta: ${String(err)}`);
100
- },
101
- });
102
-
103
- const tableMode = core.channel.text.resolveMarkdownTableMode({
104
- cfg,
105
- channel: "twitch",
106
- accountId,
107
- });
108
- const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
109
- cfg,
110
- agentId: route.agentId,
111
- channel: "twitch",
112
- accountId,
113
- });
114
-
115
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
116
- ctx: ctxPayload,
117
- cfg,
118
- dispatcherOptions: {
119
- ...prefixOptions,
120
- deliver: async (payload) => {
121
- await deliverTwitchReply({
122
- payload,
123
- channel: message.channel,
124
- account,
57
+ raw: message,
58
+ adapter: {
59
+ ingest: (incoming) => ({
60
+ id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`,
61
+ timestamp: incoming.timestamp?.getTime(),
62
+ rawText: incoming.message,
63
+ textForAgent: incoming.message,
64
+ textForCommands: incoming.message,
65
+ raw: incoming,
66
+ }),
67
+ resolveTurn: (input) => {
68
+ const route = core.channel.routing.resolveAgentRoute({
69
+ cfg,
70
+ channel: "twitch",
71
+ accountId,
72
+ peer: {
73
+ kind: "group",
74
+ id: message.channel,
75
+ },
76
+ });
77
+ const senderId = message.userId ?? message.username;
78
+ const fromLabel = message.displayName ?? message.username;
79
+ const body = core.channel.reply.formatAgentEnvelope({
80
+ channel: "Twitch",
81
+ from: fromLabel,
82
+ timestamp: input.timestamp,
83
+ envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
84
+ body: input.rawText,
85
+ });
86
+ const ctxPayload = core.channel.turn.buildContext({
87
+ channel: "twitch",
125
88
  accountId,
126
- config,
127
- tableMode,
128
- runtime,
129
- statusSink,
89
+ messageId: input.id,
90
+ timestamp: input.timestamp,
91
+ from: `twitch:user:${senderId}`,
92
+ sender: {
93
+ id: senderId,
94
+ name: fromLabel,
95
+ username: message.username,
96
+ },
97
+ conversation: {
98
+ kind: "group",
99
+ id: message.channel,
100
+ label: message.channel,
101
+ routePeer: {
102
+ kind: "group",
103
+ id: message.channel,
104
+ },
105
+ },
106
+ route: {
107
+ agentId: route.agentId,
108
+ accountId: route.accountId,
109
+ routeSessionKey: route.sessionKey,
110
+ },
111
+ reply: {
112
+ to: `twitch:channel:${message.channel}`,
113
+ originatingTo: `twitch:channel:${message.channel}`,
114
+ },
115
+ message: {
116
+ body,
117
+ rawBody: input.rawText,
118
+ bodyForAgent: input.textForAgent,
119
+ commandBody: input.textForCommands,
120
+ envelopeFrom: fromLabel,
121
+ },
130
122
  });
123
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
124
+ agentId: route.agentId,
125
+ });
126
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
127
+ cfg,
128
+ channel: "twitch",
129
+ accountId,
130
+ });
131
+ const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
132
+ cfg,
133
+ agentId: route.agentId,
134
+ channel: "twitch",
135
+ accountId,
136
+ });
137
+ return {
138
+ cfg,
139
+ channel: "twitch",
140
+ accountId,
141
+ agentId: route.agentId,
142
+ routeSessionKey: route.sessionKey,
143
+ storePath,
144
+ ctxPayload,
145
+ recordInboundSession: core.channel.session.recordInboundSession,
146
+ dispatchReplyWithBufferedBlockDispatcher:
147
+ core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
148
+ delivery: {
149
+ deliver: async (payload) => {
150
+ await deliverTwitchReply({
151
+ payload,
152
+ channel: message.channel,
153
+ account,
154
+ accountId,
155
+ config,
156
+ tableMode,
157
+ runtime,
158
+ statusSink,
159
+ });
160
+ },
161
+ onError: (err, info) => {
162
+ runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
163
+ },
164
+ },
165
+ dispatcherOptions: replyPipeline,
166
+ replyOptions: {
167
+ onModelSelected,
168
+ },
169
+ record: {
170
+ onRecordError: (err) => {
171
+ runtime.error?.(`Failed updating session meta: ${String(err)}`);
172
+ },
173
+ },
174
+ };
131
175
  },
132
176
  },
133
- replyOptions: {
134
- onModelSelected,
135
- },
136
177
  });
137
178
  }
138
179
 
@@ -145,7 +186,7 @@ async function deliverTwitchReply(params: {
145
186
  account: TwitchAccountConfig;
146
187
  accountId: string;
147
188
  config: unknown;
148
- tableMode: "off" | "plain" | "markdown" | "bullets" | "code";
189
+ tableMode: MarkdownTableMode;
149
190
  runtime: TwitchRuntimeEnv;
150
191
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
151
192
  }): Promise<void> {
@@ -220,7 +261,7 @@ export async function monitorTwitchProvider(
220
261
  accountId,
221
262
  );
222
263
  } catch (error) {
223
- const errorMsg = error instanceof Error ? error.message : String(error);
264
+ const errorMsg = formatErrorMessage(error);
224
265
  runtime.error?.(`Failed to connect: ${errorMsg}`);
225
266
  throw error;
226
267
  }
@@ -231,8 +272,8 @@ export async function monitorTwitchProvider(
231
272
  }
232
273
 
233
274
  // Access control check
234
- const botUsername = account.username.toLowerCase();
235
- if (message.username.toLowerCase() === botUsername) {
275
+ const botUsername = normalizeLowercaseStringOrEmpty(account.username);
276
+ if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) {
236
277
  return; // Ignore own messages
237
278
  }
238
279