@z-qinghui/migpt-claw 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +690 -0
  3. package/dist/index.d.ts +23 -0
  4. package/dist/index.js +33 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/setup-entry.d.ts +3 -0
  7. package/dist/setup-entry.js +7 -0
  8. package/dist/setup-entry.js.map +1 -0
  9. package/dist/src/channel.d.ts +10 -0
  10. package/dist/src/channel.js +444 -0
  11. package/dist/src/channel.js.map +1 -0
  12. package/dist/src/config.d.ts +125 -0
  13. package/dist/src/config.js +146 -0
  14. package/dist/src/config.js.map +1 -0
  15. package/dist/src/message.d.ts +51 -0
  16. package/dist/src/message.js +145 -0
  17. package/dist/src/message.js.map +1 -0
  18. package/dist/src/mi/account.d.ts +5 -0
  19. package/dist/src/mi/account.js +162 -0
  20. package/dist/src/mi/account.js.map +1 -0
  21. package/dist/src/mi/common.d.ts +15 -0
  22. package/dist/src/mi/common.js +80 -0
  23. package/dist/src/mi/common.js.map +1 -0
  24. package/dist/src/mi/index.d.ts +4 -0
  25. package/dist/src/mi/index.js +10 -0
  26. package/dist/src/mi/index.js.map +1 -0
  27. package/dist/src/mi/mina.d.ts +66 -0
  28. package/dist/src/mi/mina.js +225 -0
  29. package/dist/src/mi/mina.js.map +1 -0
  30. package/dist/src/mi/miot.d.ts +35 -0
  31. package/dist/src/mi/miot.js +168 -0
  32. package/dist/src/mi/miot.js.map +1 -0
  33. package/dist/src/mi/typing.d.ts +90 -0
  34. package/dist/src/mi/typing.js +1 -0
  35. package/dist/src/mi/typing.js.map +1 -0
  36. package/dist/src/onboarding.d.ts +5 -0
  37. package/dist/src/onboarding.js +118 -0
  38. package/dist/src/onboarding.js.map +1 -0
  39. package/dist/src/openclaw-plugin-sdk.d.d.ts +185 -0
  40. package/dist/src/openclaw-plugin-sdk.d.js +1 -0
  41. package/dist/src/openclaw-plugin-sdk.d.js.map +1 -0
  42. package/dist/src/outbound.d.ts +5 -0
  43. package/dist/src/outbound.js +108 -0
  44. package/dist/src/outbound.js.map +1 -0
  45. package/dist/src/runtime.d.ts +6 -0
  46. package/dist/src/runtime.js +15 -0
  47. package/dist/src/runtime.js.map +1 -0
  48. package/dist/src/service.d.ts +70 -0
  49. package/dist/src/service.js +200 -0
  50. package/dist/src/service.js.map +1 -0
  51. package/dist/src/speaker.d.ts +62 -0
  52. package/dist/src/speaker.js +211 -0
  53. package/dist/src/speaker.js.map +1 -0
  54. package/dist/src/tts/mimo.d.ts +50 -0
  55. package/dist/src/tts/mimo.js +214 -0
  56. package/dist/src/tts/mimo.js.map +1 -0
  57. package/dist/src/types.d.ts +30 -0
  58. package/dist/src/types.js +1 -0
  59. package/dist/src/types.js.map +1 -0
  60. package/dist/src/utils/codec.d.ts +31 -0
  61. package/dist/src/utils/codec.js +144 -0
  62. package/dist/src/utils/codec.js.map +1 -0
  63. package/dist/src/utils/debug.d.ts +10 -0
  64. package/dist/src/utils/debug.js +15 -0
  65. package/dist/src/utils/debug.js.map +1 -0
  66. package/dist/src/utils/hash.d.ts +40 -0
  67. package/dist/src/utils/hash.js +75 -0
  68. package/dist/src/utils/hash.js.map +1 -0
  69. package/dist/src/utils/http.d.ts +24 -0
  70. package/dist/src/utils/http.js +151 -0
  71. package/dist/src/utils/http.js.map +1 -0
  72. package/dist/src/utils/index.d.ts +6 -0
  73. package/dist/src/utils/index.js +10 -0
  74. package/dist/src/utils/index.js.map +1 -0
  75. package/dist/src/utils/io.d.ts +26 -0
  76. package/dist/src/utils/io.js +53 -0
  77. package/dist/src/utils/io.js.map +1 -0
  78. package/dist/src/utils/parse.d.ts +26 -0
  79. package/dist/src/utils/parse.js +51 -0
  80. package/dist/src/utils/parse.js.map +1 -0
  81. package/index.ts +26 -0
  82. package/openclaw.plugin.json +344 -0
  83. package/package.json +106 -0
  84. package/setup-entry.ts +12 -0
  85. package/skills/migpt-volume/SKILL.md +182 -0
  86. package/skills/migpt-volume/index.ts +50 -0
  87. package/src/channel.ts +519 -0
  88. package/src/config.ts +299 -0
  89. package/src/message.ts +186 -0
  90. package/src/mi/account.ts +184 -0
  91. package/src/mi/common.ts +105 -0
  92. package/src/mi/index.ts +4 -0
  93. package/src/mi/mina.ts +261 -0
  94. package/src/mi/miot.ts +193 -0
  95. package/src/mi/typing.ts +93 -0
  96. package/src/onboarding.ts +136 -0
  97. package/src/openclaw-plugin-sdk.d.ts +185 -0
  98. package/src/outbound.ts +137 -0
  99. package/src/runtime.ts +14 -0
  100. package/src/service.ts +246 -0
  101. package/src/speaker.ts +264 -0
  102. package/src/tts/mimo.ts +300 -0
  103. package/src/types.ts +34 -0
  104. package/src/utils/codec.ts +206 -0
  105. package/src/utils/debug.ts +16 -0
  106. package/src/utils/hash.ts +104 -0
  107. package/src/utils/http.ts +193 -0
  108. package/src/utils/index.ts +5 -0
  109. package/src/utils/io.ts +68 -0
  110. package/src/utils/parse.ts +64 -0
  111. package/tsconfig.json +25 -0
package/src/channel.ts ADDED
@@ -0,0 +1,519 @@
1
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk';
2
+ import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk';
3
+ import type { ResolvedMiAccount, ExtendedOpenClawConfig } from './types.js';
4
+ import {
5
+ resolveMiAccount,
6
+ listMiAccountIds,
7
+ resolveDefaultMiAccountId,
8
+ setMiAccountEnabled,
9
+ deleteMiAccount,
10
+ resolveMiAllowFrom,
11
+ formatMiAllowFrom,
12
+ } from './config.js';
13
+ import { miOutbound } from './outbound.js';
14
+ import { miGPTOnboardingAdapter } from './onboarding.js';
15
+ import { MiService } from './service.js';
16
+ import { MiMessage } from './message.js';
17
+ import { sleep } from './utils/parse.js';
18
+ import { Debugger } from './utils/debug.js';
19
+ import { MiSpeaker } from './speaker.js';
20
+ import { getMiGPTRuntime } from './runtime.js';
21
+ import { MiMoTTS } from './tts/mimo.js';
22
+
23
+ const meta = {
24
+ id: 'migpt',
25
+ label: 'MiGPT',
26
+ selectionLabel: '小米音箱 (MiGPT)',
27
+ docsPath: '/channels/migpt',
28
+ docsLabel: 'migpt',
29
+ blurb: '小米小爱音箱语音对话。',
30
+ aliases: ['xiaomi', 'mico'],
31
+ order: 60,
32
+ };
33
+
34
+ export const miGPTPlugin: ChannelPlugin<ResolvedMiAccount> = {
35
+ id: 'migpt',
36
+ meta: {
37
+ ...meta,
38
+ },
39
+ capabilities: {
40
+ chatTypes: ['direct'],
41
+ polls: false,
42
+ threads: false,
43
+ media: true,
44
+ reactions: false,
45
+ edit: false,
46
+ reply: false,
47
+ blockStreaming: false,
48
+ },
49
+ reload: { configPrefixes: ['channels.migpt'] },
50
+ onboarding: miGPTOnboardingAdapter,
51
+
52
+ // 新增:Agent Prompt 配置,用于定制 AI 在音箱场景下的行为规范
53
+ agentPrompt: {
54
+ description: '音箱播报规范',
55
+ getConfig: (cfg: any) => {
56
+ const migptCfg = cfg.channels?.migpt;
57
+ return {
58
+ enabled: true,
59
+ systemPrompt: migptCfg?.systemPrompt,
60
+ };
61
+ },
62
+ applyConfig: (cfg: any, updates: any) => {
63
+ const nextCfg = { ...cfg } as ExtendedOpenClawConfig;
64
+ const nextMigpt = { ...nextCfg.channels?.migpt };
65
+ if (updates.systemPrompt !== undefined) {
66
+ nextMigpt.systemPrompt = updates.systemPrompt;
67
+ }
68
+ nextCfg.channels = { ...nextCfg.channels, migpt: nextMigpt };
69
+ return nextCfg;
70
+ },
71
+ },
72
+
73
+ config: {
74
+ listAccountIds: (cfg) => listMiAccountIds(cfg as unknown as ExtendedOpenClawConfig),
75
+ resolveAccount: (cfg, accountId) =>
76
+ resolveMiAccount(cfg as unknown as ExtendedOpenClawConfig, accountId),
77
+ defaultAccountId: (cfg) => resolveDefaultMiAccountId(cfg as unknown as ExtendedOpenClawConfig),
78
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
79
+ setMiAccountEnabled(cfg as unknown as ExtendedOpenClawConfig, accountId, enabled),
80
+ deleteAccount: ({ cfg, accountId }) =>
81
+ deleteMiAccount(cfg as unknown as ExtendedOpenClawConfig, accountId),
82
+ isConfigured: (account) => account.configured,
83
+ describeAccount: (account) => ({
84
+ accountId: account.accountId,
85
+ enabled: account.enabled,
86
+ configured: account.configured,
87
+ name: account.name,
88
+ devices: account.devices,
89
+ }),
90
+ resolveAllowFrom: ({ cfg, accountId }: { cfg: any; accountId?: string }) =>
91
+ resolveMiAllowFrom(cfg as unknown as ExtendedOpenClawConfig, accountId),
92
+ formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) => formatMiAllowFrom(allowFrom),
93
+ },
94
+
95
+ setup: {
96
+ resolveAccountId: ({ accountId }: { accountId?: string }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
97
+ applyAccountConfig: ({ cfg, accountId, input }: { cfg: any; accountId?: string; input: any }) => {
98
+ const migptCfg = cfg.channels?.migpt ?? {};
99
+ const accountConfig = {
100
+ userId: input.userId,
101
+ password: input.password,
102
+ passToken: input.passToken,
103
+ devices: input.devices,
104
+ enabled: true,
105
+ };
106
+
107
+ const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
108
+
109
+ if (isDefault) {
110
+ return {
111
+ ...cfg,
112
+ channels: {
113
+ ...cfg.channels,
114
+ migpt: {
115
+ ...migptCfg,
116
+ ...accountConfig,
117
+ },
118
+ },
119
+ } as ExtendedOpenClawConfig;
120
+ }
121
+
122
+ return {
123
+ ...cfg,
124
+ channels: {
125
+ ...cfg.channels,
126
+ migpt: {
127
+ ...migptCfg,
128
+ accounts: {
129
+ ...migptCfg.accounts,
130
+ [accountId]: accountConfig,
131
+ },
132
+ },
133
+ },
134
+ } as ExtendedOpenClawConfig;
135
+ },
136
+ validateInput: ({ input }: { input: any }) => {
137
+ if (!input.userId) {
138
+ return '小米 ID (userId) 是必需的';
139
+ }
140
+ if (!input.passToken && !input.password) {
141
+ return '需要提供 passToken 或 password';
142
+ }
143
+ return null;
144
+ },
145
+ },
146
+
147
+ messaging: {
148
+ normalizeTarget: (target: string) => {
149
+ // 支持格式:migpt:客厅音箱 或 客厅音箱
150
+ let id = target.replace(/^migpt:/i, '');
151
+ if (id.trim()) {
152
+ return { ok: true, to: id.trim() };
153
+ }
154
+ return { ok: false, error: 'Invalid target format' };
155
+ },
156
+ targetResolver: {
157
+ looksLikeId: (id: string): boolean => {
158
+ // 简单的启发式判断:非空字符串
159
+ return id.length > 0 && id.length < 100;
160
+ },
161
+ hint: 'MiGPT 目标格式:设备名称(如:客厅音箱)',
162
+ },
163
+ },
164
+
165
+ outbound: miOutbound,
166
+
167
+ gateway: {
168
+ startAccount: async (ctx) => {
169
+ const { account, abortSignal, log, cfg } = ctx;
170
+
171
+ log?.info(`[migpt:${account.accountId}] Starting gateway`);
172
+
173
+ if (!account.configured) {
174
+ log?.error(`[migpt:${account.accountId}] Account not configured`);
175
+ return;
176
+ }
177
+
178
+ // 获取设备列表
179
+ const devices = account.devices;
180
+ if (devices.length === 0) {
181
+ log?.error(`[migpt:${account.accountId}] No devices configured`);
182
+ return;
183
+ }
184
+
185
+ // ============ MiMo TTS 初始化(如果配置了) ============
186
+ const mimoConfig = (cfg as any).channels?.migpt?.mimo;
187
+ if (mimoConfig?.apiKey) {
188
+ // 清理旧实例,防止端口泄漏
189
+ await MiSpeaker.cleanupMiMoTTS();
190
+
191
+ const mimoTTS = new MiMoTTS({
192
+ apiKey: mimoConfig.apiKey,
193
+ baseUrl: mimoConfig.baseUrl,
194
+ model: mimoConfig.model,
195
+ voice: mimoConfig.voice,
196
+ style: mimoConfig.style,
197
+ stream: mimoConfig.stream,
198
+ port: mimoConfig.port,
199
+ host: mimoConfig.host,
200
+ });
201
+ await mimoTTS.init();
202
+ await MiSpeaker.setMiMoTTS(mimoTTS);
203
+ log?.info(`[migpt:${account.accountId}] MiMo TTS 已启用 (server: ${mimoTTS.serverUrl})`);
204
+ }
205
+
206
+ // ============ 持续对话状态 ============
207
+ const keepAliveTimeout = (cfg as any).channels?.migpt?.keepAliveTimeout ?? 30;
208
+ let keepAlive = (cfg as any).channels?.migpt?.keepAlive ?? false;
209
+ let keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
210
+
211
+ /**
212
+ * 重置持续对话超时计时器
213
+ * 超时后自动退出持续对话模式
214
+ */
215
+ const resetKeepAliveTimer = () => {
216
+ if (keepAliveTimer) clearTimeout(keepAliveTimer);
217
+ if (keepAlive) {
218
+ keepAliveTimer = setTimeout(() => {
219
+ keepAlive = false;
220
+ log?.info(`[migpt:${account.accountId}] 持续对话超时 (${keepAliveTimeout}s),退出`);
221
+ }, keepAliveTimeout * 1000);
222
+ }
223
+ };
224
+
225
+ /**
226
+ * 进入持续对话模式
227
+ */
228
+ const enterKeepAlive = () => {
229
+ keepAlive = true;
230
+ resetKeepAliveTimer();
231
+ log?.info(`[migpt:${account.accountId}] 进入持续对话模式`);
232
+ };
233
+
234
+ /**
235
+ * 退出持续对话模式
236
+ */
237
+ const exitKeepAlive = () => {
238
+ keepAlive = false;
239
+ if (keepAliveTimer) {
240
+ clearTimeout(keepAliveTimer);
241
+ keepAliveTimer = null;
242
+ }
243
+ log?.info(`[migpt:${account.accountId}] 退出持续对话模式`);
244
+ };
245
+
246
+ // 为每个设备启动轮询
247
+ const devicePromises = devices.map(async (deviceName: string) => {
248
+ log?.info(`[migpt:${account.accountId}] Starting poller for device: ${deviceName}`);
249
+
250
+ // 初始化服务(传递启动播报配置)
251
+ const initSuccess = await MiService.init({
252
+ ...account.config,
253
+ announceOnStart: account.config.announceOnStart ?? cfg.channels?.migpt?.announceOnStart,
254
+ startupMessage: account.config.startupMessage ?? cfg.channels?.migpt?.startupMessage,
255
+ }, deviceName);
256
+ if (!initSuccess) {
257
+ log?.error(`[migpt:${account.accountId}] Failed to initialize device: ${deviceName}`);
258
+ return;
259
+ }
260
+
261
+ // 设置调试模式和音箱控制方式
262
+ Debugger.debug = account.config.debug ?? false;
263
+
264
+ // 更新状态
265
+ ctx.setStatus({
266
+ ...ctx.getStatus(),
267
+ running: true,
268
+ connected: true,
269
+ lastConnectedAt: Date.now(),
270
+ });
271
+
272
+ // 获取轮询间隔
273
+ const heartbeat = cfg.channels?.migpt?.heartbeat ?? 1000;
274
+
275
+ // 持续对话唤醒关键词
276
+ const enterKeywords: string[] = (cfg as any).channels?.migpt?.keepAliveEnterKeywords ?? ['打开连续对话', '进入持续对话', '开启持续对话', '持续对话模式'];
277
+ const exitKeywords: string[] = (cfg as any).channels?.migpt?.keepAliveExitKeywords ?? ['关闭连续对话', '退出持续对话', '退出持续对话模式', '再见'];
278
+
279
+ // 轮询消息
280
+ while (!abortSignal.aborted) {
281
+ try {
282
+ const msg = await MiMessage.fetchNextMessage(deviceName);
283
+ if (msg) {
284
+ log?.info(`[migpt:${account.accountId}] Received message from ${deviceName}: ${msg.text.slice(0, 50)}...`);
285
+
286
+ // ============ 持续对话关键词检测 ============
287
+ if (enterKeywords.some(kw => msg.text.includes(kw))) {
288
+ enterKeepAlive();
289
+ const enterResult = await MiSpeaker.play({ text: '已进入持续对话模式' });
290
+ // 等音频播放完成后再唤醒,确保语音完整播放
291
+ const waitMs = enterResult.duration ? Math.ceil(enterResult.duration * 1000) + 200 : 4000;
292
+ log?.info(`[migpt:${account.accountId}] 等待音频播放完成: ${waitMs}ms`);
293
+ await sleep(waitMs);
294
+ await MiService.wakeUp();
295
+ log?.info(`[migpt:${account.accountId}] 持续对话:已唤醒音箱等待下一句`);
296
+ continue;
297
+ }
298
+ if (exitKeywords.some(kw => msg.text.includes(kw))) {
299
+ exitKeepAlive();
300
+ await MiSpeaker.play({ text: '已退出持续对话模式' });
301
+ continue;
302
+ }
303
+
304
+ // ============ 硬件控制指令过滤 ============
305
+ // 这些指令应该由小爱原生处理,不发送给 openclaw
306
+
307
+ // 豁免:持续对话控制指令
308
+ const keepAliveKeywords = [...enterKeywords, ...exitKeywords];
309
+ if (keepAliveKeywords.some(kw => msg.text.includes(kw))) {
310
+ log?.info(`[migpt:${account.accountId}] 豁免持续对话指令: ${msg.text.slice(0, 30)}...`);
311
+ continue;
312
+ }
313
+
314
+ // 从配置读取硬件控制动词关键词,默认值包含常见动词
315
+ const hardwareControlVerbs: string[] = (cfg as any).channels?.migpt?.hardwareControlVerbs ?? [
316
+ '播放', '打开', '关闭', '暂停', '继续', '停止', '切换',
317
+ '开启', '关掉', '开', '关', '启动', '调节',
318
+ '调高', '调低', '调大', '调小', '调亮', '调暗', '增大', '减小', '设置', '调到',
319
+ '导航', '拨打', '打电话', '呼叫', '发短信', '发消息',
320
+ ];
321
+
322
+ // 构建正则表达式:支持两种模式
323
+ // 模式1:动词关键词 + 内容(如"打开灯"、"播放音乐")
324
+ // 模式2:任意内容 + 动词关键词 + 内容(如"大白调小一些"、"吸顶灯调亮一些")
325
+ const hardwareControlPattern = new RegExp(
326
+ `^((${hardwareControlVerbs.join('|')})|.+(${hardwareControlVerbs.join('|')})).+`
327
+ );
328
+
329
+ const isHardwareControl = hardwareControlPattern.test(msg.text.trim());
330
+ if (isHardwareControl) {
331
+ log?.info(`[migpt:${account.accountId}] 跳过硬件控制指令: ${msg.text.slice(0, 30)}...`);
332
+ continue;
333
+ }
334
+
335
+ // ============ 抢答抑制:立即暂停小爱原生回复 ============
336
+ try {
337
+ await MiSpeaker.pause();
338
+ } catch {
339
+ // 忽略暂停失败
340
+ }
341
+
342
+ // 重置持续对话计时器
343
+ if (keepAlive) {
344
+ resetKeepAliveTimer();
345
+ }
346
+
347
+ // 记录活动
348
+ const pluginRuntime = getMiGPTRuntime();
349
+ pluginRuntime.channel.activity.record({
350
+ channel: 'migpt',
351
+ accountId: account.accountId,
352
+ direction: 'inbound',
353
+ });
354
+
355
+ // 构建路由
356
+ const fromAddress = `migpt:${deviceName}`;
357
+ const toAddress = `migpt:${account.accountId}`;
358
+ const sessionKey = `${account.accountId}:${deviceName}`;
359
+
360
+ // ============ 系统提示词注入 ============
361
+ // 收集系统提示词(账户级别 + 全局)
362
+ const systemPrompts: string[] = [];
363
+
364
+ // 账户级别的 systemPrompt
365
+ if (account.config.systemPrompt) {
366
+ systemPrompts.push(account.config.systemPrompt);
367
+ }
368
+
369
+ // 全局 systemPrompt
370
+ const globalSystemPrompt = (cfg as any).channels?.migpt?.systemPrompt;
371
+ if (globalSystemPrompt && globalSystemPrompt !== account.config.systemPrompt) {
372
+ systemPrompts.push(globalSystemPrompt);
373
+ }
374
+
375
+ // 构建消息体
376
+ const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
377
+ const body = pluginRuntime.channel.reply.formatInboundEnvelope({
378
+ Body: msg.text,
379
+ BodyForAgent: msg.text,
380
+ From: fromAddress,
381
+ To: toAddress,
382
+ SessionKey: sessionKey,
383
+ ChatType: 'direct',
384
+ SenderId: deviceName,
385
+ SenderName: deviceName,
386
+ Provider: 'migpt',
387
+ Surface: 'migpt',
388
+ MessageSid: `${deviceName}-${msg.timestamp}`,
389
+ Timestamp: msg.timestamp,
390
+ OriginatingChannel: 'migpt',
391
+ envelopeOptions,
392
+ });
393
+
394
+ // 默认的音箱场景提示词(如果没有配置 systemPrompt)
395
+ const DEFAULT_SPEAKER_PROMPT = `你是小米智能音箱助手,用简短口语回复。规则:普通对话回复 50 字以内,讲故事或查资料可以适当放宽限制不超过 200 字;不用 URL/代码/emoji/markdown 格式。`;
396
+
397
+ // 构建 AI 看到的完整上下文
398
+ const contextInfo = `你正在通过小米音箱与用户对话。
399
+
400
+ 【会话上下文】
401
+ - 设备:${deviceName}
402
+ - 用户:${deviceName}
403
+ - 消息 ID: ${deviceName}-${msg.timestamp}
404
+ - 当前时间:${new Date(msg.timestamp).toLocaleString('zh-CN')}`;
405
+
406
+ // BodyForAgent: AI 实际看到的完整上下文(动态数据 + 系统提示 + 用户输入)
407
+ const agentBody = systemPrompts.length > 0
408
+ ? `${contextInfo}\n\n${systemPrompts.join("\n\n")}\n\n${msg.text}`
409
+ : `${contextInfo}\n\n${DEFAULT_SPEAKER_PROMPT}\n\n${msg.text}`;
410
+
411
+ // 构建上下文
412
+ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
413
+ Body: body,
414
+ BodyForAgent: agentBody,
415
+ RawBody: msg.text,
416
+ CommandBody: msg.text,
417
+ From: fromAddress,
418
+ To: toAddress,
419
+ SessionKey: sessionKey,
420
+ AccountId: account.accountId,
421
+ ChatType: 'direct',
422
+ SenderId: deviceName,
423
+ SenderName: deviceName,
424
+ Provider: 'migpt',
425
+ Surface: 'migpt',
426
+ MessageSid: `${deviceName}-${msg.timestamp}`,
427
+ Timestamp: msg.timestamp,
428
+ OriginatingChannel: 'migpt',
429
+ OriginatingTo: toAddress,
430
+ CommandAuthorized: true,
431
+ });
432
+
433
+ // 分派消息到 OpenClaw
434
+ await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
435
+ ctx: ctxPayload,
436
+ cfg,
437
+ dispatcherOptions: {
438
+ responsePrefix: '',
439
+ deliver: async (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, info: { kind: string }) => {
440
+ log?.info(`[migpt:${account.accountId}] deliver called, kind: ${info.kind}`);
441
+ if (payload.text) {
442
+ const playResult = await MiSpeaker.play({ text: payload.text });
443
+
444
+ // ============ 检测 AI 回复是否包含退出关键词 ============
445
+ const exitKeywordsInReply = ['退出', '关闭', '再见', '已退出', '已关闭'];
446
+ if (exitKeywordsInReply.some(kw => payload.text!.includes(kw))) {
447
+ exitKeepAlive();
448
+ log?.info(`[migpt:${account.accountId}] AI 回复包含退出关键词,退出持续对话`);
449
+ }
450
+
451
+ // ============ 持续对话:AI 回复后重新唤醒音箱 ============
452
+ if (keepAlive) {
453
+ // 等音频播放完成后再唤醒,确保语音完整播放
454
+ const waitMs = playResult.duration ? Math.ceil(playResult.duration * 1000) + 200 : 2000;
455
+ log?.info(`[migpt:${account.accountId}] 等待音频播放完成: ${waitMs}ms`);
456
+ await sleep(waitMs);
457
+ await MiService.wakeUp();
458
+ log?.info(`[migpt:${account.accountId}] 持续对话:已唤醒音箱等待下一句`);
459
+ }
460
+ }
461
+ },
462
+ },
463
+ });
464
+ }
465
+ } catch (err: any) {
466
+ log?.error(`[migpt:${account.accountId}] Error polling messages: ${err.message}`);
467
+ ctx.setStatus({
468
+ ...ctx.getStatus(),
469
+ lastError: err.message,
470
+ });
471
+ }
472
+
473
+ await sleep(heartbeat);
474
+ }
475
+
476
+ log?.info(`[migpt:${account.accountId}] Stopping poller for device: ${deviceName}`);
477
+ });
478
+
479
+ await Promise.all(devicePromises);
480
+
481
+ // 清理持续对话计时器
482
+ if (keepAliveTimer) {
483
+ clearTimeout(keepAliveTimer);
484
+ }
485
+ },
486
+ },
487
+
488
+ status: {
489
+ defaultRuntime: {
490
+ accountId: DEFAULT_ACCOUNT_ID,
491
+ running: false,
492
+ connected: false,
493
+ lastConnectedAt: null,
494
+ lastError: null,
495
+ lastInboundAt: null,
496
+ lastOutboundAt: null,
497
+ },
498
+ buildChannelSummary: ({ snapshot }: { snapshot: any }) => ({
499
+ configured: snapshot.configured ?? false,
500
+ running: snapshot.running ?? false,
501
+ connected: snapshot.connected ?? false,
502
+ lastConnectedAt: snapshot.lastConnectedAt ?? null,
503
+ lastError: snapshot.lastError ?? null,
504
+ }),
505
+ buildAccountSnapshot: ({ account, runtime }: { account: any; runtime: any }) => ({
506
+ accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
507
+ name: account?.name,
508
+ enabled: account?.enabled ?? false,
509
+ configured: Boolean(account?.configured),
510
+ devices: account?.devices,
511
+ running: runtime?.running ?? false,
512
+ connected: runtime?.connected ?? false,
513
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
514
+ lastError: runtime?.lastError ?? null,
515
+ lastInboundAt: runtime?.lastInboundAt ?? null,
516
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
517
+ }),
518
+ },
519
+ };