@folotoy/folotoy-openclaw-plugin 0.1.2 → 0.2.1

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.1",
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.1",
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/config.ts CHANGED
@@ -31,7 +31,7 @@ export type FlatChannelConfig = {
31
31
  }
32
32
 
33
33
  export const DEFAULT_API_URL = 'https://api.folotoy.cn'
34
- export const DEFAULT_MQTT_HOST = process.env.FOLOTOY_MQTT_HOST ?? '198.19.249.25'
34
+ export const DEFAULT_MQTT_HOST = process.env.FOLOTOY_MQTT_HOST ?? 'f.qrc92.cn'
35
35
  export const DEFAULT_MQTT_PORT = 1883
36
36
 
37
37
  export function flatToPluginConfig(flat: FlatChannelConfig): PluginConfig {
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
@@ -81,6 +81,7 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
81
81
  }
82
82
 
83
83
  const mqttConfig = flatToPluginConfig(account)
84
+ log?.info?.(`Connecting to MQTT broker ${mqttConfig.mqtt.host}:${mqttConfig.mqtt.port}...`)
84
85
  const credentials = await resolveCredentials(mqttConfig)
85
86
  const client = await createMqttClient(mqttConfig, credentials)
86
87
  const inboundTopic = buildInboundTopic(credentials.toy_sn)
@@ -100,9 +101,10 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
100
101
  } catch {
101
102
  return
102
103
  }
103
- if (msg.identifier !== 'chat_input' || typeof msg.outParams?.text !== 'string') return
104
+ if (msg.identifier !== 'chat_input' || typeof msg.inputParams?.text !== 'string') return
104
105
 
105
- const { msgId, outParams: { text } } = msg
106
+ const { msgId, inputParams: { text, recording_id } } = msg
107
+ let order = 0
106
108
 
107
109
  const inboundCtx = channelRuntime.reply.finalizeInboundContext({
108
110
  Body: text,
@@ -113,23 +115,36 @@ const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
113
115
  Provider: 'folotoy',
114
116
  })
115
117
 
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
- })
118
+ // dispatch and send finish message when done
119
+ void (async () => {
120
+ try {
121
+ await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
122
+ ctx: inboundCtx,
123
+ cfg,
124
+ dispatcherOptions: {
125
+ deliver: async (replyPayload) => {
126
+ if (!replyPayload.text) return
127
+ order++
128
+ const outMsg: OutboundMessage = {
129
+ msgId,
130
+ identifier: 'chat_output',
131
+ outParams: { content: replyPayload.text, recording_id, order, is_finished: false },
132
+ }
133
+ client.publish(outboundTopic, JSON.stringify(outMsg))
134
+ },
135
+ onError: (err) => log?.error?.(`Dispatch error: ${String(err)}`),
136
+ },
137
+ })
138
+ } finally {
139
+ order++
140
+ const finishMsg: OutboundMessage = {
141
+ msgId,
142
+ identifier: 'chat_output',
143
+ outParams: { content: '', recording_id, order, is_finished: true },
144
+ }
145
+ client.publish(outboundTopic, JSON.stringify(finishMsg))
146
+ }
147
+ })()
133
148
  })
134
149
 
135
150
  // 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,