@folotoy/folotoy-openclaw-plugin 0.1.1 → 0.2.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 +28 -6
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/channel.test.ts +43 -25
- package/src/__tests__/mqtt.test.ts +10 -4
- package/src/index.ts +42 -27
- package/src/mqtt.ts +8 -2
package/README.md
CHANGED
|
@@ -64,10 +64,11 @@ Example `openclaw.json`:
|
|
|
64
64
|
|
|
65
65
|
## MQTT
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
Inbound and outbound use separate topics:
|
|
68
68
|
|
|
69
69
|
```
|
|
70
|
-
/openapi/folotoy/{sn}/thing/
|
|
70
|
+
Inbound (Toy → Plugin): /openapi/folotoy/{sn}/thing/command/call
|
|
71
|
+
Outbound (Plugin → Toy): /openapi/folotoy/{sn}/thing/command/callAck
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
The plugin connects with an `openapi:` prefix on the username to distinguish itself from the toy's own connection:
|
|
@@ -85,25 +86,46 @@ password: {toy_key}
|
|
|
85
86
|
{
|
|
86
87
|
"msgId": 1,
|
|
87
88
|
"identifier": "chat_input",
|
|
88
|
-
"
|
|
89
|
-
"text": "hello"
|
|
89
|
+
"inputParams": {
|
|
90
|
+
"text": "hello",
|
|
91
|
+
"recording_id": 100
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
```
|
|
93
95
|
|
|
94
96
|
**Plugin → Toy (outbound)**
|
|
95
97
|
|
|
98
|
+
Multiple response chunks with auto-incrementing `order`, followed by a finish message:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"msgId": 1,
|
|
103
|
+
"identifier": "chat_output",
|
|
104
|
+
"outParams": {
|
|
105
|
+
"content": "hello",
|
|
106
|
+
"recording_id": 100,
|
|
107
|
+
"order": 1,
|
|
108
|
+
"is_finished": false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Finish message (`is_finished: true`, empty content):
|
|
114
|
+
|
|
96
115
|
```json
|
|
97
116
|
{
|
|
98
117
|
"msgId": 1,
|
|
99
118
|
"identifier": "chat_output",
|
|
100
119
|
"outParams": {
|
|
101
|
-
"content": "
|
|
120
|
+
"content": "",
|
|
121
|
+
"recording_id": 100,
|
|
122
|
+
"order": 2,
|
|
123
|
+
"is_finished": true
|
|
102
124
|
}
|
|
103
125
|
}
|
|
104
126
|
```
|
|
105
127
|
|
|
106
|
-
`msgId` starts at 1 per session and auto-increments.
|
|
128
|
+
`msgId` starts at 1 per session and auto-increments. `recording_id` is passed through from the inbound message.
|
|
107
129
|
|
|
108
130
|
## Environments
|
|
109
131
|
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { EventEmitter } from 'events'
|
|
3
|
-
import {
|
|
3
|
+
import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
|
|
4
4
|
|
|
5
5
|
// Replicate the message parsing logic from index.ts for unit testing
|
|
6
6
|
type InboundMessage = {
|
|
7
7
|
msgId: number
|
|
8
8
|
identifier: 'chat_input'
|
|
9
|
-
|
|
9
|
+
inputParams: { text: string; recording_id: number }
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
type OutboundMessage = {
|
|
13
13
|
msgId: number
|
|
14
14
|
identifier: 'chat_output'
|
|
15
|
-
outParams: { content: string }
|
|
15
|
+
outParams: { content: string; recording_id: number; order: number; is_finished: boolean }
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function makeMockClient() {
|
|
@@ -23,15 +23,15 @@ function makeMockClient() {
|
|
|
23
23
|
})
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function setupSubscriber(client: ReturnType<typeof makeMockClient>, toy_sn: string, onMessage: (msgId: number, text: string) => void) {
|
|
27
|
-
const topic =
|
|
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
28
|
client.subscribe(topic, () => {})
|
|
29
29
|
client.on('message', (_topic: string, payload: Buffer) => {
|
|
30
30
|
if (_topic !== topic) return
|
|
31
31
|
try {
|
|
32
32
|
const msg = JSON.parse(payload.toString()) as InboundMessage
|
|
33
|
-
if (msg.identifier !== 'chat_input' || typeof msg.
|
|
34
|
-
onMessage(msg.msgId, msg.
|
|
33
|
+
if (msg.identifier !== 'chat_input' || typeof msg.inputParams?.text !== 'string') return
|
|
34
|
+
onMessage(msg.msgId, msg.inputParams.text, msg.inputParams.recording_id)
|
|
35
35
|
} catch {
|
|
36
36
|
// ignore
|
|
37
37
|
}
|
|
@@ -40,17 +40,17 @@ function setupSubscriber(client: ReturnType<typeof makeMockClient>, toy_sn: stri
|
|
|
40
40
|
|
|
41
41
|
describe('inbound message parsing', () => {
|
|
42
42
|
const toy_sn = 'SN001'
|
|
43
|
-
const
|
|
43
|
+
const inboundTopic = buildInboundTopic(toy_sn)
|
|
44
44
|
|
|
45
|
-
it('calls onMessage with msgId and
|
|
45
|
+
it('calls onMessage with msgId, text and recording_id on valid chat_input', () => {
|
|
46
46
|
const client = makeMockClient()
|
|
47
47
|
const onMessage = vi.fn()
|
|
48
48
|
setupSubscriber(client, toy_sn, onMessage)
|
|
49
49
|
|
|
50
|
-
const msg: InboundMessage = { msgId: 42, identifier: 'chat_input',
|
|
51
|
-
client.emit('message',
|
|
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
52
|
|
|
53
|
-
expect(onMessage).toHaveBeenCalledWith(42, 'hello')
|
|
53
|
+
expect(onMessage).toHaveBeenCalledWith(42, 'hello', 100)
|
|
54
54
|
})
|
|
55
55
|
|
|
56
56
|
it('ignores messages on other topics', () => {
|
|
@@ -58,8 +58,8 @@ describe('inbound message parsing', () => {
|
|
|
58
58
|
const onMessage = vi.fn()
|
|
59
59
|
setupSubscriber(client, toy_sn, onMessage)
|
|
60
60
|
|
|
61
|
-
const msg: InboundMessage = { msgId: 1, identifier: 'chat_input',
|
|
62
|
-
client.emit('message', '/openapi/folotoy/OTHER/thing/
|
|
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
63
|
|
|
64
64
|
expect(onMessage).not.toHaveBeenCalled()
|
|
65
65
|
})
|
|
@@ -69,7 +69,7 @@ describe('inbound message parsing', () => {
|
|
|
69
69
|
const onMessage = vi.fn()
|
|
70
70
|
setupSubscriber(client, toy_sn, onMessage)
|
|
71
71
|
|
|
72
|
-
client.emit('message',
|
|
72
|
+
client.emit('message', inboundTopic, Buffer.from(JSON.stringify({ msgId: 1, identifier: 'other', inputParams: { text: 'hi', recording_id: 1 } })))
|
|
73
73
|
|
|
74
74
|
expect(onMessage).not.toHaveBeenCalled()
|
|
75
75
|
})
|
|
@@ -79,7 +79,7 @@ describe('inbound message parsing', () => {
|
|
|
79
79
|
const onMessage = vi.fn()
|
|
80
80
|
setupSubscriber(client, toy_sn, onMessage)
|
|
81
81
|
|
|
82
|
-
client.emit('message',
|
|
82
|
+
client.emit('message', inboundTopic, Buffer.from('not json'))
|
|
83
83
|
|
|
84
84
|
expect(onMessage).not.toHaveBeenCalled()
|
|
85
85
|
})
|
|
@@ -87,22 +87,40 @@ describe('inbound message parsing', () => {
|
|
|
87
87
|
|
|
88
88
|
describe('outbound message format', () => {
|
|
89
89
|
const toy_sn = 'SN001'
|
|
90
|
-
const
|
|
90
|
+
const outboundTopic = buildOutboundTopic(toy_sn)
|
|
91
91
|
|
|
92
|
-
it('publishes chat_output with
|
|
92
|
+
it('publishes chat_output with recording_id, order and is_finished', () => {
|
|
93
93
|
const client = makeMockClient()
|
|
94
|
-
const msgId = 42
|
|
95
|
-
const content = 'world'
|
|
96
94
|
const outMsg: OutboundMessage = {
|
|
97
|
-
msgId,
|
|
95
|
+
msgId: 42,
|
|
98
96
|
identifier: 'chat_output',
|
|
99
|
-
outParams: { content },
|
|
97
|
+
outParams: { content: 'world', recording_id: 100, order: 1, is_finished: false },
|
|
100
98
|
}
|
|
101
|
-
client.publish(
|
|
99
|
+
client.publish(outboundTopic, JSON.stringify(outMsg))
|
|
102
100
|
|
|
103
101
|
expect(client.publish).toHaveBeenCalledOnce()
|
|
104
102
|
const [t, payload] = client.publish.mock.calls[0] as [string, string]
|
|
105
|
-
expect(t).toBe(
|
|
106
|
-
expect(JSON.parse(payload)).toEqual({
|
|
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('')
|
|
107
125
|
})
|
|
108
126
|
})
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
|
|
3
3
|
|
|
4
|
-
describe('
|
|
5
|
-
it('builds the correct topic for a given SN', () => {
|
|
6
|
-
expect(
|
|
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')
|
|
7
13
|
})
|
|
8
14
|
})
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OpenClawPluginApi, ChannelPlugin } from 'openclaw/plugin-sdk/core'
|
|
2
|
-
import { resolveCredentials, createMqttClient,
|
|
2
|
+
import { resolveCredentials, createMqttClient, buildInboundTopic, buildOutboundTopic } from './mqtt.js'
|
|
3
3
|
import { DEFAULT_MQTT_HOST, DEFAULT_MQTT_PORT, flatToPluginConfig } from './config.js'
|
|
4
4
|
import type { FlatChannelConfig } from './config.js'
|
|
5
5
|
import type { MqttClient } from 'mqtt'
|
|
@@ -7,13 +7,13 @@ import type { MqttClient } from 'mqtt'
|
|
|
7
7
|
type InboundMessage = {
|
|
8
8
|
msgId: number
|
|
9
9
|
identifier: 'chat_input'
|
|
10
|
-
|
|
10
|
+
inputParams: { text: string; recording_id: number }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
type OutboundMessage = {
|
|
14
14
|
msgId: number
|
|
15
15
|
identifier: 'chat_output'
|
|
16
|
-
outParams: { content: string }
|
|
16
|
+
outParams: { content: string; recording_id: number; order: number; is_finished: boolean }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Per-account MQTT clients and msgId counters
|
|
@@ -83,12 +83,13 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
|
|
|
83
83
|
const mqttConfig = flatToPluginConfig(account)
|
|
84
84
|
const credentials = await resolveCredentials(mqttConfig)
|
|
85
85
|
const client = await createMqttClient(mqttConfig, credentials)
|
|
86
|
-
const
|
|
86
|
+
const inboundTopic = buildInboundTopic(credentials.toy_sn)
|
|
87
|
+
const outboundTopic = buildOutboundTopic(credentials.toy_sn)
|
|
87
88
|
|
|
88
89
|
activeClients.set(accountId, { client, toy_sn: credentials.toy_sn, nextMsgId: 1 })
|
|
89
|
-
log?.info?.(`Connected to MQTT broker, subscribed to ${
|
|
90
|
+
log?.info?.(`Connected to MQTT broker, subscribed to ${inboundTopic}`)
|
|
90
91
|
|
|
91
|
-
client.subscribe(
|
|
92
|
+
client.subscribe(inboundTopic, (err) => {
|
|
92
93
|
if (err) log?.error?.(`Failed to subscribe: ${err.message}`)
|
|
93
94
|
})
|
|
94
95
|
|
|
@@ -99,9 +100,10 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
|
|
|
99
100
|
} catch {
|
|
100
101
|
return
|
|
101
102
|
}
|
|
102
|
-
if (msg.identifier !== 'chat_input' || typeof msg.
|
|
103
|
+
if (msg.identifier !== 'chat_input' || typeof msg.inputParams?.text !== 'string') return
|
|
103
104
|
|
|
104
|
-
const { msgId,
|
|
105
|
+
const { msgId, inputParams: { text, recording_id } } = msg
|
|
106
|
+
let order = 0
|
|
105
107
|
|
|
106
108
|
const inboundCtx = channelRuntime.reply.finalizeInboundContext({
|
|
107
109
|
Body: text,
|
|
@@ -112,23 +114,36 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
|
|
|
112
114
|
Provider: 'folotoy',
|
|
113
115
|
})
|
|
114
116
|
|
|
115
|
-
//
|
|
116
|
-
void
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
117
|
+
// dispatch and send finish message when done
|
|
118
|
+
void (async () => {
|
|
119
|
+
try {
|
|
120
|
+
await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
121
|
+
ctx: inboundCtx,
|
|
122
|
+
cfg,
|
|
123
|
+
dispatcherOptions: {
|
|
124
|
+
deliver: async (replyPayload) => {
|
|
125
|
+
if (!replyPayload.text) return
|
|
126
|
+
order++
|
|
127
|
+
const outMsg: OutboundMessage = {
|
|
128
|
+
msgId,
|
|
129
|
+
identifier: 'chat_output',
|
|
130
|
+
outParams: { content: replyPayload.text, recording_id, order, is_finished: false },
|
|
131
|
+
}
|
|
132
|
+
client.publish(outboundTopic, JSON.stringify(outMsg))
|
|
133
|
+
},
|
|
134
|
+
onError: (err) => log?.error?.(`Dispatch error: ${String(err)}`),
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
} finally {
|
|
138
|
+
order++
|
|
139
|
+
const finishMsg: OutboundMessage = {
|
|
140
|
+
msgId,
|
|
141
|
+
identifier: 'chat_output',
|
|
142
|
+
outParams: { content: '', recording_id, order, is_finished: true },
|
|
143
|
+
}
|
|
144
|
+
client.publish(outboundTopic, JSON.stringify(finishMsg))
|
|
145
|
+
}
|
|
146
|
+
})()
|
|
132
147
|
})
|
|
133
148
|
|
|
134
149
|
// Keep the account alive until aborted
|
|
@@ -154,14 +169,14 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
|
|
|
154
169
|
const entry = activeClients.get(key)
|
|
155
170
|
if (!entry) throw new Error(`No active MQTT client for account "${key}"`)
|
|
156
171
|
|
|
157
|
-
const
|
|
172
|
+
const outboundTopic = buildOutboundTopic(entry.toy_sn)
|
|
158
173
|
const msgId = entry.nextMsgId++
|
|
159
174
|
const outMsg: OutboundMessage = {
|
|
160
175
|
msgId,
|
|
161
176
|
identifier: 'chat_output',
|
|
162
177
|
outParams: { content: text },
|
|
163
178
|
}
|
|
164
|
-
entry.client.publish(
|
|
179
|
+
entry.client.publish(outboundTopic, JSON.stringify(outMsg))
|
|
165
180
|
return { channel: 'folotoy', messageId: String(msgId) }
|
|
166
181
|
},
|
|
167
182
|
},
|
package/src/mqtt.ts
CHANGED
|
@@ -44,8 +44,12 @@ export async function resolveCredentials(config: PluginConfig): Promise<MqttCred
|
|
|
44
44
|
return directCredentials(config.auth)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function
|
|
48
|
-
return `/openapi/folotoy/${toy_sn}/thing/
|
|
47
|
+
export function buildInboundTopic(toy_sn: string): string {
|
|
48
|
+
return `/openapi/folotoy/${toy_sn}/thing/command/call`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildOutboundTopic(toy_sn: string): string {
|
|
52
|
+
return `/openapi/folotoy/${toy_sn}/thing/command/callAck`
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
export async function createMqttClient(config: PluginConfig, credentials: MqttCredentials): Promise<MqttClient> {
|
|
@@ -53,7 +57,9 @@ export async function createMqttClient(config: PluginConfig, credentials: MqttCr
|
|
|
53
57
|
const { username, password } = credentials
|
|
54
58
|
|
|
55
59
|
return new Promise((resolve, reject) => {
|
|
60
|
+
const clientId = `openapi:${username}`
|
|
56
61
|
const client = mqtt.connect(`mqtt://${host}:${port}`, {
|
|
62
|
+
clientId,
|
|
57
63
|
username: `openapi:${username}`,
|
|
58
64
|
password,
|
|
59
65
|
clean: true,
|