@folotoy/folotoy-openclaw-plugin 0.6.2 → 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 +23 -2
- package/dist/cli/install.js +39 -6
- package/dist/cli/install.js.map +1 -1
- package/dist/cli/package-info.d.ts +12 -0
- package/dist/cli/package-info.d.ts.map +1 -0
- package/dist/cli/package-info.js +42 -0
- package/dist/cli/package-info.js.map +1 -0
- package/dist/cli/preset.d.ts +6 -0
- package/dist/cli/preset.d.ts.map +1 -0
- package/dist/cli/preset.js +45 -0
- package/dist/cli/preset.js.map +1 -0
- 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__/soothing.test.ts +0 -47
- package/src/__tests__/test-message.mjs +0 -233
- package/src/cli/install.ts +0 -157
- package/src/cli/qrcode-terminal.d.ts +0 -3
package/openclaw.plugin.json
CHANGED
|
@@ -2,51 +2,110 @@
|
|
|
2
2
|
"id": "folotoy-openclaw-plugin",
|
|
3
3
|
"name": "FoloToy",
|
|
4
4
|
"description": "Empower your FoloToy with OpenClaw AI capabilities.",
|
|
5
|
-
"version": "0.
|
|
6
|
-
"channels": [
|
|
5
|
+
"version": "0.8.0",
|
|
6
|
+
"channels": [
|
|
7
|
+
"folotoy"
|
|
8
|
+
],
|
|
7
9
|
"configSchema": {
|
|
8
10
|
"type": "object",
|
|
9
11
|
"additionalProperties": false,
|
|
10
|
-
"properties": {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"type": "
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
12
|
+
"properties": {}
|
|
13
|
+
},
|
|
14
|
+
"channelConfigs": {
|
|
15
|
+
"folotoy": {
|
|
16
|
+
"label": "FoloToy",
|
|
17
|
+
"description": "FoloToy smart toy channel — bridges toys to OpenClaw via MQTT.",
|
|
18
|
+
"schema": {
|
|
19
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"flow": {
|
|
23
|
+
"default": "direct",
|
|
24
|
+
"description": "Auth flow",
|
|
25
|
+
"type": "string",
|
|
26
|
+
"enum": [
|
|
27
|
+
"direct",
|
|
28
|
+
"api"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"toy_sn": {
|
|
32
|
+
"description": "Toy serial number",
|
|
33
|
+
"type": "string"
|
|
34
|
+
},
|
|
35
|
+
"toy_key": {
|
|
36
|
+
"description": "Toy key (used as MQTT password in direct flow)",
|
|
37
|
+
"type": "string"
|
|
38
|
+
},
|
|
39
|
+
"api_url": {
|
|
40
|
+
"default": "https://api.folotoy.cn",
|
|
41
|
+
"description": "FoloToy API base URL (api flow)",
|
|
42
|
+
"type": "string"
|
|
43
|
+
},
|
|
44
|
+
"api_key": {
|
|
45
|
+
"description": "FoloToy API key (api flow)",
|
|
46
|
+
"type": "string"
|
|
47
|
+
},
|
|
48
|
+
"mqtt_host": {
|
|
49
|
+
"default": "f.folotoy.cn",
|
|
50
|
+
"description": "MQTT broker host",
|
|
51
|
+
"type": "string"
|
|
52
|
+
},
|
|
53
|
+
"mqtt_port": {
|
|
54
|
+
"default": 1883,
|
|
55
|
+
"description": "MQTT broker port",
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"minimum": -9007199254740991,
|
|
58
|
+
"maximum": 9007199254740991
|
|
59
|
+
},
|
|
60
|
+
"summary_enabled": {
|
|
61
|
+
"default": true,
|
|
62
|
+
"description": "Enable reply summarization for over-long replies",
|
|
63
|
+
"type": "boolean"
|
|
64
|
+
},
|
|
65
|
+
"summary_max_chars": {
|
|
66
|
+
"default": 200,
|
|
67
|
+
"description": "Character threshold that triggers summarization",
|
|
68
|
+
"type": "integer",
|
|
69
|
+
"minimum": -9007199254740991,
|
|
70
|
+
"maximum": 9007199254740991
|
|
71
|
+
},
|
|
72
|
+
"sentence_split_enabled": {
|
|
73
|
+
"default": true,
|
|
74
|
+
"description": "Stream replies sentence-by-sentence for faster TTS",
|
|
75
|
+
"type": "boolean"
|
|
76
|
+
},
|
|
77
|
+
"sentence_split_delimiters": {
|
|
78
|
+
"default": "!。?;!.?;~",
|
|
79
|
+
"description": "Punctuation characters that split sentences",
|
|
80
|
+
"type": "string"
|
|
81
|
+
},
|
|
82
|
+
"soothing_loop_enabled": {
|
|
83
|
+
"default": true,
|
|
84
|
+
"description": "Repeat soothing replies while waiting for the LLM. Disable to send only the initial order=1 reply.",
|
|
85
|
+
"type": "boolean"
|
|
86
|
+
},
|
|
87
|
+
"soothing_loop_interval_ms": {
|
|
88
|
+
"default": 8000,
|
|
89
|
+
"description": "Interval (ms) between soothing replies during the LLM wait",
|
|
90
|
+
"type": "integer",
|
|
91
|
+
"minimum": -9007199254740991,
|
|
92
|
+
"maximum": 9007199254740991
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"required": [
|
|
96
|
+
"flow",
|
|
97
|
+
"api_url",
|
|
98
|
+
"mqtt_host",
|
|
99
|
+
"mqtt_port",
|
|
100
|
+
"summary_enabled",
|
|
101
|
+
"summary_max_chars",
|
|
102
|
+
"sentence_split_enabled",
|
|
103
|
+
"sentence_split_delimiters",
|
|
104
|
+
"soothing_loop_enabled",
|
|
105
|
+
"soothing_loop_interval_ms"
|
|
106
|
+
],
|
|
107
|
+
"additionalProperties": false
|
|
40
108
|
}
|
|
41
109
|
}
|
|
42
|
-
},
|
|
43
|
-
"uiHints": {
|
|
44
|
-
"flow": { "label": "Auth Flow" },
|
|
45
|
-
"toy_sn": { "label": "Toy SN" },
|
|
46
|
-
"toy_key": { "label": "Toy Key", "sensitive": true },
|
|
47
|
-
"api_url": { "label": "API URL", "placeholder": "https://api.folotoy.cn" },
|
|
48
|
-
"api_key": { "label": "API Key", "sensitive": true },
|
|
49
|
-
"mqtt_host": { "label": "MQTT Host", "placeholder": "198.19.249.25" },
|
|
50
|
-
"mqtt_port": { "label": "MQTT Port", "placeholder": "1883" }
|
|
51
110
|
}
|
|
52
111
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@folotoy/folotoy-openclaw-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Empower your FoloToy with OpenClaw AI capabilities.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"folotoy",
|
|
@@ -22,17 +22,18 @@
|
|
|
22
22
|
},
|
|
23
23
|
"main": "./src/index.ts",
|
|
24
24
|
"types": "./src/index.ts",
|
|
25
|
-
"bin": {
|
|
26
|
-
"folotoy-openclaw-plugin": "./bin/folotoy.mjs"
|
|
27
|
-
},
|
|
28
25
|
"files": [
|
|
29
|
-
"src",
|
|
30
|
-
"
|
|
26
|
+
"src/index.ts",
|
|
27
|
+
"src/mqtt.ts",
|
|
28
|
+
"src/config.ts",
|
|
29
|
+
"src/config-schema.ts",
|
|
30
|
+
"src/soothing.ts",
|
|
31
|
+
"src/strip-markdown.ts",
|
|
31
32
|
"dist",
|
|
32
33
|
"openclaw.plugin.json"
|
|
33
34
|
],
|
|
34
35
|
"scripts": {
|
|
35
|
-
"build": "tsc",
|
|
36
|
+
"build": "tsc && node scripts/sync-manifest.mjs",
|
|
36
37
|
"prepublishOnly": "npm run build",
|
|
37
38
|
"dev": "tsc --watch",
|
|
38
39
|
"typecheck": "tsc --noEmit",
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"mqtt": "^5.10.1",
|
|
45
|
-
"
|
|
46
|
+
"zod": "^4.0.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/node": "^22.13.10",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_API_URL,
|
|
5
|
+
DEFAULT_MQTT_HOST,
|
|
6
|
+
DEFAULT_MQTT_PORT,
|
|
7
|
+
DEFAULT_SENTENCE_SPLIT_DELIMITERS,
|
|
8
|
+
DEFAULT_SENTENCE_SPLIT_ENABLED,
|
|
9
|
+
DEFAULT_SOOTHING_LOOP_ENABLED,
|
|
10
|
+
DEFAULT_SOOTHING_LOOP_INTERVAL_MS,
|
|
11
|
+
DEFAULT_SUMMARY_ENABLED,
|
|
12
|
+
DEFAULT_SUMMARY_MAX_CHARS,
|
|
13
|
+
} from './config.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Top-level zod schema for `channels.folotoy.*` config.
|
|
17
|
+
*
|
|
18
|
+
* Used both as runtime input validation (via openclaw plugin SDK's
|
|
19
|
+
* `buildChannelConfigSchema()`) and as the source of the JSON Schema that
|
|
20
|
+
* OpenClaw's web UI renders. OpenClaw 2026.4.x's UI requires a zod-derived
|
|
21
|
+
* schema for form rendering — a raw JSON Schema in the old shape causes
|
|
22
|
+
* "Unsupported type" / "Use Raw mode" fallback.
|
|
23
|
+
*
|
|
24
|
+
* Field set matches the flat config currently consumed by index.ts:
|
|
25
|
+
* `account.<field> ?? DEFAULT_*`.
|
|
26
|
+
*/
|
|
27
|
+
export const FolotoyConfigSchema = z.object({
|
|
28
|
+
flow: z.enum(['direct', 'api']).default('direct').describe('Auth flow'),
|
|
29
|
+
|
|
30
|
+
toy_sn: z.string().optional().describe('Toy serial number'),
|
|
31
|
+
toy_key: z.string().optional().describe('Toy key (used as MQTT password in direct flow)'),
|
|
32
|
+
|
|
33
|
+
api_url: z.string().default(DEFAULT_API_URL).describe('FoloToy API base URL (api flow)'),
|
|
34
|
+
api_key: z.string().optional().describe('FoloToy API key (api flow)'),
|
|
35
|
+
|
|
36
|
+
mqtt_host: z.string().default(DEFAULT_MQTT_HOST).describe('MQTT broker host'),
|
|
37
|
+
mqtt_port: z.number().int().default(DEFAULT_MQTT_PORT).describe('MQTT broker port'),
|
|
38
|
+
|
|
39
|
+
summary_enabled: z
|
|
40
|
+
.boolean()
|
|
41
|
+
.default(DEFAULT_SUMMARY_ENABLED)
|
|
42
|
+
.describe('Enable reply summarization for over-long replies'),
|
|
43
|
+
summary_max_chars: z
|
|
44
|
+
.number()
|
|
45
|
+
.int()
|
|
46
|
+
.default(DEFAULT_SUMMARY_MAX_CHARS)
|
|
47
|
+
.describe('Character threshold that triggers summarization'),
|
|
48
|
+
|
|
49
|
+
sentence_split_enabled: z
|
|
50
|
+
.boolean()
|
|
51
|
+
.default(DEFAULT_SENTENCE_SPLIT_ENABLED)
|
|
52
|
+
.describe('Stream replies sentence-by-sentence for faster TTS'),
|
|
53
|
+
sentence_split_delimiters: z
|
|
54
|
+
.string()
|
|
55
|
+
.default(DEFAULT_SENTENCE_SPLIT_DELIMITERS)
|
|
56
|
+
.describe('Punctuation characters that split sentences'),
|
|
57
|
+
|
|
58
|
+
soothing_loop_enabled: z
|
|
59
|
+
.boolean()
|
|
60
|
+
.default(DEFAULT_SOOTHING_LOOP_ENABLED)
|
|
61
|
+
.describe(
|
|
62
|
+
'Repeat soothing replies while waiting for the LLM. Disable to send only the initial order=1 reply.',
|
|
63
|
+
),
|
|
64
|
+
soothing_loop_interval_ms: z
|
|
65
|
+
.number()
|
|
66
|
+
.int()
|
|
67
|
+
.default(DEFAULT_SOOTHING_LOOP_INTERVAL_MS)
|
|
68
|
+
.describe('Interval (ms) between soothing replies during the LLM wait'),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
export type FolotoyConfig = z.infer<typeof FolotoyConfigSchema>
|
package/src/config.ts
CHANGED
|
@@ -44,7 +44,7 @@ export const DEFAULT_SUMMARY_MAX_CHARS = 200
|
|
|
44
44
|
export const DEFAULT_SENTENCE_SPLIT_ENABLED = true
|
|
45
45
|
export const DEFAULT_SENTENCE_SPLIT_DELIMITERS = '!。?;!.?;~'
|
|
46
46
|
export const DEFAULT_SOOTHING_LOOP_ENABLED = true
|
|
47
|
-
export const DEFAULT_SOOTHING_LOOP_INTERVAL_MS =
|
|
47
|
+
export const DEFAULT_SOOTHING_LOOP_INTERVAL_MS = 8000
|
|
48
48
|
|
|
49
49
|
export function flatToPluginConfig(flat: FlatChannelConfig): PluginConfig {
|
|
50
50
|
const flow = flat.flow ?? 'direct'
|
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,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
|
-
})
|