@folotoy/folotoy-openclaw-plugin 0.1.2 → 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
@@ -86,25 +86,46 @@ password: {toy_key}
86
86
  {
87
87
  "msgId": 1,
88
88
  "identifier": "chat_input",
89
- "outParams": {
90
- "text": "hello"
89
+ "inputParams": {
90
+ "text": "hello",
91
+ "recording_id": 100
91
92
  }
92
93
  }
93
94
  ```
94
95
 
95
96
  **Plugin → Toy (outbound)**
96
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
+
97
115
  ```json
98
116
  {
99
117
  "msgId": 1,
100
118
  "identifier": "chat_output",
101
119
  "outParams": {
102
- "content": "hello"
120
+ "content": "",
121
+ "recording_id": 100,
122
+ "order": 2,
123
+ "is_finished": true
103
124
  }
104
125
  }
105
126
  ```
106
127
 
107
- `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.
108
129
 
109
130
  ## Environments
110
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.2",
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.2",
3
+ "version": "0.2.0",
4
4
  "description": "Empower your FoloToy with OpenClaw AI capabilities.",
5
5
  "keywords": [
6
6
  "folotoy",
@@ -6,13 +6,13 @@ import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
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) {
26
+ function setupSubscriber(client: ReturnType<typeof makeMockClient>, toy_sn: string, onMessage: (msgId: number, text: string, recording_id: number) => void) {
27
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
  }
@@ -42,15 +42,15 @@ describe('inbound message parsing', () => {
42
42
  const toy_sn = 'SN001'
43
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' } }
50
+ const msg: InboundMessage = { msgId: 42, identifier: 'chat_input', inputParams: { text: 'hello', recording_id: 100 } }
51
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,7 +58,7 @@ 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' } }
61
+ const msg: InboundMessage = { msgId: 1, identifier: 'chat_input', inputParams: { text: 'hi', recording_id: 1 } }
62
62
  client.emit('message', '/openapi/folotoy/OTHER/thing/command/call', Buffer.from(JSON.stringify(msg)))
63
63
 
64
64
  expect(onMessage).not.toHaveBeenCalled()
@@ -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', inboundTopic, 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
  })
@@ -89,20 +89,38 @@ describe('outbound message format', () => {
89
89
  const toy_sn = 'SN001'
90
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
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
103
  expect(t).toBe(outboundTopic)
106
- expect(JSON.parse(payload)).toEqual({ msgId: 42, identifier: 'chat_output', outParams: { content: 'world' } })
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
  })
package/src/index.ts CHANGED
@@ -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
@@ -100,9 +100,10 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
100
100
  } catch {
101
101
  return
102
102
  }
103
- if (msg.identifier !== 'chat_input' || typeof msg.outParams?.text !== 'string') return
103
+ if (msg.identifier !== 'chat_input' || typeof msg.inputParams?.text !== 'string') return
104
104
 
105
- const { msgId, outParams: { text } } = msg
105
+ const { msgId, inputParams: { text, recording_id } } = msg
106
+ let order = 0
106
107
 
107
108
  const inboundCtx = channelRuntime.reply.finalizeInboundContext({
108
109
  Body: text,
@@ -113,23 +114,36 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
113
114
  Provider: 'folotoy',
114
115
  })
115
116
 
116
- // fire-and-forget: OpenClaw handles queuing internally
117
- void channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
118
- ctx: inboundCtx,
119
- cfg,
120
- dispatcherOptions: {
121
- deliver: async (replyPayload) => {
122
- if (!replyPayload.text) return
123
- const outMsg: OutboundMessage = {
124
- msgId,
125
- identifier: 'chat_output',
126
- outParams: { content: replyPayload.text },
127
- }
128
- client.publish(outboundTopic, JSON.stringify(outMsg))
129
- },
130
- onError: (err) => log?.error?.(`Dispatch error: ${String(err)}`),
131
- },
132
- })
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
+ })()
133
147
  })
134
148
 
135
149
  // Keep the account alive until aborted
package/src/mqtt.ts CHANGED
@@ -57,7 +57,9 @@ export async function createMqttClient(config: PluginConfig, credentials: MqttCr
57
57
  const { username, password } = credentials
58
58
 
59
59
  return new Promise((resolve, reject) => {
60
+ const clientId = `openapi:${username}`
60
61
  const client = mqtt.connect(`mqtt://${host}:${port}`, {
62
+ clientId,
61
63
  username: `openapi:${username}`,
62
64
  password,
63
65
  clean: true,