@invago/mixin 1.0.8 → 1.0.9

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/src/config.ts CHANGED
@@ -1,26 +1,60 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { MixinAccountConfigSchema, type MixinAccountConfig, type MixinConfig } from "./config-schema.js";
2
+ import {
3
+ MixinAccountConfigSchema,
4
+ MixinConversationConfigSchema,
5
+ type MixinAccountConfig,
6
+ type MixinConversationConfig,
7
+ } from "./config-schema.js";
3
8
 
4
- function getRawConfig(cfg: OpenClawConfig): any {
5
- return (cfg.channels as Record<string, unknown>)?.mixin ?? {};
9
+ type RawMixinConfig = Partial<MixinAccountConfig> & {
10
+ defaultAccount?: string;
11
+ accounts?: Record<string, Partial<MixinAccountConfig> | undefined>;
12
+ };
13
+
14
+ function getRawConfig(cfg: OpenClawConfig): RawMixinConfig {
15
+ return ((cfg.channels as Record<string, unknown>)?.mixin ?? {}) as RawMixinConfig;
6
16
  }
7
17
 
8
- export function listAccountIds(cfg: OpenClawConfig): string[] {
18
+ function hasTopLevelAccountConfig(raw: RawMixinConfig): boolean {
19
+ return Boolean(raw.appId || raw.sessionId || raw.serverPublicKey || raw.sessionPrivateKey || raw.name);
20
+ }
21
+
22
+ export function resolveDefaultAccountId(cfg: OpenClawConfig): string {
9
23
  const raw = getRawConfig(cfg);
24
+ const configuredDefault = raw.defaultAccount?.trim();
25
+ if (configuredDefault && raw.accounts?.[configuredDefault]) {
26
+ return configuredDefault;
27
+ }
28
+ if (configuredDefault === "default") {
29
+ return "default";
30
+ }
10
31
  if (raw.accounts && Object.keys(raw.accounts).length > 0) {
11
- return Object.keys(raw.accounts);
32
+ if (hasTopLevelAccountConfig(raw)) {
33
+ return "default";
34
+ }
35
+ return Object.keys(raw.accounts)[0] ?? "default";
12
36
  }
13
- return ["default"];
37
+ return "default";
38
+ }
39
+
40
+ export function listAccountIds(cfg: OpenClawConfig): string[] {
41
+ const raw = getRawConfig(cfg);
42
+ const accountIds = raw.accounts ? Object.keys(raw.accounts) : [];
43
+ if (hasTopLevelAccountConfig(raw) || accountIds.length === 0) {
44
+ return ["default", ...accountIds.filter((accountId) => accountId !== "default")];
45
+ }
46
+ return accountIds;
14
47
  }
15
48
 
16
49
  export function getAccountConfig(cfg: OpenClawConfig, accountId?: string): MixinAccountConfig {
17
50
  const raw = getRawConfig(cfg);
51
+ const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg);
18
52
  let accountRaw: Partial<MixinAccountConfig>;
19
53
 
20
- if (accountId && accountId !== "default" && raw.accounts?.[accountId]) {
21
- accountRaw = raw.accounts[accountId] as Partial<MixinAccountConfig>;
54
+ if (resolvedAccountId !== "default" && raw.accounts?.[resolvedAccountId]) {
55
+ accountRaw = raw.accounts[resolvedAccountId] as Partial<MixinAccountConfig>;
22
56
  } else {
23
- accountRaw = raw as MixinConfig as Partial<MixinAccountConfig>;
57
+ accountRaw = raw;
24
58
  }
25
59
 
26
60
  const result = MixinAccountConfigSchema.safeParse(accountRaw);
@@ -29,7 +63,7 @@ export function getAccountConfig(cfg: OpenClawConfig, accountId?: string): Mixin
29
63
  }
30
64
 
31
65
  export function resolveAccount(cfg: OpenClawConfig, accountId?: string) {
32
- const id = accountId ?? "default";
66
+ const id = accountId ?? resolveDefaultAccountId(cfg);
33
67
  const config = getAccountConfig(cfg, id);
34
68
  const configured = Boolean(config.appId && config.sessionId && config.serverPublicKey && config.sessionPrivateKey);
35
69
  return {
@@ -49,6 +83,60 @@ export function resolveAccount(cfg: OpenClawConfig, accountId?: string) {
49
83
  };
50
84
  }
51
85
 
86
+ export function resolveMediaMaxMb(cfg: OpenClawConfig, accountId?: string): number | undefined {
87
+ return getAccountConfig(cfg, accountId).mediaMaxMb;
88
+ }
89
+
90
+ function getRawAccountConfig(cfg: OpenClawConfig, accountId?: string): Partial<MixinAccountConfig> {
91
+ const raw = getRawConfig(cfg);
92
+ const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg);
93
+ if (resolvedAccountId !== "default" && raw.accounts?.[resolvedAccountId]) {
94
+ return raw.accounts[resolvedAccountId] as Partial<MixinAccountConfig>;
95
+ }
96
+ return raw;
97
+ }
98
+
99
+ export function getConversationConfig(
100
+ cfg: OpenClawConfig,
101
+ accountId: string,
102
+ conversationId: string,
103
+ ): {
104
+ exists: boolean;
105
+ config: MixinConversationConfig;
106
+ } {
107
+ const accountRaw = getRawAccountConfig(cfg, accountId);
108
+ const conversationRaw = accountRaw.conversations?.[conversationId] as Partial<MixinConversationConfig> | undefined;
109
+ const result = MixinConversationConfigSchema.safeParse(conversationRaw ?? {});
110
+ return {
111
+ exists: Boolean(conversationRaw),
112
+ config: result.success ? result.data : MixinConversationConfigSchema.parse({}),
113
+ };
114
+ }
115
+
116
+ export function resolveConversationPolicy(
117
+ cfg: OpenClawConfig,
118
+ accountId: string,
119
+ conversationId: string,
120
+ ): {
121
+ enabled: boolean;
122
+ requireMention: boolean;
123
+ mediaBypassMention: boolean;
124
+ groupPolicy: MixinAccountConfig["groupPolicy"];
125
+ groupAllowFrom: string[];
126
+ hasConversationOverride: boolean;
127
+ } {
128
+ const accountConfig = getAccountConfig(cfg, accountId);
129
+ const conversation = getConversationConfig(cfg, accountId, conversationId);
130
+ return {
131
+ enabled: conversation.config.enabled !== false,
132
+ requireMention: conversation.config.requireMention ?? accountConfig.requireMentionInGroup,
133
+ mediaBypassMention: conversation.config.mediaBypassMention ?? accountConfig.mediaBypassMentionInGroup,
134
+ groupPolicy: conversation.config.groupPolicy ?? accountConfig.groupPolicy,
135
+ groupAllowFrom: conversation.config.allowFrom ?? accountConfig.groupAllowFrom ?? [],
136
+ hasConversationOverride: conversation.exists,
137
+ };
138
+ }
139
+
52
140
  export function isConfigured(account: ReturnType<typeof resolveAccount>): boolean {
53
141
  return account.configured;
54
142
  }
@@ -2,22 +2,17 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { MixinApi } from "@mixin.dev/mixin-node-sdk";
5
- import { buildAgentMediaPayload } from "openclaw/plugin-sdk";
5
+ import { buildAgentMediaPayload, evaluateSenderGroupAccess, resolveDefaultGroupPolicy } from "openclaw/plugin-sdk";
6
6
  import type { AgentMediaPayload, OpenClawConfig } from "openclaw/plugin-sdk";
7
- import { getAccountConfig } from "./config.js";
7
+ import { getAccountConfig, resolveConversationPolicy } from "./config.js";
8
8
  import type { MixinAccountConfig } from "./config-schema.js";
9
9
  import { decryptMixinMessage } from "./crypto.js";
10
10
  import { buildRequestConfig } from "./proxy.js";
11
- import { buildMixinReplyPlan } from "./reply-format.js";
11
+ import { buildMixinOutboundPlanFromReplyText, executeMixinOutboundPlan } from "./outbound-plan.js";
12
12
  import { getMixinRuntime } from "./runtime.js";
13
13
  import {
14
- sendAudioMessage,
15
14
  getOutboxStatus,
16
15
  purgePermanentInvalidOutboxEntries,
17
- sendFileMessage,
18
- sendButtonGroupMessage,
19
- sendCardMessage,
20
- sendPostMessage,
21
16
  sendTextMessage,
22
17
  } from "./send-service.js";
23
18
 
@@ -100,6 +95,14 @@ function buildClient(config: MixinAccountConfig) {
100
95
  });
101
96
  }
102
97
 
98
+ function resolveInboundMediaMaxBytes(config: MixinAccountConfig): number {
99
+ const mediaMaxMb = config.mediaMaxMb;
100
+ if (typeof mediaMaxMb === "number" && Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
101
+ return Math.max(1, Math.floor(mediaMaxMb * 1024 * 1024));
102
+ }
103
+ return INBOUND_MEDIA_MAX_BYTES;
104
+ }
105
+
103
106
  function parseInboundAttachmentRequest(category: string, data: string): MixinAttachmentRequest | null {
104
107
  if (category !== "PLAIN_DATA" && category !== "PLAIN_AUDIO") {
105
108
  return null;
@@ -165,17 +168,18 @@ async function resolveInboundAttachment(params: {
165
168
 
166
169
  try {
167
170
  const client = buildClient(params.config);
171
+ const maxBytes = resolveInboundMediaMaxBytes(params.config);
168
172
  const attachment = await client.attachment.fetch(payload.attachmentId);
169
173
  const fetched = await params.rt.channel.media.fetchRemoteMedia({
170
174
  url: attachment.view_url,
171
175
  filePathHint: payload.fileName,
172
- maxBytes: INBOUND_MEDIA_MAX_BYTES,
176
+ maxBytes,
173
177
  });
174
178
  const saved = await params.rt.channel.media.saveMediaBuffer(
175
179
  fetched.buffer,
176
180
  payload.mimeType ?? fetched.contentType,
177
181
  "mixin",
178
- INBOUND_MEDIA_MAX_BYTES,
182
+ maxBytes,
179
183
  payload.fileName ?? fetched.fileName,
180
184
  );
181
185
 
@@ -236,6 +240,10 @@ function normalizeAllowEntry(entry: string): string {
236
240
  return entry.trim().toLowerCase();
237
241
  }
238
242
 
243
+ function normalizeAllowEntries(entries: string[] | undefined): string[] {
244
+ return (entries ?? []).map(normalizeAllowEntry).filter(Boolean);
245
+ }
246
+
239
247
  function resolveMixinAllowFromPaths(
240
248
  rt: ReturnType<typeof getMixinRuntime>,
241
249
  accountId: string,
@@ -290,41 +298,21 @@ async function deliverMixinReply(params: {
290
298
  log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
291
299
  }): Promise<void> {
292
300
  const { cfg, accountId, conversationId, recipientId, text, log } = params;
293
- const plan = buildMixinReplyPlan(text);
294
-
295
- if (!plan) {
301
+ const plan = buildMixinOutboundPlanFromReplyText(text);
302
+ if (plan.steps.length === 0) {
296
303
  return;
297
304
  }
298
-
299
- if (plan.kind === "text") {
300
- await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
301
- return;
302
- }
303
-
304
- if (plan.kind === "post") {
305
- await sendPostMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
306
- return;
307
- }
308
-
309
- if (plan.kind === "file") {
310
- await sendFileMessage(cfg, accountId, conversationId, recipientId, plan.file, log);
311
- return;
312
- }
313
-
314
- if (plan.kind === "audio") {
315
- await sendAudioMessage(cfg, accountId, conversationId, recipientId, plan.audio, log);
316
- return;
317
- }
318
-
319
- if (plan.kind === "buttons") {
320
- if (plan.intro) {
321
- await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.intro, log);
322
- }
323
- await sendButtonGroupMessage(cfg, accountId, conversationId, recipientId, plan.buttons, log);
324
- return;
305
+ for (const warning of plan.warnings) {
306
+ log.warn(`[mixin] outbound plan warning: ${warning}`);
325
307
  }
326
-
327
- await sendCardMessage(cfg, accountId, conversationId, recipientId, plan.card, log);
308
+ await executeMixinOutboundPlan({
309
+ cfg,
310
+ accountId,
311
+ conversationId,
312
+ recipientId,
313
+ steps: plan.steps,
314
+ log,
315
+ });
328
316
  }
329
317
 
330
318
  async function handleUnauthorizedDirectMessage(params: {
@@ -386,6 +374,46 @@ async function handleUnauthorizedDirectMessage(params: {
386
374
  }
387
375
  }
388
376
 
377
+ function evaluateMixinGroupAccess(params: {
378
+ cfg: OpenClawConfig;
379
+ config: MixinAccountConfig;
380
+ accountId: string;
381
+ conversationId: string;
382
+ senderId: string;
383
+ }): {
384
+ allowed: boolean;
385
+ reason: string;
386
+ groupPolicy: "open" | "disabled" | "allowlist";
387
+ groupAllowFrom: string[];
388
+ } {
389
+ const conversationPolicy = resolveConversationPolicy(params.cfg, params.accountId, params.conversationId);
390
+ if (!conversationPolicy.enabled) {
391
+ return {
392
+ allowed: false,
393
+ reason: "conversation disabled",
394
+ groupPolicy: "disabled",
395
+ groupAllowFrom: normalizeAllowEntries(conversationPolicy.groupAllowFrom),
396
+ };
397
+ }
398
+
399
+ const normalizedGroupAllowFrom = normalizeAllowEntries(conversationPolicy.groupAllowFrom);
400
+ const decision = evaluateSenderGroupAccess({
401
+ providerConfigPresent: true,
402
+ configuredGroupPolicy: conversationPolicy.groupPolicy,
403
+ defaultGroupPolicy: resolveDefaultGroupPolicy(params.cfg),
404
+ groupAllowFrom: normalizedGroupAllowFrom,
405
+ senderId: normalizeAllowEntry(params.senderId),
406
+ isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(normalizeAllowEntry(senderId)),
407
+ });
408
+
409
+ return {
410
+ allowed: decision.allowed,
411
+ reason: decision.reason,
412
+ groupPolicy: decision.groupPolicy,
413
+ groupAllowFrom: normalizedGroupAllowFrom,
414
+ };
415
+ }
416
+
389
417
  export async function handleMixinMessage(params: {
390
418
  cfg: OpenClawConfig;
391
419
  accountId: string;
@@ -446,7 +474,19 @@ export async function handleMixinMessage(params: {
446
474
  return;
447
475
  }
448
476
 
449
- if (!isDirect && !isAttachmentMessage && !shouldPassGroupFilter(config, text)) {
477
+ const conversationPolicy = isDirect
478
+ ? null
479
+ : resolveConversationPolicy(cfg, accountId, msg.conversationId);
480
+
481
+ if (
482
+ !isDirect &&
483
+ conversationPolicy &&
484
+ !(isAttachmentMessage && conversationPolicy.mediaBypassMention) &&
485
+ !shouldPassGroupFilter({
486
+ ...config,
487
+ requireMentionInGroup: conversationPolicy.requireMention,
488
+ }, text)
489
+ ) {
450
490
  log.info(`[mixin] group message filtered: ${msg.messageId}`);
451
491
  return;
452
492
  }
@@ -454,10 +494,27 @@ export async function handleMixinMessage(params: {
454
494
  const effectiveAllowFrom = await readEffectiveAllowFrom(rt, accountId, config.allowFrom, log);
455
495
  const normalizedUserId = normalizeAllowEntry(msg.userId);
456
496
  const dmPolicy = config.dmPolicy ?? "pairing";
457
- const isAuthorized = dmPolicy === "open" || effectiveAllowFrom.has(normalizedUserId);
497
+ const groupAccess = isDirect
498
+ ? null
499
+ : evaluateMixinGroupAccess({
500
+ cfg,
501
+ config,
502
+ accountId,
503
+ conversationId: msg.conversationId,
504
+ senderId: msg.userId,
505
+ });
506
+ const isAuthorized = isDirect
507
+ ? dmPolicy === "open" || effectiveAllowFrom.has(normalizedUserId)
508
+ : groupAccess?.allowed === true;
458
509
 
459
510
  if (!isAuthorized) {
460
- log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
511
+ if (isDirect) {
512
+ log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
513
+ } else {
514
+ log.warn(
515
+ `[mixin] group sender ${msg.userId} blocked: conversationId=${msg.conversationId}, groupPolicy=${groupAccess?.groupPolicy ?? "unknown"}, reason=${groupAccess?.reason ?? "unknown"}`,
516
+ );
517
+ }
461
518
  markProcessed(msg.messageId);
462
519
  if (isDirect) {
463
520
  await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
@@ -507,14 +564,18 @@ export async function handleMixinMessage(params: {
507
564
 
508
565
  const shouldComputeCommandAuthorized = rt.channel.commands.shouldComputeCommandAuthorized(text, cfg);
509
566
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
510
- const senderAllowedForCommands = useAccessGroups ? effectiveAllowFrom.has(normalizedUserId) : true;
567
+ const senderAllowedForCommands = useAccessGroups
568
+ ? isDirect
569
+ ? effectiveAllowFrom.has(normalizedUserId)
570
+ : groupAccess?.allowed === true
571
+ : true;
511
572
 
512
573
  const commandAuthorized = shouldComputeCommandAuthorized
513
574
  ? rt.channel.commands.resolveCommandAuthorizedFromAuthorizers({
514
575
  useAccessGroups,
515
576
  authorizers: [
516
577
  {
517
- configured: effectiveAllowFrom.size > 0,
578
+ configured: isDirect ? effectiveAllowFrom.size > 0 : (groupAccess?.groupAllowFrom.length ?? 0) > 0,
518
579
  allowed: senderAllowedForCommands,
519
580
  },
520
581
  ],
@@ -0,0 +1,197 @@
1
+ import type { ReplyPayload } from "openclaw/plugin-sdk";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import { buildMixinReplyPlan, resolveMixinReplyPlan } from "./reply-format.js";
4
+ import {
5
+ sendAudioMessage,
6
+ sendButtonGroupMessage,
7
+ sendCardMessage,
8
+ sendFileMessage,
9
+ sendPostMessage,
10
+ sendTextMessage,
11
+ } from "./send-service.js";
12
+
13
+ type SendLog = {
14
+ info: (msg: string) => void;
15
+ warn: (msg: string) => void;
16
+ error: (msg: string, err?: unknown) => void;
17
+ };
18
+
19
+ export type MixinOutboundStep =
20
+ | { kind: "text"; text: string }
21
+ | { kind: "post"; text: string }
22
+ | { kind: "file"; file: Parameters<typeof sendFileMessage>[4] }
23
+ | { kind: "audio"; audio: Parameters<typeof sendAudioMessage>[4] }
24
+ | { kind: "buttons"; intro?: string; buttons: Parameters<typeof sendButtonGroupMessage>[4] }
25
+ | { kind: "card"; card: Parameters<typeof sendCardMessage>[4] }
26
+ | { kind: "media-url"; mediaUrl: string };
27
+
28
+ export type MixinOutboundPlan = {
29
+ steps: MixinOutboundStep[];
30
+ warnings: string[];
31
+ };
32
+
33
+ function appendReplyTextPlan(
34
+ steps: MixinOutboundStep[],
35
+ warnings: string[],
36
+ text: string,
37
+ options?: {
38
+ allowAttachmentTemplates?: boolean;
39
+ },
40
+ ): void {
41
+ const resolution = resolveMixinReplyPlan(text);
42
+ if (resolution.matchedTemplate && !resolution.plan) {
43
+ steps.push({
44
+ kind: "text",
45
+ text: `Mixin template error: ${resolution.error ?? "invalid template"}`,
46
+ });
47
+ return;
48
+ }
49
+
50
+ const plan = resolution.plan ?? buildMixinReplyPlan(text);
51
+ if (!plan) {
52
+ return;
53
+ }
54
+
55
+ if ((plan.kind === "file" || plan.kind === "audio") && options?.allowAttachmentTemplates === false) {
56
+ warnings.push(`ignored ${plan.kind} template because native media payload already contains media`);
57
+ steps.push({
58
+ kind: "text",
59
+ text: `Mixin template warning: ${plan.kind} template was ignored because mediaUrl/mediaUrls is already present.`,
60
+ });
61
+ return;
62
+ }
63
+
64
+ if (plan.kind === "text") {
65
+ steps.push({ kind: "text", text: plan.text });
66
+ return;
67
+ }
68
+ if (plan.kind === "post") {
69
+ steps.push({ kind: "post", text: plan.text });
70
+ return;
71
+ }
72
+ if (plan.kind === "file") {
73
+ steps.push({ kind: "file", file: plan.file });
74
+ return;
75
+ }
76
+ if (plan.kind === "audio") {
77
+ steps.push({ kind: "audio", audio: plan.audio });
78
+ return;
79
+ }
80
+ if (plan.kind === "buttons") {
81
+ steps.push({ kind: "buttons", intro: plan.intro, buttons: plan.buttons });
82
+ return;
83
+ }
84
+ steps.push({ kind: "card", card: plan.card });
85
+ }
86
+
87
+ export function buildMixinOutboundPlanFromReplyText(text: string): MixinOutboundPlan {
88
+ const steps: MixinOutboundStep[] = [];
89
+ const warnings: string[] = [];
90
+ appendReplyTextPlan(steps, warnings, text, { allowAttachmentTemplates: true });
91
+ return { steps, warnings };
92
+ }
93
+
94
+ export function buildMixinOutboundPlanFromReplyPayload(payload: ReplyPayload): MixinOutboundPlan {
95
+ const steps: MixinOutboundStep[] = [];
96
+ const warnings: string[] = [];
97
+ const mediaUrls = payload.mediaUrls && payload.mediaUrls.length > 0
98
+ ? payload.mediaUrls
99
+ : payload.mediaUrl
100
+ ? [payload.mediaUrl]
101
+ : [];
102
+
103
+ if (payload.text?.trim()) {
104
+ appendReplyTextPlan(steps, warnings, payload.text, {
105
+ allowAttachmentTemplates: mediaUrls.length === 0,
106
+ });
107
+ }
108
+
109
+ for (const mediaUrl of mediaUrls) {
110
+ steps.push({ kind: "media-url", mediaUrl });
111
+ }
112
+
113
+ return { steps, warnings };
114
+ }
115
+
116
+ export async function executeMixinOutboundPlan(params: {
117
+ cfg: OpenClawConfig;
118
+ accountId: string;
119
+ conversationId: string;
120
+ recipientId?: string;
121
+ steps: MixinOutboundStep[];
122
+ log?: SendLog;
123
+ sendMediaUrl?: (mediaUrl: string) => Promise<string | undefined>;
124
+ }): Promise<string | undefined> {
125
+ const { cfg, accountId, conversationId, recipientId, steps, log, sendMediaUrl } = params;
126
+ let lastMessageId: string | undefined;
127
+
128
+ for (const step of steps) {
129
+ if (step.kind === "text") {
130
+ const result = await sendTextMessage(cfg, accountId, conversationId, recipientId, step.text, log);
131
+ if (!result.ok) {
132
+ throw new Error(result.error ?? "mixin outbound text send failed");
133
+ }
134
+ lastMessageId = result.messageId ?? lastMessageId;
135
+ continue;
136
+ }
137
+
138
+ if (step.kind === "post") {
139
+ const result = await sendPostMessage(cfg, accountId, conversationId, recipientId, step.text, log);
140
+ if (!result.ok) {
141
+ throw new Error(result.error ?? "mixin outbound post send failed");
142
+ }
143
+ lastMessageId = result.messageId ?? lastMessageId;
144
+ continue;
145
+ }
146
+
147
+ if (step.kind === "file") {
148
+ const result = await sendFileMessage(cfg, accountId, conversationId, recipientId, step.file, log);
149
+ if (!result.ok) {
150
+ throw new Error(result.error ?? "mixin outbound file send failed");
151
+ }
152
+ lastMessageId = result.messageId ?? lastMessageId;
153
+ continue;
154
+ }
155
+
156
+ if (step.kind === "audio") {
157
+ const result = await sendAudioMessage(cfg, accountId, conversationId, recipientId, step.audio, log);
158
+ if (!result.ok) {
159
+ throw new Error(result.error ?? "mixin outbound audio send failed");
160
+ }
161
+ lastMessageId = result.messageId ?? lastMessageId;
162
+ continue;
163
+ }
164
+
165
+ if (step.kind === "buttons") {
166
+ if (step.intro) {
167
+ const introResult = await sendTextMessage(cfg, accountId, conversationId, recipientId, step.intro, log);
168
+ if (!introResult.ok) {
169
+ throw new Error(introResult.error ?? "mixin outbound intro send failed");
170
+ }
171
+ lastMessageId = introResult.messageId ?? lastMessageId;
172
+ }
173
+ const result = await sendButtonGroupMessage(cfg, accountId, conversationId, recipientId, step.buttons, log);
174
+ if (!result.ok) {
175
+ throw new Error(result.error ?? "mixin outbound buttons send failed");
176
+ }
177
+ lastMessageId = result.messageId ?? lastMessageId;
178
+ continue;
179
+ }
180
+
181
+ if (step.kind === "card") {
182
+ const result = await sendCardMessage(cfg, accountId, conversationId, recipientId, step.card, log);
183
+ if (!result.ok) {
184
+ throw new Error(result.error ?? "mixin outbound card send failed");
185
+ }
186
+ lastMessageId = result.messageId ?? lastMessageId;
187
+ continue;
188
+ }
189
+
190
+ if (!sendMediaUrl) {
191
+ throw new Error("mixin outbound mediaUrl handler not configured");
192
+ }
193
+ lastMessageId = await sendMediaUrl(step.mediaUrl) ?? lastMessageId;
194
+ }
195
+
196
+ return lastMessageId;
197
+ }