@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 +25 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/channel.test.ts +34 -16
- package/src/index.ts +35 -21
- package/src/mqtt.ts +2 -0
package/README.md
CHANGED
|
@@ -86,25 +86,46 @@ password: {toy_key}
|
|
|
86
86
|
{
|
|
87
87
|
"msgId": 1,
|
|
88
88
|
"identifier": "chat_input",
|
|
89
|
-
"
|
|
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": "
|
|
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
|
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -6,13 +6,13 @@ import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
|
|
|
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) {
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
|
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({
|
|
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
|
-
|
|
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.
|
|
103
|
+
if (msg.identifier !== 'chat_input' || typeof msg.inputParams?.text !== 'string') return
|
|
104
104
|
|
|
105
|
-
const { msgId,
|
|
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
|
-
//
|
|
117
|
-
void
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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,
|