@folotoy/folotoy-openclaw-plugin 0.1.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 +109 -0
- package/openclaw.plugin.json +5 -0
- package/package.json +61 -0
- package/src/__tests__/channel.test.ts +108 -0
- package/src/__tests__/mqtt.test.ts +8 -0
- package/src/config.ts +23 -0
- package/src/index.ts +197 -0
- package/src/mqtt.ts +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# @folotoy/folotoy-openclaw-plugin
|
|
2
|
+
|
|
3
|
+
FoloToy channel plugin for [OpenClaw](https://openclaw.ai). Bridges FoloToy smart toys with OpenClaw via MQTT, allowing users to interact with OpenClaw through their FoloToy devices.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
FoloToy 玩具 <──MQTT──> FoloToy MQTT Broker <──MQTT──> This Plugin <──> OpenClaw
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
openclaw plugins install @folotoy/folotoy-openclaw-plugin
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install locally for development:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
openclaw plugins install -l .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
The plugin supports two authentication flows.
|
|
24
|
+
|
|
25
|
+
### Flow 2: Direct SN + Key (Default)
|
|
26
|
+
|
|
27
|
+
Configure your toy SN and key directly:
|
|
28
|
+
|
|
29
|
+
| Field | Description |
|
|
30
|
+
|-------|-------------|
|
|
31
|
+
| `auth.flow` | `"direct"` |
|
|
32
|
+
| `auth.toy_sn` | Toy serial number |
|
|
33
|
+
| `auth.toy_key` | Toy key (used as MQTT password) |
|
|
34
|
+
| `mqtt.host` | MQTT broker host (default: `192.168.10.138`) |
|
|
35
|
+
| `mqtt.port` | MQTT broker port (default: `1883`) |
|
|
36
|
+
|
|
37
|
+
### Flow 1: HTTP API Login
|
|
38
|
+
|
|
39
|
+
Exchange an API key for MQTT credentials via the FoloToy API:
|
|
40
|
+
|
|
41
|
+
| Field | Description |
|
|
42
|
+
|-------|-------------|
|
|
43
|
+
| `auth.flow` | `"api"` |
|
|
44
|
+
| `auth.api_url` | FoloToy API base URL, e.g. `https://api.folotoy.com` |
|
|
45
|
+
| `auth.api_key` | Bearer token |
|
|
46
|
+
| `auth.toy_sn` | Toy serial number |
|
|
47
|
+
| `mqtt.host` | MQTT broker host |
|
|
48
|
+
| `mqtt.port` | MQTT broker port (default: `1883`) |
|
|
49
|
+
|
|
50
|
+
## MQTT
|
|
51
|
+
|
|
52
|
+
Both inbound and outbound messages use the same topic:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
/openapi/folotoy/{sn}/thing/data/post
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The plugin connects to the MQTT broker with an `openapi:` prefix on the username to distinguish itself from the toy's own connection:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
username: openapi:{toy_sn}
|
|
62
|
+
password: {toy_key}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Message Format
|
|
66
|
+
|
|
67
|
+
**Toy → Plugin (inbound)**
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"msgId": 1,
|
|
72
|
+
"identifier": "chat_input",
|
|
73
|
+
"outParams": {
|
|
74
|
+
"text": "hello"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Plugin → Toy (outbound)**
|
|
80
|
+
|
|
81
|
+
Single response message with `msgId` matching the inbound request:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"msgId": 1,
|
|
86
|
+
"identifier": "chat_output",
|
|
87
|
+
"outParams": {
|
|
88
|
+
"content": "hello"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Environments
|
|
94
|
+
|
|
95
|
+
| Environment | MQTT Host | Port |
|
|
96
|
+
|-------------|-----------|------|
|
|
97
|
+
| Development | `192.168.10.138` | 1883 |
|
|
98
|
+
| Testing | `f.qrc92.cn` | 1883 |
|
|
99
|
+
| Production | `f.folotoy.cn` | 1883 |
|
|
100
|
+
|
|
101
|
+
Switch environments via the `FOLOTOY_MQTT_HOST` environment variable.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm install
|
|
107
|
+
npm run build
|
|
108
|
+
npm test
|
|
109
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@folotoy/folotoy-openclaw-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw channel plugin for FoloToy smart toys via MQTT",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"folotoy",
|
|
7
|
+
"openclaw"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://github.com/FoloToy/folotoy-openclaw-plugin#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/FoloToy/folotoy-openclaw-plugin/issues"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/FoloToy/folotoy-openclaw-plugin.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Larry Wang",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"main": "./src/index.ts",
|
|
24
|
+
"types": "./src/index.ts",
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"openclaw.plugin.json"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc",
|
|
31
|
+
"dev": "tsc --watch",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest",
|
|
35
|
+
"clean": "rm -rf dist"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"mqtt": "^5.10.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.13.10",
|
|
42
|
+
"openclaw": "^2026.3.8",
|
|
43
|
+
"typescript": "^5.7.3",
|
|
44
|
+
"vitest": "^3.0.7"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20.0.0"
|
|
48
|
+
},
|
|
49
|
+
"openclaw": {
|
|
50
|
+
"extensions": [
|
|
51
|
+
"./src/index.ts"
|
|
52
|
+
],
|
|
53
|
+
"channel": {
|
|
54
|
+
"id": "folotoy",
|
|
55
|
+
"label": "FoloToy",
|
|
56
|
+
"selectionLabel": "FoloToy",
|
|
57
|
+
"docsPath": "/channels/folotoy",
|
|
58
|
+
"blurb": "Connect FoloToy smart toys via MQTT."
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
import { buildTopic } from '../mqtt.js'
|
|
4
|
+
|
|
5
|
+
// Replicate the message parsing logic from index.ts for unit testing
|
|
6
|
+
type InboundMessage = {
|
|
7
|
+
msgId: number
|
|
8
|
+
identifier: 'chat_input'
|
|
9
|
+
outParams: { text: string }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type OutboundMessage = {
|
|
13
|
+
msgId: number
|
|
14
|
+
identifier: 'chat_output'
|
|
15
|
+
outParams: { content: string }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeMockClient() {
|
|
19
|
+
const emitter = new EventEmitter()
|
|
20
|
+
return Object.assign(emitter, {
|
|
21
|
+
subscribe: vi.fn((_topic: string, cb: (err: null) => void) => cb(null)),
|
|
22
|
+
publish: vi.fn(),
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function setupSubscriber(client: ReturnType<typeof makeMockClient>, toy_sn: string, onMessage: (msgId: number, text: string) => void) {
|
|
27
|
+
const topic = buildTopic(toy_sn)
|
|
28
|
+
client.subscribe(topic, () => {})
|
|
29
|
+
client.on('message', (_topic: string, payload: Buffer) => {
|
|
30
|
+
if (_topic !== topic) return
|
|
31
|
+
try {
|
|
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)
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('inbound message parsing', () => {
|
|
42
|
+
const toy_sn = 'SN001'
|
|
43
|
+
const topic = buildTopic(toy_sn)
|
|
44
|
+
|
|
45
|
+
it('calls onMessage with msgId and text on valid chat_input', () => {
|
|
46
|
+
const client = makeMockClient()
|
|
47
|
+
const onMessage = vi.fn()
|
|
48
|
+
setupSubscriber(client, toy_sn, onMessage)
|
|
49
|
+
|
|
50
|
+
const msg: InboundMessage = { msgId: 42, identifier: 'chat_input', outParams: { text: 'hello' } }
|
|
51
|
+
client.emit('message', topic, Buffer.from(JSON.stringify(msg)))
|
|
52
|
+
|
|
53
|
+
expect(onMessage).toHaveBeenCalledWith(42, 'hello')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('ignores messages on other topics', () => {
|
|
57
|
+
const client = makeMockClient()
|
|
58
|
+
const onMessage = vi.fn()
|
|
59
|
+
setupSubscriber(client, toy_sn, onMessage)
|
|
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)))
|
|
63
|
+
|
|
64
|
+
expect(onMessage).not.toHaveBeenCalled()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('ignores messages with unknown identifier', () => {
|
|
68
|
+
const client = makeMockClient()
|
|
69
|
+
const onMessage = vi.fn()
|
|
70
|
+
setupSubscriber(client, toy_sn, onMessage)
|
|
71
|
+
|
|
72
|
+
client.emit('message', topic, Buffer.from(JSON.stringify({ msgId: 1, identifier: 'other', outParams: { text: 'hi' } })))
|
|
73
|
+
|
|
74
|
+
expect(onMessage).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('ignores malformed JSON', () => {
|
|
78
|
+
const client = makeMockClient()
|
|
79
|
+
const onMessage = vi.fn()
|
|
80
|
+
setupSubscriber(client, toy_sn, onMessage)
|
|
81
|
+
|
|
82
|
+
client.emit('message', topic, Buffer.from('not json'))
|
|
83
|
+
|
|
84
|
+
expect(onMessage).not.toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('outbound message format', () => {
|
|
89
|
+
const toy_sn = 'SN001'
|
|
90
|
+
const topic = buildTopic(toy_sn)
|
|
91
|
+
|
|
92
|
+
it('publishes chat_output with correct msgId and content', () => {
|
|
93
|
+
const client = makeMockClient()
|
|
94
|
+
const msgId = 42
|
|
95
|
+
const content = 'world'
|
|
96
|
+
const outMsg: OutboundMessage = {
|
|
97
|
+
msgId,
|
|
98
|
+
identifier: 'chat_output',
|
|
99
|
+
outParams: { content },
|
|
100
|
+
}
|
|
101
|
+
client.publish(topic, JSON.stringify(outMsg))
|
|
102
|
+
|
|
103
|
+
expect(client.publish).toHaveBeenCalledOnce()
|
|
104
|
+
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' } })
|
|
107
|
+
})
|
|
108
|
+
})
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type AuthFlow1Config = {
|
|
2
|
+
flow: 'api'
|
|
3
|
+
api_url: string
|
|
4
|
+
api_key: string
|
|
5
|
+
toy_sn: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type AuthFlow2Config = {
|
|
9
|
+
flow: 'direct'
|
|
10
|
+
toy_sn: string
|
|
11
|
+
toy_key: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PluginConfig = {
|
|
15
|
+
auth: AuthFlow1Config | AuthFlow2Config
|
|
16
|
+
mqtt: {
|
|
17
|
+
host: string
|
|
18
|
+
port: number
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_MQTT_HOST = process.env.FOLOTOY_MQTT_HOST ?? '198.19.249.25'
|
|
23
|
+
export const DEFAULT_MQTT_PORT = 1883
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { OpenClawPluginApi, ChannelPlugin } from 'openclaw/plugin-sdk/core'
|
|
2
|
+
import { resolveCredentials, createMqttClient, buildTopic } from './mqtt.js'
|
|
3
|
+
import { DEFAULT_MQTT_HOST, DEFAULT_MQTT_PORT } from './config.js'
|
|
4
|
+
import type { PluginConfig } from './config.js'
|
|
5
|
+
import type { MqttClient } from 'mqtt'
|
|
6
|
+
|
|
7
|
+
type FoloToyAccount = PluginConfig
|
|
8
|
+
|
|
9
|
+
type InboundMessage = {
|
|
10
|
+
msgId: number
|
|
11
|
+
identifier: 'chat_input'
|
|
12
|
+
outParams: { text: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type OutboundMessage = {
|
|
16
|
+
msgId: number
|
|
17
|
+
identifier: 'chat_output'
|
|
18
|
+
outParams: { content: string }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Per-account MQTT clients, kept for proactive outbound sends
|
|
22
|
+
const activeClients = new Map<string, { client: MqttClient; toy_sn: string }>()
|
|
23
|
+
|
|
24
|
+
const folotoyChannel: ChannelPlugin<FoloToyAccount> = {
|
|
25
|
+
id: 'folotoy',
|
|
26
|
+
meta: {
|
|
27
|
+
id: 'folotoy',
|
|
28
|
+
label: 'FoloToy',
|
|
29
|
+
selectionLabel: 'FoloToy',
|
|
30
|
+
docsPath: '/channels/folotoy',
|
|
31
|
+
blurb: 'Connect FoloToy smart toys via MQTT.',
|
|
32
|
+
},
|
|
33
|
+
capabilities: {
|
|
34
|
+
chatTypes: ['direct'],
|
|
35
|
+
},
|
|
36
|
+
configSchema: {
|
|
37
|
+
schema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
auth: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
oneOf: [
|
|
43
|
+
{
|
|
44
|
+
title: 'Flow 2: SN + Key (Default)',
|
|
45
|
+
properties: {
|
|
46
|
+
flow: { type: 'string', const: 'direct' },
|
|
47
|
+
toy_sn: { type: 'string' },
|
|
48
|
+
toy_key: { type: 'string' },
|
|
49
|
+
},
|
|
50
|
+
required: ['flow', 'toy_sn', 'toy_key'],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
title: 'Flow 1: HTTP API Login',
|
|
54
|
+
properties: {
|
|
55
|
+
flow: { type: 'string', const: 'api' },
|
|
56
|
+
api_url: { type: 'string' },
|
|
57
|
+
api_key: { type: 'string' },
|
|
58
|
+
toy_sn: { type: 'string' },
|
|
59
|
+
},
|
|
60
|
+
required: ['flow', 'api_url', 'api_key', 'toy_sn'],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
default: { flow: 'direct' },
|
|
64
|
+
},
|
|
65
|
+
mqtt: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
host: { type: 'string', default: DEFAULT_MQTT_HOST },
|
|
69
|
+
port: { type: 'number', default: DEFAULT_MQTT_PORT },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ['auth'],
|
|
74
|
+
},
|
|
75
|
+
uiHints: {
|
|
76
|
+
'auth.toy_sn': { label: 'Toy SN' },
|
|
77
|
+
'auth.toy_key': { label: 'Toy Key', sensitive: true },
|
|
78
|
+
'auth.api_url': { label: 'API URL', placeholder: 'https://api.folotoy.com' },
|
|
79
|
+
'auth.api_key': { label: 'API Key', sensitive: true },
|
|
80
|
+
'mqtt.host': { label: 'MQTT Host', placeholder: DEFAULT_MQTT_HOST },
|
|
81
|
+
'mqtt.port': { label: 'MQTT Port' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
config: {
|
|
85
|
+
listAccountIds: (cfg) => {
|
|
86
|
+
const accounts = (cfg as Record<string, unknown> & { channels?: { folotoy?: { accounts?: Record<string, unknown> } } })
|
|
87
|
+
.channels?.folotoy?.accounts ?? {}
|
|
88
|
+
return Object.keys(accounts)
|
|
89
|
+
},
|
|
90
|
+
resolveAccount: (cfg, accountId) => {
|
|
91
|
+
const accounts = (cfg as Record<string, unknown> & { channels?: { folotoy?: { accounts?: Record<string, FoloToyAccount> } } })
|
|
92
|
+
.channels?.folotoy?.accounts ?? {}
|
|
93
|
+
return accounts[accountId ?? 'default'] ?? ({} as FoloToyAccount)
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
gateway: {
|
|
97
|
+
startAccount: async (ctx) => {
|
|
98
|
+
const { account, cfg, accountId, abortSignal, channelRuntime, log } = ctx
|
|
99
|
+
|
|
100
|
+
if (!channelRuntime) {
|
|
101
|
+
log?.warn?.('channelRuntime not available — skipping MQTT connection')
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const mqttConfig: PluginConfig = {
|
|
106
|
+
auth: account.auth,
|
|
107
|
+
mqtt: {
|
|
108
|
+
host: account.mqtt?.host ?? DEFAULT_MQTT_HOST,
|
|
109
|
+
port: account.mqtt?.port ?? DEFAULT_MQTT_PORT,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const credentials = await resolveCredentials(mqttConfig)
|
|
114
|
+
const client = await createMqttClient(mqttConfig, credentials)
|
|
115
|
+
const topic = buildTopic(credentials.toy_sn)
|
|
116
|
+
|
|
117
|
+
activeClients.set(accountId, { client, toy_sn: credentials.toy_sn })
|
|
118
|
+
log?.info?.(`Connected to MQTT broker, subscribed to ${topic}`)
|
|
119
|
+
|
|
120
|
+
client.subscribe(topic, (err) => {
|
|
121
|
+
if (err) log?.error?.(`Failed to subscribe: ${err.message}`)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
client.on('message', (_topic, payload) => {
|
|
125
|
+
let msg: InboundMessage
|
|
126
|
+
try {
|
|
127
|
+
msg = JSON.parse(payload.toString()) as InboundMessage
|
|
128
|
+
} catch {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (msg.identifier !== 'chat_input' || typeof msg.outParams?.text !== 'string') return
|
|
132
|
+
|
|
133
|
+
const { msgId, outParams: { text } } = msg
|
|
134
|
+
|
|
135
|
+
const inboundCtx = channelRuntime.reply.finalizeInboundContext({
|
|
136
|
+
Body: text,
|
|
137
|
+
From: credentials.toy_sn,
|
|
138
|
+
To: credentials.toy_sn,
|
|
139
|
+
SessionKey: `folotoy-${accountId}-${credentials.toy_sn}`,
|
|
140
|
+
AccountId: accountId,
|
|
141
|
+
Provider: 'folotoy',
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// fire-and-forget: OpenClaw handles queuing internally
|
|
145
|
+
void channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
146
|
+
ctx: inboundCtx,
|
|
147
|
+
cfg,
|
|
148
|
+
dispatcherOptions: {
|
|
149
|
+
deliver: async (replyPayload) => {
|
|
150
|
+
if (!replyPayload.text) return
|
|
151
|
+
const outMsg: OutboundMessage = {
|
|
152
|
+
msgId,
|
|
153
|
+
identifier: 'chat_output',
|
|
154
|
+
outParams: { content: replyPayload.text },
|
|
155
|
+
}
|
|
156
|
+
client.publish(topic, JSON.stringify(outMsg))
|
|
157
|
+
},
|
|
158
|
+
onError: (err) => log?.error?.(`Dispatch error: ${String(err)}`),
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
abortSignal.addEventListener('abort', () => {
|
|
164
|
+
activeClients.delete(accountId)
|
|
165
|
+
client.end()
|
|
166
|
+
log?.info?.('MQTT client disconnected')
|
|
167
|
+
})
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
stopAccount: async (_ctx) => {
|
|
171
|
+
// cleanup handled by abortSignal listener in startAccount
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
outbound: {
|
|
176
|
+
deliveryMode: 'direct',
|
|
177
|
+
sendText: async ({ text, accountId }) => {
|
|
178
|
+
const key = accountId ?? 'default'
|
|
179
|
+
const entry = activeClients.get(key)
|
|
180
|
+
if (!entry) throw new Error(`No active MQTT client for account "${key}"`)
|
|
181
|
+
|
|
182
|
+
const topic = buildTopic(entry.toy_sn)
|
|
183
|
+
const msgId = Date.now()
|
|
184
|
+
const outMsg: OutboundMessage = {
|
|
185
|
+
msgId,
|
|
186
|
+
identifier: 'chat_output',
|
|
187
|
+
outParams: { content: text },
|
|
188
|
+
}
|
|
189
|
+
entry.client.publish(topic, JSON.stringify(outMsg))
|
|
190
|
+
return { channel: 'folotoy', messageId: String(msgId) }
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default (api: OpenClawPluginApi) => {
|
|
196
|
+
api.registerChannel({ plugin: folotoyChannel })
|
|
197
|
+
}
|
package/src/mqtt.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import mqtt, { MqttClient } from 'mqtt'
|
|
2
|
+
import { AuthFlow1Config, AuthFlow2Config, PluginConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
export type MqttCredentials = {
|
|
5
|
+
username: string
|
|
6
|
+
password: string
|
|
7
|
+
toy_sn: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function fetchCredentials(auth: AuthFlow1Config): Promise<MqttCredentials> {
|
|
11
|
+
const res = await fetch(`${auth.api_url}/v1/openapi/create_mqtt_token`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
'Authorization': `Bearer ${auth.api_key}`,
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify({ toy_sn: auth.toy_sn }),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(`Failed to fetch MQTT token: ${res.status} ${res.statusText}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const data = await res.json() as { username: string; password: string }
|
|
25
|
+
return {
|
|
26
|
+
username: data.username,
|
|
27
|
+
password: data.password,
|
|
28
|
+
toy_sn: auth.toy_sn,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function directCredentials(auth: AuthFlow2Config): MqttCredentials {
|
|
33
|
+
return {
|
|
34
|
+
username: auth.toy_sn,
|
|
35
|
+
password: auth.toy_key,
|
|
36
|
+
toy_sn: auth.toy_sn,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function resolveCredentials(config: PluginConfig): Promise<MqttCredentials> {
|
|
41
|
+
if (config.auth.flow === 'api') {
|
|
42
|
+
return fetchCredentials(config.auth)
|
|
43
|
+
}
|
|
44
|
+
return directCredentials(config.auth)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildTopic(toy_sn: string): string {
|
|
48
|
+
return `/openapi/folotoy/${toy_sn}/thing/data/post`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function createMqttClient(config: PluginConfig, credentials: MqttCredentials): Promise<MqttClient> {
|
|
52
|
+
const { host, port } = config.mqtt
|
|
53
|
+
const { username, password } = credentials
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const client = mqtt.connect(`mqtt://${host}:${port}`, {
|
|
57
|
+
username: `openapi:${username}`,
|
|
58
|
+
password,
|
|
59
|
+
clean: true,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
client.once('connect', () => resolve(client))
|
|
63
|
+
client.once('error', reject)
|
|
64
|
+
})
|
|
65
|
+
}
|