@invago/mixin 1.0.7

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.
@@ -0,0 +1,336 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { getMixinRuntime } from "./runtime.js";
3
+ import {
4
+ getOutboxStatus,
5
+ purgePermanentInvalidOutboxEntries,
6
+ sendButtonGroupMessage,
7
+ sendCardMessage,
8
+ sendPostMessage,
9
+ sendTextMessage,
10
+ } from "./send-service.js";
11
+ import { getAccountConfig } from "./config.js";
12
+ import type { MixinAccountConfig } from "./config-schema.js";
13
+
14
+ import { decryptMixinMessage } from "./crypto.js";
15
+ import { buildMixinReplyPlan } from "./reply-format.js";
16
+
17
+ export interface MixinInboundMessage {
18
+ conversationId: string;
19
+ userId: string;
20
+ messageId: string;
21
+ category: string;
22
+ data: string;
23
+ createdAt: string;
24
+ publicKey?: string;
25
+ }
26
+
27
+ const processedMessages = new Set<string>();
28
+ const MAX_DEDUP_SIZE = 2000;
29
+ const unauthNotifiedUsers = new Map<string, number>();
30
+ const UNAUTH_NOTIFY_INTERVAL = 20 * 60 * 1000;
31
+ const MAX_UNAUTH_NOTIFY_USERS = 1000;
32
+
33
+ function isProcessed(messageId: string): boolean {
34
+ return processedMessages.has(messageId);
35
+ }
36
+
37
+ function markProcessed(messageId: string): void {
38
+ if (processedMessages.size >= MAX_DEDUP_SIZE) {
39
+ const first = processedMessages.values().next().value;
40
+ if (first) processedMessages.delete(first);
41
+ }
42
+ processedMessages.add(messageId);
43
+ }
44
+
45
+ function pruneUnauthNotifiedUsers(now: number): void {
46
+ for (const [userId, lastNotified] of unauthNotifiedUsers) {
47
+ if (now - lastNotified > UNAUTH_NOTIFY_INTERVAL) {
48
+ unauthNotifiedUsers.delete(userId);
49
+ }
50
+ }
51
+
52
+ while (unauthNotifiedUsers.size >= MAX_UNAUTH_NOTIFY_USERS) {
53
+ const first = unauthNotifiedUsers.keys().next().value;
54
+ if (!first) {
55
+ break;
56
+ }
57
+ unauthNotifiedUsers.delete(first);
58
+ }
59
+ }
60
+
61
+ function decodeContent(category: string, data: string): string {
62
+ if (category.startsWith("PLAIN_TEXT") || category.startsWith("PLAIN_POST")) {
63
+ try {
64
+ return Buffer.from(data, "base64").toString("utf-8");
65
+ } catch {
66
+ return data;
67
+ }
68
+ }
69
+ return `[${category}]`;
70
+ }
71
+
72
+ function shouldPassGroupFilter(config: MixinAccountConfig, text: string): boolean {
73
+ if (!config.requireMentionInGroup) return true;
74
+ const lower = text.toLowerCase();
75
+ return (
76
+ lower.includes("?") ||
77
+ lower.includes("?") ||
78
+ /帮|请|分析|总结|help/i.test(lower)
79
+ );
80
+ }
81
+
82
+ function isOutboxCommand(text: string): boolean {
83
+ return text.trim().toLowerCase().startsWith("/mixin-outbox");
84
+ }
85
+
86
+ function isOutboxPurgeInvalidCommand(text: string): boolean {
87
+ return text.trim().toLowerCase() === "/mixin-outbox purge-invalid";
88
+ }
89
+
90
+ function formatOutboxStatus(status: Awaited<ReturnType<typeof getOutboxStatus>>): string {
91
+ const lines = [
92
+ `Outbox pending: ${status.totalPending}`,
93
+ `Oldest pending: ${status.oldestPendingAt ?? "N/A"}`,
94
+ `Next attempt: ${status.nextAttemptAt ?? "N/A"}`,
95
+ `Latest error: ${status.latestError ?? "N/A"}`,
96
+ ];
97
+
98
+ if (status.pendingByAccount.length > 0) {
99
+ lines.push("By account:");
100
+ for (const item of status.pendingByAccount) {
101
+ lines.push(`- ${item.accountId}: ${item.pending}`);
102
+ }
103
+ }
104
+
105
+ return lines.join("\n");
106
+ }
107
+
108
+ async function deliverMixinReply(params: {
109
+ cfg: OpenClawConfig;
110
+ accountId: string;
111
+ conversationId: string;
112
+ recipientId?: string;
113
+ text: string;
114
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
115
+ }): Promise<void> {
116
+ const { cfg, accountId, conversationId, recipientId, text, log } = params;
117
+ const plan = buildMixinReplyPlan(text);
118
+
119
+ if (!plan) {
120
+ return;
121
+ }
122
+
123
+ if (plan.kind === "text") {
124
+ await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
125
+ return;
126
+ }
127
+
128
+ if (plan.kind === "post") {
129
+ await sendPostMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
130
+ return;
131
+ }
132
+
133
+ if (plan.kind === "buttons") {
134
+ if (plan.intro) {
135
+ await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.intro, log);
136
+ }
137
+ await sendButtonGroupMessage(cfg, accountId, conversationId, recipientId, plan.buttons, log);
138
+ return;
139
+ }
140
+
141
+ await sendCardMessage(cfg, accountId, conversationId, recipientId, plan.card, log);
142
+ }
143
+
144
+ export async function handleMixinMessage(params: {
145
+ cfg: OpenClawConfig;
146
+ accountId: string;
147
+ msg: MixinInboundMessage;
148
+ isDirect: boolean;
149
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
150
+ }): Promise<void> {
151
+ const { cfg, accountId, msg, isDirect, log } = params;
152
+ const rt = getMixinRuntime();
153
+
154
+ // 立即检查是否已处理,防止并发
155
+ if (isProcessed(msg.messageId)) return;
156
+
157
+ const config = getAccountConfig(cfg, accountId);
158
+
159
+ // 处理加密消息
160
+ if (msg.category === "ENCRYPTED_TEXT" || msg.category === "ENCRYPTED_POST") {
161
+ log.info(`[mixin] decrypting encrypted message ${msg.messageId}, category=${msg.category}`);
162
+ try {
163
+ const decrypted = decryptMixinMessage(
164
+ msg.data,
165
+ config.sessionPrivateKey!,
166
+ config.sessionId!
167
+ );
168
+ if (decrypted) {
169
+ log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
170
+ msg.data = Buffer.from(decrypted).toString("base64");
171
+ msg.category = "PLAIN_TEXT";
172
+ } else {
173
+ log.error(`[mixin] decryption failed for ${msg.messageId}`);
174
+ markProcessed(msg.messageId);
175
+ return;
176
+ }
177
+ } catch (err) {
178
+ log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
179
+ markProcessed(msg.messageId);
180
+ return;
181
+ }
182
+ }
183
+
184
+ // 检查是否是文本消息
185
+ if (!msg.category.startsWith("PLAIN_TEXT") && !msg.category.startsWith("PLAIN_POST")) {
186
+ log.info(`[mixin] skip non-text message: ${msg.category}`);
187
+ return;
188
+ }
189
+
190
+ const text = decodeContent(msg.category, msg.data).trim();
191
+ log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
192
+
193
+ if (!text) return;
194
+
195
+ // 群组消息过滤:只有包含关键词的消息才会被处理
196
+ if (!isDirect && !shouldPassGroupFilter(config, text)) {
197
+ log.info(`[mixin] group message filtered: ${msg.messageId}`);
198
+ return;
199
+ }
200
+
201
+ // allowlist 检查:只处理白名单中的用户
202
+ if (!config.allowFrom.includes(msg.userId)) {
203
+ log.warn(`[mixin] user ${msg.userId} not in allowlist`);
204
+ markProcessed(msg.messageId);
205
+
206
+ // 只在首次消息时回复,20分钟内不重复回复
207
+ const now = Date.now();
208
+ const lastNotified = unauthNotifiedUsers.get(msg.userId) ?? 0;
209
+
210
+ if (lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL) {
211
+ pruneUnauthNotifiedUsers(now);
212
+ unauthNotifiedUsers.set(msg.userId, now);
213
+ const msgBody = `⚠️ 请等待管理员认证\n\n您的 Mixin UUID: ${msg.userId}\n\n请将此UUID添加到 allowFrom 列表中完成认证`;
214
+ sendTextMessage(cfg, accountId, msg.conversationId, msg.userId, msgBody, log).catch(() => {});
215
+ }
216
+
217
+ return;
218
+ }
219
+
220
+ // 标记为已处理
221
+ markProcessed(msg.messageId);
222
+
223
+ if (isOutboxCommand(text)) {
224
+ if (isOutboxPurgeInvalidCommand(text)) {
225
+ const result = await purgePermanentInvalidOutboxEntries();
226
+ const recipientId = isDirect ? msg.userId : undefined;
227
+ const replyText = result.removed > 0
228
+ ? `Removed ${result.removed} invalid outbox entr${result.removed === 1 ? "y" : "ies"}.\n${result.removedJobIds.map((jobId) => `- ${jobId}`).join("\n")}`
229
+ : "No invalid outbox entries found.";
230
+ await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, replyText, log);
231
+ return;
232
+ }
233
+
234
+ const status = await getOutboxStatus();
235
+ const replyText = formatOutboxStatus(status);
236
+ const recipientId = isDirect ? msg.userId : undefined;
237
+ await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, replyText, log);
238
+ return;
239
+ }
240
+
241
+ // 解析消息路由
242
+ const peerId = isDirect ? msg.userId : msg.conversationId;
243
+ log.info(`[mixin] resolving route: channel=mixin, accountId=${accountId}, peer.kind=${isDirect ? "direct" : "group"}, peer.id=${peerId}`);
244
+
245
+ const route = rt.channel.routing.resolveAgentRoute({
246
+ cfg,
247
+ channel: "mixin",
248
+ accountId,
249
+ peer: {
250
+ kind: isDirect ? "direct" : "group",
251
+ id: peerId,
252
+ },
253
+ });
254
+
255
+ log.info(`[mixin] route result: ${route ? "FOUND" : "NULL"} - agentId=${route?.agentId ?? "N/A"}`);
256
+
257
+ if (!route) {
258
+ log.warn(`[mixin] no agent route for ${msg.userId} (peerId: ${peerId})`);
259
+ return;
260
+ }
261
+
262
+ // 创建上下文
263
+ const shouldComputeCommandAuthorized = rt.channel.commands.shouldComputeCommandAuthorized(
264
+ text,
265
+ cfg,
266
+ );
267
+
268
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
269
+ const commandAllowFrom = config.allowFrom;
270
+
271
+ const senderAllowedForCommands = useAccessGroups
272
+ ? config.allowFrom.includes(msg.userId)
273
+ : true;
274
+
275
+ const commandAuthorized = shouldComputeCommandAuthorized
276
+ ? rt.channel.commands.resolveCommandAuthorizedFromAuthorizers({
277
+ useAccessGroups,
278
+ authorizers: [
279
+ {
280
+ configured: commandAllowFrom.length > 0,
281
+ allowed: senderAllowedForCommands,
282
+ },
283
+ ],
284
+ })
285
+ : undefined;
286
+
287
+ const ctx = rt.channel.reply.finalizeInboundContext({
288
+ Body: text,
289
+ RawBody: text,
290
+ CommandBody: text,
291
+ From: isDirect ? msg.userId : msg.conversationId,
292
+ SessionKey: route.sessionKey,
293
+ AccountId: accountId,
294
+ ChatType: isDirect ? "direct" : "group",
295
+ Provider: "mixin",
296
+ Surface: "mixin",
297
+ MessageSid: msg.messageId,
298
+ CommandAuthorized: commandAuthorized,
299
+ });
300
+
301
+ // 记录会话
302
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
303
+ agentId: route.agentId,
304
+ });
305
+ await rt.channel.session.recordInboundSession({
306
+ storePath,
307
+ sessionKey: route.sessionKey,
308
+ ctx,
309
+ onRecordError: (err: unknown) => {
310
+ log.error("[mixin] session record error", err);
311
+ },
312
+ });
313
+
314
+ // 分发消息
315
+ log.info(`[mixin] dispatching ${msg.messageId} from ${msg.userId}`);
316
+
317
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
318
+ ctx,
319
+ cfg,
320
+ dispatcherOptions: {
321
+ deliver: async (payload) => {
322
+ const replyText = payload.text ?? "";
323
+ if (!replyText) return;
324
+ const recipientId = isDirect ? msg.userId : undefined;
325
+ await deliverMixinReply({
326
+ cfg,
327
+ accountId,
328
+ conversationId: msg.conversationId,
329
+ recipientId,
330
+ text: replyText,
331
+ log,
332
+ });
333
+ },
334
+ },
335
+ });
336
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { RequestConfig } from "@mixin.dev/mixin-node-sdk";
2
+ import { ProxyAgent } from "proxy-agent";
3
+ import type { Agent } from "http";
4
+ import type { MixinProxyConfig } from "./config-schema.js";
5
+
6
+ export function buildProxyUrl(proxy?: MixinProxyConfig): string | undefined {
7
+ if (!proxy?.enabled || !proxy.url) {
8
+ return undefined;
9
+ }
10
+
11
+ const url = new URL(proxy.url);
12
+ if (proxy.username) {
13
+ url.username = proxy.username;
14
+ }
15
+ if (proxy.password) {
16
+ url.password = proxy.password;
17
+ }
18
+ return url.toString();
19
+ }
20
+
21
+ export function createProxyAgent(proxy?: MixinProxyConfig): Agent | undefined {
22
+ const proxyUrl = buildProxyUrl(proxy);
23
+ if (!proxyUrl) {
24
+ return undefined;
25
+ }
26
+ return new ProxyAgent({
27
+ getProxyForUrl: () => proxyUrl,
28
+ }) as Agent;
29
+ }
30
+
31
+ export function buildRequestConfig(proxy?: MixinProxyConfig): RequestConfig | undefined {
32
+ const agent = createProxyAgent(proxy);
33
+ if (!agent) {
34
+ return undefined;
35
+ }
36
+
37
+ return {
38
+ proxy: false,
39
+ httpAgent: agent,
40
+ httpsAgent: agent,
41
+ };
42
+ }
@@ -0,0 +1,281 @@
1
+ import type { MixinButton, MixinCard } from "./send-service.js";
2
+
3
+ type LinkItem = {
4
+ label: string;
5
+ url: string;
6
+ };
7
+
8
+ export type MixinReplyPlan =
9
+ | { kind: "text"; text: string }
10
+ | { kind: "post"; text: string }
11
+ | { kind: "buttons"; intro?: string; buttons: MixinButton[] }
12
+ | { kind: "card"; card: MixinCard };
13
+
14
+ const MAX_BUTTONS = 6;
15
+ const MAX_BUTTON_LABEL = 36;
16
+ const MAX_CARD_TITLE = 36;
17
+ const MAX_CARD_DESCRIPTION = 120;
18
+ const TEMPLATE_REGEX = /^```mixin-(text|post|buttons|card)\s*\n([\s\S]*?)\n```$/i;
19
+
20
+ function truncate(value: string, limit: number): string {
21
+ return value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 3))}...`;
22
+ }
23
+
24
+ function normalizeWhitespace(value: string): string {
25
+ return value.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").trim();
26
+ }
27
+
28
+ function isValidHttpUrl(value: string | undefined): value is string {
29
+ if (!value) {
30
+ return false;
31
+ }
32
+
33
+ try {
34
+ const url = new URL(value);
35
+ return url.protocol === "http:" || url.protocol === "https:";
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function parseJsonTemplate<T>(body: string): T | null {
42
+ try {
43
+ return JSON.parse(body) as T;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function parseTextTemplate(body: string): MixinReplyPlan | null {
50
+ const text = normalizeWhitespace(body);
51
+ return text ? { kind: "text", text } : null;
52
+ }
53
+
54
+ function parsePostTemplate(body: string): MixinReplyPlan | null {
55
+ const text = normalizeWhitespace(body);
56
+ return text ? { kind: "post", text } : null;
57
+ }
58
+
59
+ function parseButtonsTemplate(body: string): MixinReplyPlan | null {
60
+ const parsed = parseJsonTemplate<{ intro?: string; buttons?: MixinButton[] } | MixinButton[]>(body);
61
+ if (!parsed) {
62
+ return null;
63
+ }
64
+
65
+ const intro = Array.isArray(parsed) ? undefined : typeof parsed.intro === "string" ? normalizeWhitespace(parsed.intro) : undefined;
66
+ const buttons = (Array.isArray(parsed) ? parsed : parsed.buttons ?? [])
67
+ .filter((button) => typeof button?.label === "string" && isValidHttpUrl(button?.action))
68
+ .slice(0, MAX_BUTTONS)
69
+ .map((button) => ({
70
+ label: truncate(normalizeWhitespace(button.label), MAX_BUTTON_LABEL),
71
+ color: button.color,
72
+ action: button.action,
73
+ }));
74
+
75
+ if (buttons.length === 0) {
76
+ return null;
77
+ }
78
+
79
+ return { kind: "buttons", intro: intro || undefined, buttons };
80
+ }
81
+
82
+ function parseCardTemplate(body: string): MixinReplyPlan | null {
83
+ const parsed = parseJsonTemplate<MixinCard>(body);
84
+ if (!parsed) {
85
+ return null;
86
+ }
87
+
88
+ const title = typeof parsed.title === "string" ? truncate(normalizeWhitespace(parsed.title), MAX_CARD_TITLE) : "";
89
+ const description = typeof parsed.description === "string"
90
+ ? truncate(normalizeWhitespace(parsed.description), MAX_CARD_DESCRIPTION)
91
+ : "";
92
+ const action = isValidHttpUrl(parsed.action) ? parsed.action : undefined;
93
+ const actions = Array.isArray(parsed.actions)
94
+ ? parsed.actions
95
+ .filter((button) => typeof button?.label === "string" && isValidHttpUrl(button?.action))
96
+ .slice(0, MAX_BUTTONS)
97
+ .map((button) => ({
98
+ label: truncate(normalizeWhitespace(button.label), MAX_BUTTON_LABEL),
99
+ color: button.color,
100
+ action: button.action,
101
+ }))
102
+ : undefined;
103
+ const coverUrl = isValidHttpUrl(parsed.coverUrl) ? parsed.coverUrl : undefined;
104
+ const iconUrl = isValidHttpUrl(parsed.iconUrl) ? parsed.iconUrl : undefined;
105
+
106
+ if (!title || !description) {
107
+ return null;
108
+ }
109
+
110
+ return {
111
+ kind: "card",
112
+ card: {
113
+ title,
114
+ description,
115
+ action,
116
+ actions,
117
+ coverUrl,
118
+ iconUrl,
119
+ shareable: parsed.shareable,
120
+ },
121
+ };
122
+ }
123
+
124
+ function parseExplicitTemplate(text: string): MixinReplyPlan | null {
125
+ const match = text.match(TEMPLATE_REGEX);
126
+ if (!match) {
127
+ return null;
128
+ }
129
+
130
+ const templateType = (match[1] ?? "").toLowerCase();
131
+ const body = match[2] ?? "";
132
+
133
+ if (templateType === "text") {
134
+ return parseTextTemplate(body);
135
+ }
136
+
137
+ if (templateType === "post") {
138
+ return parsePostTemplate(body);
139
+ }
140
+
141
+ if (templateType === "buttons") {
142
+ return parseButtonsTemplate(body);
143
+ }
144
+
145
+ if (templateType === "card") {
146
+ return parseCardTemplate(body);
147
+ }
148
+
149
+ return null;
150
+ }
151
+
152
+ function toPlainText(text: string): string {
153
+ return normalizeWhitespace(
154
+ text
155
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "$1: $2")
156
+ .replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g, "$1")
157
+ .replace(/`{1,3}([^`]+)`{1,3}/g, "$1")
158
+ .replace(/^#{1,6}\s+/gm, "")
159
+ .replace(/^\s*[-*+]\s+/gm, "- "),
160
+ );
161
+ }
162
+
163
+ function extractLinks(text: string): LinkItem[] {
164
+ const links: LinkItem[] = [];
165
+ const seen = new Set<string>();
166
+ const markdownRegex = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
167
+ const plainUrlRegex = /(^|[\s(])(https?:\/\/[^\s)]+)/g;
168
+
169
+ for (const match of text.matchAll(markdownRegex)) {
170
+ const label = normalizeWhitespace(match[1] ?? "");
171
+ const url = match[2] ?? "";
172
+ if (!label || !url || seen.has(url)) {
173
+ continue;
174
+ }
175
+ seen.add(url);
176
+ links.push({ label, url });
177
+ }
178
+
179
+ for (const match of text.matchAll(plainUrlRegex)) {
180
+ const url = match[2] ?? "";
181
+ if (!url || seen.has(url)) {
182
+ continue;
183
+ }
184
+ seen.add(url);
185
+ try {
186
+ const parsed = new URL(url);
187
+ links.push({ label: parsed.hostname, url });
188
+ } catch {
189
+ continue;
190
+ }
191
+ }
192
+
193
+ return links;
194
+ }
195
+
196
+ function buildButtons(links: LinkItem[]): MixinButton[] {
197
+ return links.slice(0, MAX_BUTTONS).map((link, index) => ({
198
+ label: truncate(link.label || `Link ${index + 1}`, MAX_BUTTON_LABEL),
199
+ color: index === 0 ? "#0A84FF" : undefined,
200
+ action: link.url,
201
+ }));
202
+ }
203
+
204
+ function detectTitle(text: string, fallback: string): string {
205
+ const lines = normalizeWhitespace(text).split("\n").map((line) => line.trim()).filter(Boolean);
206
+ const candidate = lines[0]?.replace(/^#{1,6}\s+/, "") ?? fallback;
207
+ return truncate(candidate, MAX_CARD_TITLE);
208
+ }
209
+
210
+ function detectCardDescription(text: string, title: string): string {
211
+ const plain = toPlainText(text)
212
+ .split("\n")
213
+ .map((line) => line.trim())
214
+ .filter(Boolean)
215
+ .filter((line) => line !== title);
216
+ return truncate(plain.join(" "), MAX_CARD_DESCRIPTION);
217
+ }
218
+
219
+ function isLongStructuredText(text: string): boolean {
220
+ const normalized = normalizeWhitespace(text);
221
+ const lines = normalized.split("\n").filter((line) => line.trim().length > 0);
222
+ const hasCodeBlock = /```[\s\S]*?```/.test(normalized);
223
+ const hasMarkdownTable =
224
+ /^\|.+\|$/m.test(normalized) ||
225
+ /^\s*\|?[-: ]+\|[-|: ]+\|/m.test(normalized);
226
+
227
+ return (
228
+ hasCodeBlock ||
229
+ hasMarkdownTable ||
230
+ normalized.length > 420 ||
231
+ lines.length > 8 ||
232
+ /^#{1,6}\s/m.test(normalized) ||
233
+ /^\s*[-*+]\s/m.test(normalized) ||
234
+ /^\d+\.\s/m.test(normalized)
235
+ );
236
+ }
237
+
238
+ export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
239
+ const normalized = normalizeWhitespace(text);
240
+ if (!normalized) {
241
+ return null;
242
+ }
243
+
244
+ const explicit = parseExplicitTemplate(normalized);
245
+ if (explicit) {
246
+ return explicit;
247
+ }
248
+
249
+ const links = extractLinks(normalized);
250
+
251
+ if (isLongStructuredText(normalized)) {
252
+ return { kind: "post", text: normalized };
253
+ }
254
+
255
+ if (links.length >= 2 && links.length <= MAX_BUTTONS) {
256
+ const intro = toPlainText(
257
+ normalized.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "").replace(/https?:\/\/[^\s)]+/g, ""),
258
+ );
259
+ return {
260
+ kind: "buttons",
261
+ intro: intro || undefined,
262
+ buttons: buildButtons(links),
263
+ };
264
+ }
265
+
266
+ if (links.length === 1) {
267
+ const title = detectTitle(normalized, links[0].label);
268
+ const description = detectCardDescription(normalized, title) || truncate(links[0].url, MAX_CARD_DESCRIPTION);
269
+ return {
270
+ kind: "card",
271
+ card: {
272
+ title,
273
+ description,
274
+ action: links[0].url,
275
+ shareable: true,
276
+ },
277
+ };
278
+ }
279
+
280
+ return { kind: "text", text: toPlainText(normalized) };
281
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setMixinRuntime(next: PluginRuntime): void {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getMixinRuntime(): PluginRuntime {
10
+ if (!runtime) throw new Error("Mixin runtime not initialized");
11
+ return runtime;
12
+ }