@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 +25 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/channel.test.ts +34 -16
- package/src/config.ts +1 -1
- package/src/index.ts +36 -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/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 ?? '
|
|
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
|
-
|
|
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.
|
|
104
|
+
if (msg.identifier !== 'chat_input' || typeof msg.inputParams?.text !== 'string') return
|
|
104
105
|
|
|
105
|
-
const { msgId,
|
|
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
|
-
//
|
|
117
|
-
void
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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,
|