@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 CHANGED
@@ -64,10 +64,11 @@ Example `openclaw.json`:
64
64
 
65
65
  ## MQTT
66
66
 
67
- Both inbound and outbound messages use the same topic:
67
+ Inbound and outbound use separate topics:
68
68
 
69
69
  ```
70
- /openapi/folotoy/{sn}/thing/data/post
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
- "outParams": {
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": "hello"
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
 
@@ -2,7 +2,7 @@
2
2
  "id": "folotoy-openclaw-plugin",
3
3
  "name": "FoloToy",
4
4
  "description": "Empower your FoloToy with OpenClaw AI capabilities.",
5
- "version": "0.1.0",
5
+ "version": "0.2.0",
6
6
  "channels": ["folotoy"],
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@folotoy/folotoy-openclaw-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Empower your FoloToy with OpenClaw AI capabilities.",
5
5
  "keywords": [
6
6
  "folotoy",
@@ -1,18 +1,18 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
2
  import { EventEmitter } from 'events'
3
- import { buildTopic } from '../mqtt.js'
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
- outParams: { text: string }
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 = buildTopic(toy_sn)
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.outParams?.text !== 'string') return
34
- onMessage(msg.msgId, msg.outParams.text)
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 topic = buildTopic(toy_sn)
43
+ const inboundTopic = buildInboundTopic(toy_sn)
44
44
 
45
- it('calls onMessage with msgId and text on valid chat_input', () => {
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', outParams: { text: 'hello' } }
51
- client.emit('message', topic, Buffer.from(JSON.stringify(msg)))
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', outParams: { text: 'hi' } }
62
- client.emit('message', '/openapi/folotoy/OTHER/thing/data/post', Buffer.from(JSON.stringify(msg)))
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', topic, Buffer.from(JSON.stringify({ msgId: 1, identifier: 'other', outParams: { text: 'hi' } })))
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', topic, Buffer.from('not json'))
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 topic = buildTopic(toy_sn)
90
+ const outboundTopic = buildOutboundTopic(toy_sn)
91
91
 
92
- it('publishes chat_output with correct msgId and content', () => {
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(topic, JSON.stringify(outMsg))
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(topic)
106
- expect(JSON.parse(payload)).toEqual({ msgId: 42, identifier: 'chat_output', outParams: { content: 'world' } })
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 { buildTopic } from '../mqtt.js'
2
+ import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
3
3
 
4
- describe('buildTopic', () => {
5
- it('builds the correct topic for a given SN', () => {
6
- expect(buildTopic('SN001')).toBe('/openapi/folotoy/SN001/thing/data/post')
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, buildTopic } from './mqtt.js'
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
- outParams: { text: string }
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 topic = buildTopic(credentials.toy_sn)
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 ${topic}`)
90
+ log?.info?.(`Connected to MQTT broker, subscribed to ${inboundTopic}`)
90
91
 
91
- client.subscribe(topic, (err) => {
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.outParams?.text !== 'string') return
103
+ if (msg.identifier !== 'chat_input' || typeof msg.inputParams?.text !== 'string') return
103
104
 
104
- const { msgId, outParams: { text } } = msg
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
- // fire-and-forget: OpenClaw handles queuing internally
116
- void channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
117
- ctx: inboundCtx,
118
- cfg,
119
- dispatcherOptions: {
120
- deliver: async (replyPayload) => {
121
- if (!replyPayload.text) return
122
- const outMsg: OutboundMessage = {
123
- msgId,
124
- identifier: 'chat_output',
125
- outParams: { content: replyPayload.text },
126
- }
127
- client.publish(topic, JSON.stringify(outMsg))
128
- },
129
- onError: (err) => log?.error?.(`Dispatch error: ${String(err)}`),
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 topic = buildTopic(entry.toy_sn)
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(topic, JSON.stringify(outMsg))
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 buildTopic(toy_sn: string): string {
48
- return `/openapi/folotoy/${toy_sn}/thing/data/post`
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,