@swarmclawai/swarmclaw 1.9.11 → 1.9.13

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.
@@ -0,0 +1,141 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import test from 'node:test'
6
+
7
+ import type { Connector } from '@/types'
8
+ import {
9
+ drainFileQueueOnce,
10
+ normalizeFileQueueEnvelope,
11
+ resolveFileQueuePaths,
12
+ writeFileQueueOutbound,
13
+ } from './filequeue'
14
+
15
+ function makeConnector(rootDir: string): Connector {
16
+ return {
17
+ id: 'filequeue-1',
18
+ name: 'Local Queue',
19
+ platform: 'filequeue',
20
+ agentId: 'agent-1',
21
+ chatroomId: null,
22
+ credentialId: null,
23
+ config: {
24
+ rootDir,
25
+ defaultSenderId: 'queue',
26
+ defaultSenderName: 'Queue',
27
+ defaultChannelId: 'ops',
28
+ },
29
+ isEnabled: true,
30
+ status: 'running',
31
+ createdAt: 1,
32
+ updatedAt: 1,
33
+ }
34
+ }
35
+
36
+ test('normalizeFileQueueEnvelope maps command JSON into an inbound connector message', () => {
37
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
38
+ try {
39
+ const connector = makeConnector(rootDir)
40
+ const inbound = normalizeFileQueueEnvelope(connector, {
41
+ id: 'cmd-1',
42
+ channelId: 'ops',
43
+ senderId: 'jarvis',
44
+ senderName: 'JARVIS',
45
+ text: 'Summarize current status',
46
+ threadId: 'status-thread',
47
+ })
48
+
49
+ assert.equal(inbound.platform, 'filequeue')
50
+ assert.equal(inbound.channelId, 'ops')
51
+ assert.equal(inbound.senderId, 'jarvis')
52
+ assert.equal(inbound.senderName, 'JARVIS')
53
+ assert.equal(inbound.messageId, 'cmd-1')
54
+ assert.equal(inbound.threadId, 'status-thread')
55
+ assert.equal(inbound.text, 'Summarize current status')
56
+ } finally {
57
+ fs.rmSync(rootDir, { recursive: true, force: true })
58
+ }
59
+ })
60
+
61
+ test('drainFileQueueOnce archives processed commands and writes replies to outbox', async () => {
62
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
63
+ try {
64
+ const connector = makeConnector(rootDir)
65
+ const paths = resolveFileQueuePaths(connector)
66
+ fs.mkdirSync(paths.inboxDir, { recursive: true })
67
+ fs.writeFileSync(path.join(paths.inboxDir, '001.json'), JSON.stringify({
68
+ id: 'cmd-1',
69
+ senderId: 'jarvis',
70
+ senderName: 'JARVIS',
71
+ text: 'Run the release check',
72
+ }))
73
+
74
+ const result = await drainFileQueueOnce(connector, async (msg) => {
75
+ assert.equal(msg.channelId, 'ops')
76
+ assert.equal(msg.senderId, 'jarvis')
77
+ assert.equal(msg.text, 'Run the release check')
78
+ return 'Release check queued.'
79
+ })
80
+
81
+ assert.equal(result.processed, 1)
82
+ assert.equal(result.failed, 0)
83
+ assert.equal(fs.existsSync(path.join(paths.inboxDir, '001.json')), false)
84
+ assert.equal(fs.existsSync(path.join(paths.archiveDir, '001.json')), true)
85
+
86
+ const outboxFiles = fs.readdirSync(paths.outboxDir).filter((file) => file.endsWith('.json'))
87
+ assert.equal(outboxFiles.length, 1)
88
+ const outbound = JSON.parse(fs.readFileSync(path.join(paths.outboxDir, outboxFiles[0]), 'utf8')) as Record<string, unknown>
89
+ assert.equal(outbound.connectorId, connector.id)
90
+ assert.equal(outbound.channelId, 'ops')
91
+ assert.equal(outbound.text, 'Release check queued.')
92
+ assert.equal(outbound.replyToMessageId, 'cmd-1')
93
+ } finally {
94
+ fs.rmSync(rootDir, { recursive: true, force: true })
95
+ }
96
+ })
97
+
98
+ test('drainFileQueueOnce moves malformed JSON into the error directory', async () => {
99
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
100
+ try {
101
+ const connector = makeConnector(rootDir)
102
+ const paths = resolveFileQueuePaths(connector)
103
+ fs.mkdirSync(paths.inboxDir, { recursive: true })
104
+ fs.writeFileSync(path.join(paths.inboxDir, 'broken.json'), '{bad-json')
105
+
106
+ const result = await drainFileQueueOnce(connector, async () => {
107
+ throw new Error('should not route malformed envelopes')
108
+ })
109
+
110
+ assert.equal(result.processed, 0)
111
+ assert.equal(result.failed, 1)
112
+ assert.equal(fs.existsSync(path.join(paths.inboxDir, 'broken.json')), false)
113
+ assert.equal(fs.existsSync(path.join(paths.errorDir, 'broken.json')), true)
114
+ assert.equal(fs.existsSync(path.join(paths.errorDir, 'broken.json.error.txt')), true)
115
+ } finally {
116
+ fs.rmSync(rootDir, { recursive: true, force: true })
117
+ }
118
+ })
119
+
120
+ test('writeFileQueueOutbound stores structured command output in the outbox', async () => {
121
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
122
+ try {
123
+ const connector = makeConnector(rootDir)
124
+ const written = await writeFileQueueOutbound(connector, {
125
+ channelId: 'ops',
126
+ text: 'Done',
127
+ threadId: 'status-thread',
128
+ replyToMessageId: 'cmd-1',
129
+ })
130
+
131
+ const payload = JSON.parse(fs.readFileSync(written.path, 'utf8')) as Record<string, unknown>
132
+ assert.equal(payload.kind, 'swarmclaw.filequeue.outbound')
133
+ assert.equal(payload.connectorId, connector.id)
134
+ assert.equal(payload.channelId, 'ops')
135
+ assert.equal(payload.text, 'Done')
136
+ assert.equal(payload.threadId, 'status-thread')
137
+ assert.equal(payload.replyToMessageId, 'cmd-1')
138
+ } finally {
139
+ fs.rmSync(rootDir, { recursive: true, force: true })
140
+ }
141
+ })
@@ -0,0 +1,324 @@
1
+ import fs from 'node:fs'
2
+ import fsp from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+
6
+ import { genId } from '@/lib/id'
7
+ import { CONNECTORS_DATA_DIR } from '@/lib/server/data-dir'
8
+ import { log } from '@/lib/server/logger'
9
+ import { errorMessage } from '@/lib/shared-utils'
10
+ import type { Connector } from '@/types'
11
+ import type {
12
+ ConnectorIngressResult,
13
+ ConnectorInstance,
14
+ InboundMessage,
15
+ OutboundSendOptions,
16
+ PlatformConnector,
17
+ } from './types'
18
+ import { resolveConnectorIngressReply } from './ingress-delivery'
19
+
20
+ const TAG = 'filequeue'
21
+ const DEFAULT_POLL_INTERVAL_MS = 1_000
22
+ const MIN_POLL_INTERVAL_MS = 250
23
+
24
+ export interface FileQueuePaths {
25
+ rootDir: string
26
+ inboxDir: string
27
+ outboxDir: string
28
+ archiveDir: string
29
+ errorDir: string
30
+ }
31
+
32
+ export interface FileQueueDrainResult {
33
+ processed: number
34
+ failed: number
35
+ }
36
+
37
+ export interface FileQueueOutboundInput {
38
+ channelId: string
39
+ text: string
40
+ threadId?: string
41
+ replyToMessageId?: string
42
+ options?: OutboundSendOptions
43
+ }
44
+
45
+ function asRecord(value: unknown): Record<string, unknown> | null {
46
+ return value && typeof value === 'object' && !Array.isArray(value)
47
+ ? value as Record<string, unknown>
48
+ : null
49
+ }
50
+
51
+ function clean(value: unknown): string {
52
+ return typeof value === 'string' ? value.trim() : ''
53
+ }
54
+
55
+ function expandHome(input: string): string {
56
+ if (input === '~') return os.homedir()
57
+ if (input.startsWith('~/') || input.startsWith('~\\')) {
58
+ return path.join(os.homedir(), input.slice(2))
59
+ }
60
+ return input
61
+ }
62
+
63
+ function resolveConfiguredPath(rootDir: string, configured: unknown, fallback: string): string {
64
+ const value = clean(configured)
65
+ if (!value) return path.join(rootDir, fallback)
66
+ const expanded = expandHome(value)
67
+ return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(rootDir, expanded)
68
+ }
69
+
70
+ function parsePollIntervalMs(value: unknown): number {
71
+ const parsed = typeof value === 'number'
72
+ ? value
73
+ : (typeof value === 'string' && value.trim() ? Number.parseInt(value.trim(), 10) : DEFAULT_POLL_INTERVAL_MS)
74
+ if (!Number.isFinite(parsed)) return DEFAULT_POLL_INTERVAL_MS
75
+ return Math.max(MIN_POLL_INTERVAL_MS, parsed)
76
+ }
77
+
78
+ function ensureFileQueueDirs(paths: FileQueuePaths): void {
79
+ for (const dir of [paths.rootDir, paths.inboxDir, paths.outboxDir, paths.archiveDir, paths.errorDir]) {
80
+ fs.mkdirSync(dir, { recursive: true })
81
+ }
82
+ }
83
+
84
+ export function resolveFileQueuePaths(connector: Connector): FileQueuePaths {
85
+ const config = connector.config || {}
86
+ const configuredRoot = clean(config.rootDir)
87
+ const rootDir = configuredRoot
88
+ ? path.resolve(expandHome(configuredRoot))
89
+ : path.join(CONNECTORS_DATA_DIR, connector.id, 'filequeue')
90
+ return {
91
+ rootDir,
92
+ inboxDir: resolveConfiguredPath(rootDir, config.inboxDir, 'inbox'),
93
+ outboxDir: resolveConfiguredPath(rootDir, config.outboxDir, 'outbox'),
94
+ archiveDir: resolveConfiguredPath(rootDir, config.archiveDir, 'archive'),
95
+ errorDir: resolveConfiguredPath(rootDir, config.errorDir, 'errors'),
96
+ }
97
+ }
98
+
99
+ function firstText(...values: unknown[]): string {
100
+ for (const value of values) {
101
+ const text = clean(value)
102
+ if (text) return text
103
+ }
104
+ return ''
105
+ }
106
+
107
+ function fileNameSafe(value: string): string {
108
+ return value
109
+ .split('')
110
+ .map((char) => {
111
+ const code = char.charCodeAt(0)
112
+ const isDigit = code >= 48 && code <= 57
113
+ const isUpper = code >= 65 && code <= 90
114
+ const isLower = code >= 97 && code <= 122
115
+ return isDigit || isUpper || isLower || char === '-' || char === '_' ? char : '_'
116
+ })
117
+ .join('')
118
+ .slice(0, 96) || 'item'
119
+ }
120
+
121
+ function resolveUniquePath(dir: string, basename: string): string {
122
+ const ext = path.extname(basename)
123
+ const stem = path.basename(basename, ext)
124
+ let candidate = path.join(dir, basename)
125
+ if (!fs.existsSync(candidate)) return candidate
126
+ for (let i = 1; i < 1_000; i += 1) {
127
+ candidate = path.join(dir, `${stem}-${i}${ext}`)
128
+ if (!fs.existsSync(candidate)) return candidate
129
+ }
130
+ return path.join(dir, `${stem}-${Date.now()}-${genId()}${ext}`)
131
+ }
132
+
133
+ async function moveFile(source: string, targetDir: string): Promise<string> {
134
+ await fsp.mkdir(targetDir, { recursive: true })
135
+ const target = resolveUniquePath(targetDir, path.basename(source))
136
+ try {
137
+ await fsp.rename(source, target)
138
+ } catch (err: unknown) {
139
+ const code = typeof err === 'object' && err && 'code' in err ? String((err as { code?: unknown }).code) : ''
140
+ if (code !== 'EXDEV') throw err
141
+ await fsp.copyFile(source, target)
142
+ await fsp.unlink(source)
143
+ }
144
+ return target
145
+ }
146
+
147
+ function readNestedEnvelope(record: Record<string, unknown>): Record<string, unknown> | null {
148
+ return asRecord(record.payload)
149
+ || asRecord(record.command)
150
+ || asRecord(record.message)
151
+ || asRecord(record.data)
152
+ }
153
+
154
+ export function normalizeFileQueueEnvelope(connector: Connector, envelope: unknown): InboundMessage {
155
+ const record = asRecord(envelope)
156
+ if (!record) throw new Error('File queue envelope must be a JSON object')
157
+ const nested = readNestedEnvelope(record)
158
+ const sender = asRecord(record.sender) || asRecord(nested?.sender)
159
+ const config = connector.config || {}
160
+ const id = firstText(record.id, record.messageId, record.commandId, nested?.id, nested?.messageId, genId())
161
+ const text = firstText(
162
+ record.text,
163
+ record.body,
164
+ record.prompt,
165
+ typeof record.command === 'string' ? record.command : '',
166
+ nested?.text,
167
+ nested?.body,
168
+ nested?.prompt,
169
+ typeof nested?.command === 'string' ? nested.command : '',
170
+ )
171
+ if (!text) throw new Error('File queue envelope requires text, body, prompt, or command')
172
+
173
+ const channelId = firstText(record.channelId, record.channel, nested?.channelId, nested?.channel, config.defaultChannelId, 'filequeue')
174
+ const senderId = firstText(record.senderId, sender?.id, sender?.senderId, nested?.senderId, config.defaultSenderId, 'filequeue')
175
+ const senderName = firstText(record.senderName, sender?.name, sender?.senderName, nested?.senderName, config.defaultSenderName, senderId)
176
+
177
+ return {
178
+ platform: 'filequeue',
179
+ channelId,
180
+ channelName: firstText(record.channelName, nested?.channelName, channelId),
181
+ senderId,
182
+ senderName,
183
+ text,
184
+ messageId: id,
185
+ replyToMessageId: firstText(record.replyToMessageId, nested?.replyToMessageId) || undefined,
186
+ threadId: firstText(record.threadId, nested?.threadId) || undefined,
187
+ isGroup: record.isGroup === true || nested?.isGroup === true,
188
+ }
189
+ }
190
+
191
+ export async function writeFileQueueOutbound(
192
+ connector: Connector,
193
+ input: FileQueueOutboundInput,
194
+ ): Promise<{ id: string; path: string; payload: Record<string, unknown> }> {
195
+ const paths = resolveFileQueuePaths(connector)
196
+ ensureFileQueueDirs(paths)
197
+ const id = genId()
198
+ const payload: Record<string, unknown> = {
199
+ id,
200
+ kind: 'swarmclaw.filequeue.outbound',
201
+ connectorId: connector.id,
202
+ connectorName: connector.name,
203
+ platform: 'filequeue',
204
+ channelId: input.channelId,
205
+ text: input.text,
206
+ createdAt: new Date().toISOString(),
207
+ }
208
+ if (input.threadId) payload.threadId = input.threadId
209
+ if (input.replyToMessageId) payload.replyToMessageId = input.replyToMessageId
210
+ if (input.options?.imageUrl) payload.imageUrl = input.options.imageUrl
211
+ if (input.options?.fileUrl) payload.fileUrl = input.options.fileUrl
212
+ if (input.options?.mediaPath) payload.mediaPath = input.options.mediaPath
213
+ if (input.options?.mimeType) payload.mimeType = input.options.mimeType
214
+ if (input.options?.fileName) payload.fileName = input.options.fileName
215
+ if (input.options?.caption) payload.caption = input.options.caption
216
+
217
+ const filename = `${Date.now()}-${fileNameSafe(id)}.json`
218
+ const outputPath = resolveUniquePath(paths.outboxDir, filename)
219
+ await fsp.writeFile(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
220
+ return { id, path: outputPath, payload }
221
+ }
222
+
223
+ export async function drainFileQueueOnce(
224
+ connector: Connector,
225
+ onMessage: (msg: InboundMessage) => Promise<ConnectorIngressResult>,
226
+ ): Promise<FileQueueDrainResult> {
227
+ const paths = resolveFileQueuePaths(connector)
228
+ ensureFileQueueDirs(paths)
229
+ const entries = await fsp.readdir(paths.inboxDir, { withFileTypes: true })
230
+ const files = entries
231
+ .filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === '.json')
232
+ .map((entry) => entry.name)
233
+ .sort((a, b) => a.localeCompare(b))
234
+ let processed = 0
235
+ let failed = 0
236
+
237
+ for (const file of files) {
238
+ const source = path.join(paths.inboxDir, file)
239
+ try {
240
+ const raw = await fsp.readFile(source, 'utf8')
241
+ const envelope = JSON.parse(raw) as unknown
242
+ const inbound = normalizeFileQueueEnvelope(connector, envelope)
243
+ const reply = await resolveConnectorIngressReply(onMessage, inbound)
244
+ if (reply) {
245
+ await writeFileQueueOutbound(connector, {
246
+ channelId: inbound.channelId,
247
+ text: reply.visibleText,
248
+ threadId: inbound.threadId,
249
+ replyToMessageId: inbound.messageId,
250
+ })
251
+ }
252
+ await moveFile(source, paths.archiveDir)
253
+ processed += 1
254
+ } catch (err: unknown) {
255
+ failed += 1
256
+ const message = errorMessage(err)
257
+ log.warn(TAG, `Failed to process file queue command ${file}: ${message}`)
258
+ try {
259
+ const moved = fs.existsSync(source)
260
+ ? await moveFile(source, paths.errorDir)
261
+ : path.join(paths.errorDir, file)
262
+ await fsp.writeFile(`${moved}.error.txt`, `${message}\n`, 'utf8')
263
+ } catch (moveErr: unknown) {
264
+ log.warn(TAG, `Failed to move malformed file queue command ${file}: ${errorMessage(moveErr)}`)
265
+ }
266
+ }
267
+ }
268
+
269
+ return { processed, failed }
270
+ }
271
+
272
+ const fileQueue: PlatformConnector = {
273
+ async start(connector, _botToken, onMessage): Promise<ConnectorInstance> {
274
+ const paths = resolveFileQueuePaths(connector)
275
+ ensureFileQueueDirs(paths)
276
+ let stopped = false
277
+ let draining = false
278
+ const pollIntervalMs = parsePollIntervalMs(connector.config?.pollIntervalMs)
279
+
280
+ const drain = async () => {
281
+ if (stopped || draining) return
282
+ draining = true
283
+ try {
284
+ const result = await drainFileQueueOnce(connector, onMessage)
285
+ if (result.processed || result.failed) {
286
+ log.info(TAG, `File queue drain for ${connector.name}: ${result.processed} processed, ${result.failed} failed`)
287
+ }
288
+ } catch (err: unknown) {
289
+ log.warn(TAG, `File queue drain failed for ${connector.name}: ${errorMessage(err)}`)
290
+ } finally {
291
+ draining = false
292
+ }
293
+ }
294
+
295
+ void drain()
296
+ const timer = setInterval(() => {
297
+ void drain()
298
+ }, pollIntervalMs)
299
+ timer.unref?.()
300
+
301
+ return {
302
+ connector,
303
+ authenticated: true,
304
+ supportsBinaryMedia: false,
305
+ stop: async () => {
306
+ stopped = true
307
+ clearInterval(timer)
308
+ },
309
+ isAlive: () => !stopped,
310
+ sendMessage: async (channelId: string, text: string, options?: OutboundSendOptions) => {
311
+ const written = await writeFileQueueOutbound(connector, {
312
+ channelId,
313
+ text,
314
+ threadId: options?.threadId,
315
+ replyToMessageId: options?.replyToMessageId,
316
+ options,
317
+ })
318
+ return { messageId: written.id }
319
+ },
320
+ }
321
+ },
322
+ }
323
+
324
+ export default fileQueue
@@ -210,6 +210,7 @@ const VALID_CONNECTOR_PLATFORMS = new Set([
210
210
  'googlechat',
211
211
  'matrix',
212
212
  'email',
213
+ 'filequeue',
213
214
  'webchat',
214
215
  'mockmail',
215
216
  ])
@@ -698,7 +698,7 @@ if (!IS_BUILD_BOOTSTRAP) {
698
698
  - **Tasks** — The Task Board tracks work items. Assign agents and they'll execute autonomously.
699
699
  - **Schedules** — Cron-based recurring jobs that run agents or tasks automatically.
700
700
  - **Skills** — Reusable markdown instruction files agents can discover and use by default; pin them to keep favorite workflows always-on.
701
- - **Connectors** — Bridge agents to Discord, Slack, Telegram, or WhatsApp.
701
+ - **Connectors** — Bridge agents to chat platforms, local file queues, and agent channels.
702
702
  - **Secrets** — Encrypted vault for API keys (Settings → Secrets).
703
703
 
704
704
  ## Tools
@@ -161,7 +161,7 @@ export const ConnectorCreateSchema = z.object({
161
161
  name: z.string().min(1, 'Connector name is required').optional(),
162
162
  platform: z.enum([
163
163
  'discord', 'telegram', 'slack', 'whatsapp', 'openclaw',
164
- 'bluebubbles', 'signal', 'teams', 'googlechat', 'matrix', 'email', 'swarmdock',
164
+ 'bluebubbles', 'signal', 'teams', 'googlechat', 'matrix', 'email', 'filequeue', 'swarmdock',
165
165
  ]),
166
166
  agentId: z.string().nullable().optional().default(null),
167
167
  chatroomId: z.string().nullable().optional().default(null),
@@ -28,6 +28,7 @@ export type ConnectorPlatform =
28
28
  | 'email'
29
29
  | 'webchat'
30
30
  | 'mockmail'
31
+ | 'filequeue'
31
32
  | 'swarmdock'
32
33
  export type ConnectorStatus = 'stopped' | 'running' | 'error' | 'starting'
33
34