@2en/clawly-plugins 1.13.0 → 1.15.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/gateway/channels-configure.ts +151 -0
- package/gateway/index.ts +2 -0
- package/index.ts +9 -1
- package/outbound.ts +91 -2
- package/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel configuration management via openclaw.json.
|
|
3
|
+
*
|
|
4
|
+
* Methods:
|
|
5
|
+
* - clawly.channels.configure — add/update a channel account config
|
|
6
|
+
* - clawly.channels.disconnect — remove a channel account config
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
import type {PluginApi} from '../index'
|
|
13
|
+
|
|
14
|
+
const TOKEN_CHANNELS = new Set(['telegram', 'discord', 'slack', 'irc', 'googlechat'])
|
|
15
|
+
|
|
16
|
+
function resolveStateDir(api: PluginApi): string {
|
|
17
|
+
return api.runtime.state?.resolveStateDir?.(process.env) ?? process.env.OPENCLAW_STATE_DIR ?? ''
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readOpenclawConfig(configPath: string): Record<string, unknown> {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
23
|
+
} catch {
|
|
24
|
+
return {}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeOpenclawConfig(configPath: string, config: Record<string, unknown>) {
|
|
29
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function registerChannelsConfigure(api: PluginApi) {
|
|
33
|
+
// ── clawly.channels.configure ──────────────────────────────────
|
|
34
|
+
|
|
35
|
+
api.registerGatewayMethod('clawly.channels.configure', async ({params, respond}) => {
|
|
36
|
+
const channel = typeof params.channel === 'string' ? params.channel.trim() : ''
|
|
37
|
+
const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : 'default'
|
|
38
|
+
const config =
|
|
39
|
+
params.config && typeof params.config === 'object' && !Array.isArray(params.config)
|
|
40
|
+
? (params.config as Record<string, unknown>)
|
|
41
|
+
: null
|
|
42
|
+
|
|
43
|
+
if (!channel) {
|
|
44
|
+
respond(false, undefined, {code: 'invalid_params', message: 'channel is required'})
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!TOKEN_CHANNELS.has(channel)) {
|
|
49
|
+
respond(false, undefined, {
|
|
50
|
+
code: 'invalid_params',
|
|
51
|
+
message: `unsupported channel: ${channel}. Supported: ${[...TOKEN_CHANNELS].join(', ')}`,
|
|
52
|
+
})
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!config) {
|
|
57
|
+
respond(false, undefined, {code: 'invalid_params', message: 'config object is required'})
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stateDir = resolveStateDir(api)
|
|
62
|
+
if (!stateDir) {
|
|
63
|
+
respond(false, undefined, {code: 'internal', message: 'cannot resolve openclaw state dir'})
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const configPath = path.join(stateDir, 'openclaw.json')
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const ocConfig = readOpenclawConfig(configPath)
|
|
71
|
+
|
|
72
|
+
// Ensure channels.<channel>.accounts.<accountId> path exists
|
|
73
|
+
if (!ocConfig.channels || typeof ocConfig.channels !== 'object') {
|
|
74
|
+
ocConfig.channels = {}
|
|
75
|
+
}
|
|
76
|
+
const channels = ocConfig.channels as Record<string, unknown>
|
|
77
|
+
|
|
78
|
+
if (!channels[channel] || typeof channels[channel] !== 'object') {
|
|
79
|
+
channels[channel] = {}
|
|
80
|
+
}
|
|
81
|
+
const channelConfig = channels[channel] as Record<string, unknown>
|
|
82
|
+
|
|
83
|
+
if (!channelConfig.accounts || typeof channelConfig.accounts !== 'object') {
|
|
84
|
+
channelConfig.accounts = {}
|
|
85
|
+
}
|
|
86
|
+
const accounts = channelConfig.accounts as Record<string, unknown>
|
|
87
|
+
|
|
88
|
+
// Merge config with enabled: true
|
|
89
|
+
accounts[accountId] = {
|
|
90
|
+
...((accounts[accountId] as Record<string, unknown>) ?? {}),
|
|
91
|
+
...config,
|
|
92
|
+
enabled: true,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
writeOpenclawConfig(configPath, ocConfig)
|
|
96
|
+
api.logger.info(`channels.configure: wrote ${channel}/${accountId} to openclaw.json`)
|
|
97
|
+
|
|
98
|
+
respond(true, {ok: true, channel, accountId})
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
101
|
+
api.logger.error(`channels.configure: failed — ${msg}`)
|
|
102
|
+
respond(false, undefined, {code: 'internal', message: msg})
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ── clawly.channels.disconnect ─────────────────────────────────
|
|
107
|
+
|
|
108
|
+
api.registerGatewayMethod('clawly.channels.disconnect', async ({params, respond}) => {
|
|
109
|
+
const channel = typeof params.channel === 'string' ? params.channel.trim() : ''
|
|
110
|
+
const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : 'default'
|
|
111
|
+
|
|
112
|
+
if (!channel) {
|
|
113
|
+
respond(false, undefined, {code: 'invalid_params', message: 'channel is required'})
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stateDir = resolveStateDir(api)
|
|
118
|
+
if (!stateDir) {
|
|
119
|
+
respond(false, undefined, {code: 'internal', message: 'cannot resolve openclaw state dir'})
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const configPath = path.join(stateDir, 'openclaw.json')
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const ocConfig = readOpenclawConfig(configPath)
|
|
127
|
+
const channels = ocConfig.channels as Record<string, unknown> | undefined
|
|
128
|
+
|
|
129
|
+
if (channels && typeof channels === 'object') {
|
|
130
|
+
const channelConfig = channels[channel] as Record<string, unknown> | undefined
|
|
131
|
+
if (channelConfig && typeof channelConfig === 'object') {
|
|
132
|
+
const accounts = channelConfig.accounts as Record<string, unknown> | undefined
|
|
133
|
+
if (accounts && typeof accounts === 'object' && accountId in accounts) {
|
|
134
|
+
delete accounts[accountId]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
writeOpenclawConfig(configPath, ocConfig)
|
|
140
|
+
api.logger.info(`channels.disconnect: removed ${channel}/${accountId} from openclaw.json`)
|
|
141
|
+
|
|
142
|
+
respond(true, {ok: true, channel, accountId})
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
145
|
+
api.logger.error(`channels.disconnect: failed — ${msg}`)
|
|
146
|
+
respond(false, undefined, {code: 'internal', message: msg})
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
api.logger.info('channels: registered clawly.channels.configure + clawly.channels.disconnect')
|
|
151
|
+
}
|
package/gateway/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {PluginApi} from '../index'
|
|
2
2
|
import {registerAgentSend} from './agent'
|
|
3
|
+
import {registerChannelsConfigure} from './channels-configure'
|
|
3
4
|
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
4
5
|
import {registerInject} from './inject'
|
|
5
6
|
import {registerMemoryBrowser} from './memory'
|
|
@@ -15,4 +16,5 @@ export function registerGateway(api: PluginApi) {
|
|
|
15
16
|
registerMemoryBrowser(api)
|
|
16
17
|
registerClawhub2gateway(api)
|
|
17
18
|
registerPlugins(api)
|
|
19
|
+
registerChannelsConfigure(api)
|
|
18
20
|
}
|
package/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {registerCronHook} from './cron-hook'
|
|
|
33
33
|
import {registerEmail} from './email'
|
|
34
34
|
import {registerGateway} from './gateway'
|
|
35
35
|
import {getGatewayConfig} from './gateway-fetch'
|
|
36
|
-
import {registerOutboundHook, registerOutboundMethods} from './outbound'
|
|
36
|
+
import {registerOutboundHook, registerOutboundHttpRoute, registerOutboundMethods} from './outbound'
|
|
37
37
|
import {registerTools} from './tools'
|
|
38
38
|
|
|
39
39
|
type PluginRuntime = {
|
|
@@ -92,6 +92,13 @@ export type PluginApi = {
|
|
|
92
92
|
opts?: {optional?: boolean},
|
|
93
93
|
) => void
|
|
94
94
|
registerChannel: (registration: {plugin: any}) => void
|
|
95
|
+
registerHttpRoute: (params: {
|
|
96
|
+
path: string
|
|
97
|
+
handler: (
|
|
98
|
+
req: import('node:http').IncomingMessage,
|
|
99
|
+
res: import('node:http').ServerResponse,
|
|
100
|
+
) => Promise<void> | void
|
|
101
|
+
}) => void
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
export default {
|
|
@@ -101,6 +108,7 @@ export default {
|
|
|
101
108
|
register(api: PluginApi) {
|
|
102
109
|
registerOutboundHook(api)
|
|
103
110
|
registerOutboundMethods(api)
|
|
111
|
+
registerOutboundHttpRoute(api)
|
|
104
112
|
registerCommands(api)
|
|
105
113
|
registerTools(api)
|
|
106
114
|
registerClawlyCronChannel(api)
|
package/outbound.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import crypto from 'node:crypto'
|
|
10
10
|
import fs from 'node:fs'
|
|
11
|
+
import fsp from 'node:fs/promises'
|
|
12
|
+
import type {IncomingMessage, ServerResponse} from 'node:http'
|
|
11
13
|
import os from 'node:os'
|
|
12
14
|
import path from 'node:path'
|
|
13
15
|
|
|
@@ -29,6 +31,15 @@ function ensureOutboundDir(): void {
|
|
|
29
31
|
fs.mkdirSync(OUTBOUND_DIR, {recursive: true})
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
35
|
+
try {
|
|
36
|
+
await fsp.access(p)
|
|
37
|
+
return true
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
// ── Hook: tool_result_persist ───────────────────────────────────────
|
|
33
44
|
|
|
34
45
|
export function registerOutboundHook(api: PluginApi) {
|
|
@@ -73,16 +84,94 @@ export function registerOutboundMethods(api: PluginApi) {
|
|
|
73
84
|
|
|
74
85
|
const dest = outboundFilePath(rawPath)
|
|
75
86
|
|
|
76
|
-
if (!
|
|
87
|
+
if (!(await fileExists(dest))) {
|
|
77
88
|
api.logger.warn(`outbound: file not found: ${rawPath} ${dest}`)
|
|
78
89
|
respond(false, undefined, {code: 'not_found', message: 'outbound file not found'})
|
|
79
90
|
return
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
const buffer =
|
|
93
|
+
const buffer = await fsp.readFile(dest)
|
|
83
94
|
const base64 = buffer.toString('base64')
|
|
84
95
|
|
|
85
96
|
api.logger.info(`clawly.file.getOutbound: served ${rawPath} (${buffer.length} bytes)`)
|
|
86
97
|
respond(true, {base64})
|
|
87
98
|
})
|
|
88
99
|
}
|
|
100
|
+
|
|
101
|
+
// ── HTTP route: GET /clawly/file/outbound?path=<original-path> ─────────────
|
|
102
|
+
|
|
103
|
+
const MIME: Record<string, string> = {
|
|
104
|
+
'.mp3': 'audio/mpeg',
|
|
105
|
+
'.wav': 'audio/wav',
|
|
106
|
+
'.ogg': 'audio/ogg',
|
|
107
|
+
'.m4a': 'audio/mp4',
|
|
108
|
+
'.aac': 'audio/aac',
|
|
109
|
+
'.flac': 'audio/flac',
|
|
110
|
+
'.webm': 'audio/webm',
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sendJson(res: ServerResponse, status: number, body: Record<string, unknown>) {
|
|
114
|
+
res.writeHead(status, {'Content-Type': 'application/json'})
|
|
115
|
+
res.end(JSON.stringify(body))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function registerOutboundHttpRoute(api: PluginApi) {
|
|
119
|
+
api.registerHttpRoute({
|
|
120
|
+
path: '/clawly/file/outbound',
|
|
121
|
+
handler: async (_req: IncomingMessage, res: ServerResponse) => {
|
|
122
|
+
const url = new URL(_req.url ?? '/', 'http://localhost')
|
|
123
|
+
const rawPath = url.searchParams.get('path')?.trim() ?? ''
|
|
124
|
+
|
|
125
|
+
if (!rawPath) {
|
|
126
|
+
sendJson(res, 400, {error: 'path query parameter is required'})
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const dest = outboundFilePath(rawPath)
|
|
131
|
+
|
|
132
|
+
if (!(await fileExists(dest))) {
|
|
133
|
+
api.logger.warn(`outbound http: file not found: ${rawPath}`)
|
|
134
|
+
sendJson(res, 404, {error: 'outbound file not found'})
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ext = path.extname(dest).toLowerCase()
|
|
139
|
+
const contentType = MIME[ext] ?? 'application/octet-stream'
|
|
140
|
+
const stat = await fsp.stat(dest)
|
|
141
|
+
const total = stat.size
|
|
142
|
+
const rangeHeader = _req.headers.range
|
|
143
|
+
|
|
144
|
+
if (rangeHeader) {
|
|
145
|
+
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader)
|
|
146
|
+
if (match) {
|
|
147
|
+
const start = Number(match[1])
|
|
148
|
+
const end = match[2] ? Number(match[2]) : total - 1
|
|
149
|
+
const chunkSize = end - start + 1
|
|
150
|
+
const stream = fs.createReadStream(dest, {start, end})
|
|
151
|
+
|
|
152
|
+
res.writeHead(206, {
|
|
153
|
+
'Content-Type': contentType,
|
|
154
|
+
'Content-Range': `bytes ${start}-${end}/${total}`,
|
|
155
|
+
'Accept-Ranges': 'bytes',
|
|
156
|
+
'Content-Length': chunkSize,
|
|
157
|
+
})
|
|
158
|
+
stream.pipe(res)
|
|
159
|
+
api.logger.info(
|
|
160
|
+
`outbound http: served ${rawPath} -> ${dest} range ${start}-${end}/${total}`,
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const buffer = await fsp.readFile(dest)
|
|
167
|
+
res.writeHead(200, {
|
|
168
|
+
'Content-Type': contentType,
|
|
169
|
+
'Content-Length': total,
|
|
170
|
+
'Accept-Ranges': 'bytes',
|
|
171
|
+
})
|
|
172
|
+
res.end(buffer)
|
|
173
|
+
|
|
174
|
+
api.logger.info(`outbound http: served ${rawPath} -> ${dest} (${total} bytes)`)
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
}
|