@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 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
+ ```
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "folotoy",
3
+ "type": "channel",
4
+ "channels": ["folotoy"]
5
+ }
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
+ })
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildTopic } from '../mqtt.js'
3
+
4
+ describe('buildTopic', () => {
5
+ it('builds the correct topic for a given SN', () => {
6
+ expect(buildTopic('SN001')).toBe('/openapi/folotoy/SN001/thing/data/post')
7
+ })
8
+ })
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
+ }