@folotoy/folotoy-openclaw-plugin 0.7.0 → 0.8.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.
package/src/index.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { OpenClawPluginApi, ChannelPlugin, PluginRuntime } from 'openclaw/plugin-sdk/core'
2
+ import { buildChannelConfigSchema } from 'openclaw/plugin-sdk'
2
3
  import { resolveCredentials, createMqttClient, buildInboundTopic, buildOutboundTopic, buildNotificationTopic } from './mqtt.js'
3
4
  import { createSoothingPicker } from './soothing.js'
4
5
  import { stripMarkdown } from './strip-markdown.js'
5
- import { DEFAULT_MQTT_HOST, DEFAULT_MQTT_PORT, DEFAULT_SUMMARY_ENABLED, DEFAULT_SUMMARY_MAX_CHARS, DEFAULT_SENTENCE_SPLIT_ENABLED, DEFAULT_SENTENCE_SPLIT_DELIMITERS, DEFAULT_SOOTHING_LOOP_ENABLED, DEFAULT_SOOTHING_LOOP_INTERVAL_MS, flatToPluginConfig } from './config.js'
6
+ import { DEFAULT_SUMMARY_ENABLED, DEFAULT_SUMMARY_MAX_CHARS, DEFAULT_SENTENCE_SPLIT_ENABLED, DEFAULT_SENTENCE_SPLIT_DELIMITERS, DEFAULT_SOOTHING_LOOP_ENABLED, DEFAULT_SOOTHING_LOOP_INTERVAL_MS, flatToPluginConfig } from './config.js'
6
7
  import type { FlatChannelConfig } from './config.js'
8
+ import { FolotoyConfigSchema } from './config-schema.js'
7
9
  import type { MqttClient } from 'mqtt'
8
10
 
9
11
  type InboundMessage = {
@@ -42,40 +44,12 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
42
44
  capabilities: {
43
45
  chatTypes: ['direct'],
44
46
  },
47
+ // ChannelPlugin's per-channel configSchema is left empty here on purpose:
48
+ // the plugin-level configSchema (set on the default-exported plugin object
49
+ // below) is what OpenClaw 2026.4.x's web UI reads. Mirrors what
50
+ // openclaw-weixin and other 2026.4.x-compatible plugins do.
45
51
  configSchema: {
46
- schema: {
47
- type: 'object',
48
- properties: {
49
- flow: { type: 'string', enum: ['direct', 'api'], default: 'direct' },
50
- toy_sn: { type: 'string' },
51
- toy_key: { type: 'string' },
52
- api_url: { type: 'string', default: 'https://api.folotoy.cn' },
53
- api_key: { type: 'string' },
54
- mqtt_host: { type: 'string', default: DEFAULT_MQTT_HOST },
55
- mqtt_port: { type: 'number', default: DEFAULT_MQTT_PORT },
56
- summary_enabled: { type: 'boolean', default: DEFAULT_SUMMARY_ENABLED },
57
- summary_max_chars: { type: 'number', default: DEFAULT_SUMMARY_MAX_CHARS },
58
- sentence_split_enabled: { type: 'boolean', default: DEFAULT_SENTENCE_SPLIT_ENABLED },
59
- sentence_split_delimiters: { type: 'string', default: DEFAULT_SENTENCE_SPLIT_DELIMITERS },
60
- soothing_loop_enabled: { type: 'boolean', default: DEFAULT_SOOTHING_LOOP_ENABLED },
61
- soothing_loop_interval_ms: { type: 'number', default: DEFAULT_SOOTHING_LOOP_INTERVAL_MS },
62
- },
63
- },
64
- uiHints: {
65
- flow: { label: 'Auth Flow' },
66
- toy_sn: { label: 'Toy SN' },
67
- toy_key: { label: 'Toy Key', sensitive: true },
68
- api_url: { label: 'API URL', placeholder: 'https://api.folotoy.com' },
69
- api_key: { label: 'API Key', sensitive: true },
70
- mqtt_host: { label: 'MQTT Host', placeholder: DEFAULT_MQTT_HOST },
71
- mqtt_port: { label: 'MQTT Port' },
72
- summary_enabled: { label: 'Enable Summary' },
73
- summary_max_chars: { label: 'Summary Max Characters' },
74
- sentence_split_enabled: { label: 'Enable Sentence Splitting' },
75
- sentence_split_delimiters: { label: 'Sentence Delimiters' },
76
- soothing_loop_enabled: { label: 'Enable Soothing Loop' },
77
- soothing_loop_interval_ms: { label: 'Soothing Loop Interval (ms)' },
78
- },
52
+ schema: { type: 'object', additionalProperties: false, properties: {} },
79
53
  },
80
54
  config: {
81
55
  listAccountIds: (cfg) => {
@@ -455,7 +429,21 @@ export function sendNotification({ text, accountId }: { text: string; accountId?
455
429
  return { channel: 'folotoy', messageId: String(msgId) }
456
430
  }
457
431
 
458
- export default (api: OpenClawPluginApi) => {
459
- subagent = api.runtime.subagent
460
- api.registerChannel({ plugin: folotoyChannel })
432
+ /**
433
+ * Plugin manifest exposed to OpenClaw. Top-level fields (id/name/description/
434
+ * configSchema) are what the OpenClaw 2026.4.x web UI reads to render the
435
+ * config form — the per-channel configSchema on `folotoyChannel` above is
436
+ * intentionally empty so the UI uses this one.
437
+ */
438
+ const folotoyPlugin = {
439
+ id: 'folotoy-openclaw-plugin',
440
+ name: 'FoloToy',
441
+ description: 'Empower your FoloToy with OpenClaw AI capabilities.',
442
+ configSchema: buildChannelConfigSchema(FolotoyConfigSchema),
443
+ register(api: OpenClawPluginApi) {
444
+ subagent = api.runtime.subagent
445
+ api.registerChannel({ plugin: folotoyChannel })
446
+ },
461
447
  }
448
+
449
+ export default folotoyPlugin
package/bin/folotoy.mjs DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- import('../dist/cli/install.js')
@@ -1,126 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { EventEmitter } from 'events'
3
- import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
4
-
5
- // Replicate the message parsing logic from index.ts for unit testing
6
- type InboundMessage = {
7
- msgId: number
8
- identifier: 'chat_input'
9
- inputParams: { text: string; recording_id: number }
10
- }
11
-
12
- type OutboundMessage = {
13
- msgId: number
14
- identifier: 'chat_output'
15
- outParams: { content: string; recording_id: number; order: number; is_finished: boolean }
16
- }
17
-
18
- function makeMockClient() {
19
- const emitter = new EventEmitter()
20
- return Object.assign(emitter, {
21
- subscribe: vi.fn((_topic: string, cb: (err: null) => void) => cb(null)),
22
- publish: vi.fn(),
23
- })
24
- }
25
-
26
- function setupSubscriber(client: ReturnType<typeof makeMockClient>, toy_sn: string, onMessage: (msgId: number, text: string, recording_id: number) => void) {
27
- const topic = buildInboundTopic(toy_sn)
28
- client.subscribe(topic, () => {})
29
- client.on('message', (_topic: string, payload: Buffer) => {
30
- if (_topic !== topic) return
31
- try {
32
- const msg = JSON.parse(payload.toString()) as InboundMessage
33
- if (msg.identifier !== 'chat_input' || typeof msg.inputParams?.text !== 'string') return
34
- onMessage(msg.msgId, msg.inputParams.text, msg.inputParams.recording_id)
35
- } catch {
36
- // ignore
37
- }
38
- })
39
- }
40
-
41
- describe('inbound message parsing', () => {
42
- const toy_sn = 'SN001'
43
- const inboundTopic = buildInboundTopic(toy_sn)
44
-
45
- it('calls onMessage with msgId, text and recording_id on valid chat_input', () => {
46
- const client = makeMockClient()
47
- const onMessage = vi.fn()
48
- setupSubscriber(client, toy_sn, onMessage)
49
-
50
- const msg: InboundMessage = { msgId: 42, identifier: 'chat_input', inputParams: { text: 'hello', recording_id: 100 } }
51
- client.emit('message', inboundTopic, Buffer.from(JSON.stringify(msg)))
52
-
53
- expect(onMessage).toHaveBeenCalledWith(42, 'hello', 100)
54
- })
55
-
56
- it('ignores messages on other topics', () => {
57
- const client = makeMockClient()
58
- const onMessage = vi.fn()
59
- setupSubscriber(client, toy_sn, onMessage)
60
-
61
- const msg: InboundMessage = { msgId: 1, identifier: 'chat_input', inputParams: { text: 'hi', recording_id: 1 } }
62
- client.emit('message', '/openapi/folotoy/OTHER/thing/command/call', Buffer.from(JSON.stringify(msg)))
63
-
64
- expect(onMessage).not.toHaveBeenCalled()
65
- })
66
-
67
- it('ignores messages with unknown identifier', () => {
68
- const client = makeMockClient()
69
- const onMessage = vi.fn()
70
- setupSubscriber(client, toy_sn, onMessage)
71
-
72
- client.emit('message', inboundTopic, Buffer.from(JSON.stringify({ msgId: 1, identifier: 'other', inputParams: { text: 'hi', recording_id: 1 } })))
73
-
74
- expect(onMessage).not.toHaveBeenCalled()
75
- })
76
-
77
- it('ignores malformed JSON', () => {
78
- const client = makeMockClient()
79
- const onMessage = vi.fn()
80
- setupSubscriber(client, toy_sn, onMessage)
81
-
82
- client.emit('message', inboundTopic, Buffer.from('not json'))
83
-
84
- expect(onMessage).not.toHaveBeenCalled()
85
- })
86
- })
87
-
88
- describe('outbound message format', () => {
89
- const toy_sn = 'SN001'
90
- const outboundTopic = buildOutboundTopic(toy_sn)
91
-
92
- it('publishes chat_output with recording_id, order and is_finished', () => {
93
- const client = makeMockClient()
94
- const outMsg: OutboundMessage = {
95
- msgId: 42,
96
- identifier: 'chat_output',
97
- outParams: { content: 'world', recording_id: 100, order: 1, is_finished: false },
98
- }
99
- client.publish(outboundTopic, JSON.stringify(outMsg))
100
-
101
- expect(client.publish).toHaveBeenCalledOnce()
102
- const [t, payload] = client.publish.mock.calls[0] as [string, string]
103
- expect(t).toBe(outboundTopic)
104
- expect(JSON.parse(payload)).toEqual({
105
- msgId: 42,
106
- identifier: 'chat_output',
107
- outParams: { content: 'world', recording_id: 100, order: 1, is_finished: false },
108
- })
109
- })
110
-
111
- it('publishes finish message with is_finished=true', () => {
112
- const client = makeMockClient()
113
- const finishMsg: OutboundMessage = {
114
- msgId: 42,
115
- identifier: 'chat_output',
116
- outParams: { content: '', recording_id: 100, order: 2, is_finished: true },
117
- }
118
- client.publish(outboundTopic, JSON.stringify(finishMsg))
119
-
120
- const [, payload] = client.publish.mock.calls[0] as [string, string]
121
- const parsed = JSON.parse(payload)
122
- expect(parsed.outParams.is_finished).toBe(true)
123
- expect(parsed.outParams.order).toBe(2)
124
- expect(parsed.outParams.content).toBe('')
125
- })
126
- })
@@ -1,14 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
3
-
4
- describe('buildInboundTopic', () => {
5
- it('builds the correct inbound topic for a given SN', () => {
6
- expect(buildInboundTopic('SN001')).toBe('/openapi/folotoy/SN001/thing/command/call')
7
- })
8
- })
9
-
10
- describe('buildOutboundTopic', () => {
11
- it('builds the correct outbound topic for a given SN', () => {
12
- expect(buildOutboundTopic('SN001')).toBe('/openapi/folotoy/SN001/thing/command/callAck')
13
- })
14
- })
@@ -1,39 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { readFileSync } from 'node:fs'
3
- import { join } from 'node:path'
4
- import {
5
- findPackageRoot,
6
- getInstallSpec,
7
- getPluginName,
8
- getPluginVersion,
9
- } from '../cli/package-info.js'
10
-
11
- describe('package-info', () => {
12
- it('findPackageRoot resolves to a directory containing package.json', () => {
13
- const root = findPackageRoot()
14
- const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
15
- expect(typeof pkg.name).toBe('string')
16
- expect(typeof pkg.version).toBe('string')
17
- })
18
-
19
- it('getPluginName matches package.json name', () => {
20
- const root = findPackageRoot()
21
- const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
22
- expect(getPluginName()).toBe(pkg.name)
23
- })
24
-
25
- it('getPluginVersion matches package.json version', () => {
26
- const root = findPackageRoot()
27
- const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
28
- expect(getPluginVersion()).toBe(pkg.version)
29
- })
30
-
31
- it('getInstallSpec is "<name>@<version>" — pins the exact version so OpenClaw will install prereleases', () => {
32
- expect(getInstallSpec()).toBe(`${getPluginName()}@${getPluginVersion()}`)
33
- // Sanity: it must contain an `@` separator after the scope's leading `@`.
34
- const spec = getInstallSpec()
35
- const lastAt = spec.lastIndexOf('@')
36
- expect(lastAt).toBeGreaterThan(0)
37
- expect(spec.slice(lastAt + 1)).toMatch(/^\d+\.\d+\.\d+/)
38
- })
39
- })
@@ -1,85 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
- import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
5
- import { listPresets, loadPreset, PRESET_WHITELIST } from '../cli/preset.js'
6
-
7
- let dir: string
8
-
9
- beforeEach(() => {
10
- dir = mkdtempSync(join(tmpdir(), 'preset-test-'))
11
- })
12
-
13
- afterEach(() => {
14
- rmSync(dir, { recursive: true, force: true })
15
- })
16
-
17
- function writePreset(name: string, body: unknown): void {
18
- writeFileSync(join(dir, `${name}.json`), JSON.stringify(body))
19
- }
20
-
21
- describe('loadPreset', () => {
22
- it('returns parsed preset for whitelisted boolean key', () => {
23
- writePreset('single-soothing', { soothing_loop_enabled: false })
24
- expect(loadPreset('single-soothing', dir)).toEqual({ soothing_loop_enabled: false })
25
- })
26
-
27
- it('throws when preset file does not exist, listing available presets', () => {
28
- writePreset('alpha', { soothing_loop_enabled: true })
29
- writePreset('beta', { soothing_loop_enabled: false })
30
- expect(() => loadPreset('missing', dir)).toThrow(/Preset "missing" not found.*alpha, beta/)
31
- })
32
-
33
- it('throws when preset has unknown key', () => {
34
- writePreset('bad', { soothing_loop_enabled: false, summary_enabled: true })
35
- expect(() => loadPreset('bad', dir)).toThrow(/unknown key "summary_enabled"/)
36
- })
37
-
38
- it('throws when whitelisted key has wrong type', () => {
39
- writePreset('bad', { soothing_loop_enabled: 'false' })
40
- expect(() => loadPreset('bad', dir)).toThrow(/must be a boolean \(got string\)/)
41
- })
42
-
43
- it('throws when JSON is not an object', () => {
44
- writeFileSync(join(dir, 'arr.json'), '[1, 2, 3]')
45
- expect(() => loadPreset('arr', dir)).toThrow(/must be a JSON object/)
46
- })
47
-
48
- it('throws when JSON is malformed', () => {
49
- writeFileSync(join(dir, 'bad.json'), '{ not json')
50
- expect(() => loadPreset('bad', dir)).toThrow(/not valid JSON/)
51
- })
52
-
53
- it('whitelist contains only soothing_loop_enabled (current scope)', () => {
54
- expect(PRESET_WHITELIST).toEqual(['soothing_loop_enabled'])
55
- })
56
- })
57
-
58
- describe('shipped presets', () => {
59
- it('single-soothing resolves from default package presets dir and disables the soothing loop', () => {
60
- expect(loadPreset('single-soothing')).toEqual({ soothing_loop_enabled: false })
61
- })
62
-
63
- it('lists single-soothing among the default presets', () => {
64
- expect(listPresets()).toContain('single-soothing')
65
- })
66
- })
67
-
68
- describe('listPresets', () => {
69
- it('returns sorted preset names without .json extension', () => {
70
- writePreset('zeta', {})
71
- writePreset('alpha', {})
72
- writePreset('beta', {})
73
- expect(listPresets(dir)).toEqual(['alpha', 'beta', 'zeta'])
74
- })
75
-
76
- it('returns empty array for nonexistent directory', () => {
77
- expect(listPresets(join(dir, 'does-not-exist'))).toEqual([])
78
- })
79
-
80
- it('ignores non-json files', () => {
81
- writePreset('valid', {})
82
- writeFileSync(join(dir, 'README.md'), 'hi')
83
- expect(listPresets(dir)).toEqual(['valid'])
84
- })
85
- })
@@ -1,47 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { pickSoothingReply } from '../soothing.js'
3
-
4
- const CATEGORIES = [
5
- { input: '我今天好难过', label: '难过类' },
6
- { input: '我很生气', label: '生气类' },
7
- { input: '我好累', label: '累/怕类' },
8
- { input: '给我讲个故事', label: '故事类' },
9
- { input: '唱首儿歌', label: '唱歌类' },
10
- { input: '讲个笑话', label: '笑话类' },
11
- { input: '为什么天是蓝的', label: '知识类' },
12
- { input: '然后呢', label: '继续类' },
13
- { input: '你好', label: '打招呼类' },
14
- { input: '晚安', label: '告别类' },
15
- { input: '帮我连接wifi', label: '配网类' },
16
- { input: '帮我查天气', label: '查询类' },
17
- { input: '随便说点什么', label: '兜底' },
18
- ]
19
-
20
- describe('pickSoothingReply', () => {
21
- it.each(CATEGORIES)('$label — returns a non-empty string', ({ input }) => {
22
- const reply = pickSoothingReply(input)
23
- expect(typeof reply).toBe('string')
24
- expect(reply.length).toBeGreaterThan(0)
25
- })
26
-
27
- it.each(CATEGORIES)('$label — never contains 马上', ({ input }) => {
28
- // Run multiple times to cover random selection
29
- for (let i = 0; i < 20; i++) {
30
- expect(pickSoothingReply(input)).not.toContain('马上')
31
- }
32
- })
33
-
34
- it('randomly selects from candidates (not always the same reply)', () => {
35
- const results = new Set(
36
- Array.from({ length: 40 }, () => pickSoothingReply('我今天好难过'))
37
- )
38
- expect(results.size).toBeGreaterThan(1)
39
- })
40
-
41
- it('告别类 returns only short affirmatives', () => {
42
- const allowed = new Set(['好嘞~', '嗯嗯~', '好的~', '哦~', '嗯~'])
43
- for (let i = 0; i < 20; i++) {
44
- expect(allowed.has(pickSoothingReply('晚安'))).toBe(true)
45
- }
46
- })
47
- })
@@ -1,190 +0,0 @@
1
- /**
2
- * MQTT integration test — simulates a FoloToy toy.
3
- *
4
- * Modes:
5
- * chat — send a message, verify outbound reply format (soothing + reply + finish)
6
- * reminder — send a reminder request, verify reply, wait for notification delivery
7
- *
8
- * Configuration (via .env or environment variables):
9
- * FOLOTOY_TOY_SN — toy serial number
10
- * FOLOTOY_TOY_KEY — toy key (MQTT password)
11
- * FOLOTOY_MQTT_HOST — MQTT broker host
12
- * FOLOTOY_MQTT_PORT — MQTT broker port (default 1883)
13
- * FOLOTOY_TEST_MQTT_USERNAME — MQTT username for test client (default: FOLOTOY_TOY_SN)
14
- * FOLOTOY_TEST_MQTT_PASSWORD — MQTT password for test client (default: FOLOTOY_TOY_KEY)
15
- *
16
- * Usage:
17
- * node --env-file=.env test-mqtt.mjs chat "你好"
18
- * node --env-file=.env test-mqtt.mjs reminder
19
- * node --env-file=.env test-mqtt.mjs # defaults to: chat "你好"
20
- */
21
- import mqtt from 'mqtt'
22
-
23
- const SN = process.env.FOLOTOY_TOY_SN
24
- const KEY = process.env.FOLOTOY_TOY_KEY
25
- const HOST = process.env.FOLOTOY_MQTT_HOST
26
- const PORT = Number(process.env.FOLOTOY_MQTT_PORT || 1883)
27
-
28
- if (!SN || !HOST) {
29
- console.error('Missing FOLOTOY_TOY_SN or FOLOTOY_MQTT_HOST. Set them in .env or as env vars.')
30
- process.exit(1)
31
- }
32
-
33
- // Parse args
34
- const args = process.argv.slice(2)
35
- let mode = 'chat'
36
- let message = '你好'
37
-
38
- if (args[0] === 'chat') {
39
- mode = 'chat'
40
- message = args[1] || '你好'
41
- } else if (args[0] === 'reminder') {
42
- mode = 'reminder'
43
- message = args[1] || '1分钟后提醒我站起来'
44
- } else if (args[0]) {
45
- message = args[0]
46
- }
47
-
48
- const inboundTopic = `/openapi/folotoy/${SN}/thing/command/call`
49
- const outboundTopic = `/openapi/folotoy/${SN}/thing/command/callAck`
50
- const notificationTopic = `/openapi/folotoy/${SN}/thing/event/post`
51
-
52
- // ── Validation state ──
53
- const errors = []
54
- let gotSoothing = false
55
- let gotReply = false
56
- let gotFinish = false
57
- let gotNotification = false
58
-
59
- function validateOutboundMessage(msg) {
60
- if (msg.identifier !== 'chat_output') {
61
- errors.push(`Expected identifier "chat_output", got "${msg.identifier}"`)
62
- }
63
- const p = msg.outParams
64
- if (typeof p?.recording_id !== 'number') {
65
- errors.push(`Missing or invalid recording_id in outbound message (order=${p?.order})`)
66
- }
67
- if (typeof p?.order !== 'number') {
68
- errors.push('Missing or invalid order in outbound message')
69
- }
70
- if (typeof p?.is_finished !== 'boolean') {
71
- errors.push(`Missing or invalid is_finished in outbound message (order=${p?.order})`)
72
- }
73
- }
74
-
75
- function validateNotificationMessage(msg) {
76
- if (msg.identifier !== 'send_notification') {
77
- errors.push(`Expected notification identifier "send_notification", got "${msg.identifier}"`)
78
- }
79
- if (typeof msg.outParams?.text !== 'string' || !msg.outParams.text) {
80
- errors.push('Notification missing outParams.text')
81
- }
82
- if ('recording_id' in (msg.outParams || {})) {
83
- errors.push('Notification should not contain recording_id')
84
- }
85
- }
86
-
87
- function printSummary() {
88
- console.log('\n' + '='.repeat(60))
89
- console.log(`TEST SUMMARY (mode: ${mode})`)
90
- console.log('='.repeat(60))
91
-
92
- const checks = [
93
- ['Soothing ack (order=1)', gotSoothing],
94
- ['AI reply (order=2+)', gotReply],
95
- ['Finish message (is_finished=true)', gotFinish],
96
- ]
97
-
98
- for (const [label, ok] of checks) {
99
- console.log(` ${ok ? '✅' : '❌'} ${label}`)
100
- }
101
-
102
- if (mode === 'reminder') {
103
- console.log(` ${gotNotification ? '✅' : 'ℹ️ '} Notification on event/post${gotNotification ? '' : ' (not received within timeout — may arrive later)'}`)
104
- }
105
-
106
- if (errors.length > 0) {
107
- console.log('\nErrors:')
108
- for (const e of errors) console.log(` ❌ ${e}`)
109
- }
110
-
111
- const allPassed = checks.every(([, ok]) => ok) && errors.length === 0
112
- console.log(`\n${allPassed ? '✅ ALL CHECKS PASSED' : '❌ SOME CHECKS FAILED'}`)
113
- return allPassed
114
- }
115
-
116
- function done() {
117
- const passed = printSummary()
118
- client.end()
119
- process.exit(passed ? 0 : 1)
120
- }
121
-
122
- // ── MQTT ──
123
- const MQTT_USERNAME = process.env.FOLOTOY_TEST_MQTT_USERNAME || SN
124
- const MQTT_PASSWORD = process.env.FOLOTOY_TEST_MQTT_PASSWORD || KEY
125
-
126
- const client = mqtt.connect(`mqtt://${HOST}:${PORT}`, {
127
- clientId: `test-toy-${Date.now()}`,
128
- username: MQTT_USERNAME,
129
- password: MQTT_PASSWORD,
130
- })
131
-
132
- client.on('connect', () => {
133
- console.log(`[connected] ${HOST}:${PORT} as ${SN}`)
134
- console.log(`[mode] ${mode}`)
135
- console.log(`[message] ${message}`)
136
-
137
- client.subscribe([outboundTopic, notificationTopic], (err) => {
138
- if (err) {
139
- console.error('[subscribe error]', err.message)
140
- process.exit(1)
141
- }
142
-
143
- const msg = {
144
- msgId: 1,
145
- identifier: 'chat_input',
146
- inputParams: { text: message, recording_id: 999 },
147
- }
148
- console.log(`\n[publish] ${inboundTopic}`)
149
- console.log(JSON.stringify(msg, null, 2))
150
- client.publish(inboundTopic, JSON.stringify(msg))
151
- })
152
- })
153
-
154
- client.on('message', (topic, payload) => {
155
- let msg
156
- try { msg = JSON.parse(payload.toString()) } catch { return }
157
-
158
- if (topic === outboundTopic) {
159
- const p = msg.outParams
160
- const label = p?.is_finished ? 'FINISH' : `REPLY(order=${p?.order})`
161
- console.log(`\n[OUTBOUND ${label}] ${p?.content || '(empty)'}`)
162
- validateOutboundMessage(msg)
163
-
164
- if (p?.order === 1 && !p?.is_finished) gotSoothing = true
165
- if (p?.order >= 2 && !p?.is_finished && p?.content) gotReply = true
166
- if (p?.is_finished) {
167
- gotFinish = true
168
- if (mode === 'chat') {
169
- setTimeout(done, 500)
170
- } else {
171
- console.log('\n[waiting up to 90s for notification on event/post topic...]')
172
- }
173
- }
174
- }
175
-
176
- if (topic === notificationTopic) {
177
- console.log(`\n[NOTIFICATION] ${msg.outParams?.text || JSON.stringify(msg)}`)
178
- validateNotificationMessage(msg)
179
- gotNotification = true
180
- setTimeout(done, 500)
181
- }
182
- })
183
-
184
- client.on('error', (err) => {
185
- console.error('[mqtt error]', err.message)
186
- })
187
-
188
- // Timeout: chat 30s, reminder 120s
189
- const timeoutMs = mode === 'reminder' ? 120_000 : 30_000
190
- setTimeout(done, timeoutMs)