@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/README.md +11 -3
- package/dist/config-schema.d.ts +33 -0
- package/dist/config-schema.d.ts.map +1 -0
- package/dist/config-schema.js +50 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -37
- package/dist/index.js.map +1 -1
- package/openclaw.plugin.json +100 -41
- package/package.json +9 -8
- package/src/config-schema.ts +71 -0
- package/src/config.ts +1 -1
- package/src/index.ts +25 -37
- package/bin/folotoy.mjs +0 -2
- package/src/__tests__/channel.test.ts +0 -126
- package/src/__tests__/mqtt.test.ts +0 -14
- package/src/__tests__/package-info.test.ts +0 -39
- package/src/__tests__/preset.test.ts +0 -85
- package/src/__tests__/soothing.test.ts +0 -47
- package/src/__tests__/test-message.mjs +0 -190
- package/src/cli/install.ts +0 -195
- package/src/cli/package-info.ts +0 -48
- package/src/cli/preset.ts +0 -54
- package/src/cli/qrcode-terminal.d.ts +0 -3
- package/src/presets/single-soothing.json +0 -3
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 {
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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,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)
|