@swarmclawai/swarmclaw 0.2.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 +577 -0
- package/bin/server-cmd.js +359 -0
- package/bin/swarmclaw.js +29 -0
- package/bin/swarmclaw.mjs +1504 -0
- package/next.config.ts +33 -0
- package/package.json +112 -0
- package/postcss.config.mjs +7 -0
- package/public/branding/swarmclaw-org-avatar.png +0 -0
- package/public/branding/swarmclaw-org-avatar.svg +58 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/connectors.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/new-session-openclaw.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/schedules.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/agents/[id]/route.ts +30 -0
- package/src/app/api/agents/[id]/thread/route.ts +66 -0
- package/src/app/api/agents/generate/route.ts +42 -0
- package/src/app/api/agents/route.ts +33 -0
- package/src/app/api/auth/route.ts +25 -0
- package/src/app/api/claude-skills/route.ts +42 -0
- package/src/app/api/clawhub/install/route.ts +39 -0
- package/src/app/api/clawhub/search/route.ts +11 -0
- package/src/app/api/connectors/[id]/route.ts +79 -0
- package/src/app/api/connectors/route.ts +60 -0
- package/src/app/api/credentials/[id]/route.ts +14 -0
- package/src/app/api/credentials/route.ts +31 -0
- package/src/app/api/daemon/health-check/route.ts +11 -0
- package/src/app/api/daemon/route.ts +22 -0
- package/src/app/api/dirs/pick/route.ts +60 -0
- package/src/app/api/dirs/route.ts +29 -0
- package/src/app/api/documents/[id]/route.ts +47 -0
- package/src/app/api/documents/route.ts +93 -0
- package/src/app/api/files/serve/route.ts +69 -0
- package/src/app/api/generate/info/route.ts +12 -0
- package/src/app/api/generate/route.ts +106 -0
- package/src/app/api/ip/route.ts +6 -0
- package/src/app/api/knowledge/[id]/route.ts +61 -0
- package/src/app/api/knowledge/route.ts +48 -0
- package/src/app/api/knowledge/upload/route.ts +86 -0
- package/src/app/api/logs/route.ts +65 -0
- package/src/app/api/mcp-servers/[id]/route.ts +32 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
- package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
- package/src/app/api/mcp-servers/route.ts +27 -0
- package/src/app/api/memory/[id]/route.ts +126 -0
- package/src/app/api/memory/maintenance/route.ts +63 -0
- package/src/app/api/memory/route.ts +111 -0
- package/src/app/api/memory-images/[filename]/route.ts +36 -0
- package/src/app/api/orchestrator/run/route.ts +43 -0
- package/src/app/api/plugins/install/route.ts +58 -0
- package/src/app/api/plugins/marketplace/route.ts +33 -0
- package/src/app/api/plugins/route.ts +21 -0
- package/src/app/api/preview-server/route.ts +339 -0
- package/src/app/api/providers/[id]/models/route.ts +29 -0
- package/src/app/api/providers/[id]/route.ts +34 -0
- package/src/app/api/providers/configs/route.ts +7 -0
- package/src/app/api/providers/ollama/route.ts +30 -0
- package/src/app/api/providers/openclaw/health/route.ts +23 -0
- package/src/app/api/providers/route.ts +28 -0
- package/src/app/api/runs/[id]/route.ts +9 -0
- package/src/app/api/runs/route.ts +13 -0
- package/src/app/api/schedules/[id]/route.ts +28 -0
- package/src/app/api/schedules/[id]/run/route.ts +104 -0
- package/src/app/api/schedules/route.ts +78 -0
- package/src/app/api/secrets/[id]/route.ts +29 -0
- package/src/app/api/secrets/route.ts +42 -0
- package/src/app/api/sessions/[id]/browser/route.ts +13 -0
- package/src/app/api/sessions/[id]/chat/route.ts +96 -0
- package/src/app/api/sessions/[id]/clear/route.ts +19 -0
- package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
- package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
- package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
- package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
- package/src/app/api/sessions/[id]/messages/route.ts +9 -0
- package/src/app/api/sessions/[id]/retry/route.ts +28 -0
- package/src/app/api/sessions/[id]/route.ts +103 -0
- package/src/app/api/sessions/[id]/stop/route.ts +13 -0
- package/src/app/api/sessions/heartbeat/route.ts +26 -0
- package/src/app/api/sessions/route.ts +85 -0
- package/src/app/api/settings/route.ts +58 -0
- package/src/app/api/setup/check-provider/route.ts +326 -0
- package/src/app/api/setup/doctor/route.ts +250 -0
- package/src/app/api/skills/[id]/route.ts +40 -0
- package/src/app/api/skills/import/route.ts +69 -0
- package/src/app/api/skills/route.ts +28 -0
- package/src/app/api/tasks/[id]/route.ts +102 -0
- package/src/app/api/tasks/route.ts +115 -0
- package/src/app/api/tts/route.ts +40 -0
- package/src/app/api/upload/route.ts +18 -0
- package/src/app/api/uploads/[filename]/route.ts +59 -0
- package/src/app/api/usage/route.ts +35 -0
- package/src/app/api/version/route.ts +81 -0
- package/src/app/api/version/update/route.ts +95 -0
- package/src/app/api/webhooks/[id]/history/route.ts +13 -0
- package/src/app/api/webhooks/[id]/route.ts +204 -0
- package/src/app/api/webhooks/route.ts +37 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +370 -0
- package/src/app/layout.tsx +52 -0
- package/src/app/page.tsx +172 -0
- package/src/cli/index.js +1232 -0
- package/src/cli/index.test.js +281 -0
- package/src/cli/index.ts +1158 -0
- package/src/cli/spec.js +284 -0
- package/src/components/agents/agent-card.tsx +219 -0
- package/src/components/agents/agent-chat-list.tsx +165 -0
- package/src/components/agents/agent-list.tsx +110 -0
- package/src/components/agents/agent-sheet.tsx +1220 -0
- package/src/components/auth/access-key-gate.tsx +248 -0
- package/src/components/auth/setup-wizard.tsx +940 -0
- package/src/components/auth/user-picker.tsx +88 -0
- package/src/components/chat/chat-area.tsx +406 -0
- package/src/components/chat/chat-header.tsx +491 -0
- package/src/components/chat/chat-tool-toggles.tsx +161 -0
- package/src/components/chat/code-block.tsx +146 -0
- package/src/components/chat/dev-server-bar.tsx +39 -0
- package/src/components/chat/message-bubble.tsx +486 -0
- package/src/components/chat/message-list.tsx +299 -0
- package/src/components/chat/session-debug-panel.tsx +196 -0
- package/src/components/chat/streaming-bubble.tsx +85 -0
- package/src/components/chat/thinking-indicator.tsx +26 -0
- package/src/components/chat/tool-call-bubble.tsx +438 -0
- package/src/components/chat/tool-request-banner.tsx +103 -0
- package/src/components/connectors/connector-list.tsx +196 -0
- package/src/components/connectors/connector-sheet.tsx +804 -0
- package/src/components/input/chat-input.tsx +235 -0
- package/src/components/knowledge/knowledge-list.tsx +206 -0
- package/src/components/knowledge/knowledge-sheet.tsx +316 -0
- package/src/components/layout/app-layout.tsx +1016 -0
- package/src/components/layout/daemon-indicator.tsx +56 -0
- package/src/components/layout/mobile-header.tsx +31 -0
- package/src/components/layout/network-banner.tsx +17 -0
- package/src/components/layout/update-banner.tsx +130 -0
- package/src/components/logs/log-list.tsx +358 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
- package/src/components/memory/memory-card.tsx +63 -0
- package/src/components/memory/memory-detail.tsx +339 -0
- package/src/components/memory/memory-list.tsx +198 -0
- package/src/components/memory/memory-sheet.tsx +70 -0
- package/src/components/plugins/plugin-list.tsx +60 -0
- package/src/components/plugins/plugin-sheet.tsx +311 -0
- package/src/components/providers/provider-list.tsx +96 -0
- package/src/components/providers/provider-sheet.tsx +542 -0
- package/src/components/runs/run-list.tsx +231 -0
- package/src/components/schedules/schedule-card.tsx +63 -0
- package/src/components/schedules/schedule-list.tsx +76 -0
- package/src/components/schedules/schedule-sheet.tsx +336 -0
- package/src/components/secrets/secret-sheet.tsx +180 -0
- package/src/components/secrets/secrets-list.tsx +91 -0
- package/src/components/sessions/new-session-sheet.tsx +478 -0
- package/src/components/sessions/session-card.tsx +144 -0
- package/src/components/sessions/session-list.tsx +202 -0
- package/src/components/shared/ai-gen-block.tsx +77 -0
- package/src/components/shared/avatar.tsx +48 -0
- package/src/components/shared/bottom-sheet.tsx +30 -0
- package/src/components/shared/confirm-dialog.tsx +47 -0
- package/src/components/shared/connector-platform-icon.tsx +113 -0
- package/src/components/shared/dir-browser.tsx +285 -0
- package/src/components/shared/dropdown.tsx +55 -0
- package/src/components/shared/icon-button.tsx +25 -0
- package/src/components/shared/settings/plugin-manager.tsx +207 -0
- package/src/components/shared/settings/section-capability-policy.tsx +93 -0
- package/src/components/shared/settings/section-embedding.tsx +99 -0
- package/src/components/shared/settings/section-heartbeat.tsx +168 -0
- package/src/components/shared/settings/section-memory.tsx +77 -0
- package/src/components/shared/settings/section-orchestrator.tsx +108 -0
- package/src/components/shared/settings/section-providers.tsx +181 -0
- package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
- package/src/components/shared/settings/section-secrets.tsx +132 -0
- package/src/components/shared/settings/section-user-preferences.tsx +24 -0
- package/src/components/shared/settings/section-voice.tsx +53 -0
- package/src/components/shared/settings/settings-sheet.tsx +88 -0
- package/src/components/shared/settings/types.ts +7 -0
- package/src/components/shared/settings/utils.ts +13 -0
- package/src/components/shared/settings-sheet.tsx +1 -0
- package/src/components/shared/skeleton.tsx +19 -0
- package/src/components/shared/usage-badge.tsx +28 -0
- package/src/components/skills/clawhub-browser.tsx +225 -0
- package/src/components/skills/skill-list.tsx +70 -0
- package/src/components/skills/skill-sheet.tsx +254 -0
- package/src/components/tasks/task-board.tsx +96 -0
- package/src/components/tasks/task-card.tsx +179 -0
- package/src/components/tasks/task-column.tsx +73 -0
- package/src/components/tasks/task-list.tsx +118 -0
- package/src/components/tasks/task-sheet.tsx +415 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/sonner.tsx +22 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +56 -0
- package/src/components/usage/usage-list.tsx +105 -0
- package/src/components/webhooks/webhook-list.tsx +166 -0
- package/src/components/webhooks/webhook-sheet.tsx +402 -0
- package/src/hooks/use-auto-resize.ts +20 -0
- package/src/hooks/use-media-query.ts +21 -0
- package/src/hooks/use-speech-recognition.ts +83 -0
- package/src/instrumentation.ts +8 -0
- package/src/lib/agents.ts +13 -0
- package/src/lib/api-client.ts +100 -0
- package/src/lib/chat.ts +60 -0
- package/src/lib/memory.ts +42 -0
- package/src/lib/openclaw-endpoint.test.ts +48 -0
- package/src/lib/openclaw-endpoint.ts +67 -0
- package/src/lib/provider-config.ts +13 -0
- package/src/lib/providers/anthropic.ts +135 -0
- package/src/lib/providers/claude-cli.ts +202 -0
- package/src/lib/providers/codex-cli.ts +260 -0
- package/src/lib/providers/index.ts +351 -0
- package/src/lib/providers/ollama.ts +131 -0
- package/src/lib/providers/openai.ts +164 -0
- package/src/lib/providers/openclaw.ts +330 -0
- package/src/lib/providers/opencode-cli.ts +164 -0
- package/src/lib/runtime-loop.ts +15 -0
- package/src/lib/schedule-dedupe.test.ts +84 -0
- package/src/lib/schedule-dedupe.ts +174 -0
- package/src/lib/schedule-name.ts +62 -0
- package/src/lib/schedules.ts +16 -0
- package/src/lib/server/agent-registry.ts +70 -0
- package/src/lib/server/api-routes.test.ts +362 -0
- package/src/lib/server/autonomy-contract.ts +200 -0
- package/src/lib/server/build-llm.ts +155 -0
- package/src/lib/server/capability-router.test.ts +21 -0
- package/src/lib/server/capability-router.ts +172 -0
- package/src/lib/server/chat-execution.ts +894 -0
- package/src/lib/server/clawhub-client.test.ts +161 -0
- package/src/lib/server/clawhub-client.ts +26 -0
- package/src/lib/server/connectors/connector-routing.test.ts +243 -0
- package/src/lib/server/connectors/discord.ts +116 -0
- package/src/lib/server/connectors/googlechat.ts +66 -0
- package/src/lib/server/connectors/manager.ts +559 -0
- package/src/lib/server/connectors/matrix.ts +78 -0
- package/src/lib/server/connectors/media.ts +149 -0
- package/src/lib/server/connectors/openclaw.test.ts +375 -0
- package/src/lib/server/connectors/openclaw.ts +1132 -0
- package/src/lib/server/connectors/signal.ts +183 -0
- package/src/lib/server/connectors/slack.ts +258 -0
- package/src/lib/server/connectors/teams.ts +94 -0
- package/src/lib/server/connectors/telegram.ts +221 -0
- package/src/lib/server/connectors/types.ts +62 -0
- package/src/lib/server/connectors/whatsapp.ts +349 -0
- package/src/lib/server/context-manager.ts +232 -0
- package/src/lib/server/cost.ts +31 -0
- package/src/lib/server/daemon-state.ts +354 -0
- package/src/lib/server/data-dir.ts +3 -0
- package/src/lib/server/embeddings.ts +111 -0
- package/src/lib/server/execution-log.ts +257 -0
- package/src/lib/server/gateway/protocol.test.ts +54 -0
- package/src/lib/server/gateway/protocol.ts +114 -0
- package/src/lib/server/heartbeat-service.ts +366 -0
- package/src/lib/server/knowledge-db.test.ts +441 -0
- package/src/lib/server/logger.ts +47 -0
- package/src/lib/server/main-agent-loop.ts +1017 -0
- package/src/lib/server/mcp-client.test.ts +342 -0
- package/src/lib/server/mcp-client.ts +130 -0
- package/src/lib/server/memory-db.ts +1078 -0
- package/src/lib/server/memory-graph.test.ts +153 -0
- package/src/lib/server/memory-graph.ts +138 -0
- package/src/lib/server/openclaw-health.ts +245 -0
- package/src/lib/server/orchestrator-lg.ts +431 -0
- package/src/lib/server/orchestrator.ts +364 -0
- package/src/lib/server/playwright-proxy.mjs +70 -0
- package/src/lib/server/plugins.ts +229 -0
- package/src/lib/server/process-manager.ts +327 -0
- package/src/lib/server/provider-health.ts +113 -0
- package/src/lib/server/queue.ts +859 -0
- package/src/lib/server/runtime-settings.ts +119 -0
- package/src/lib/server/scheduler.ts +196 -0
- package/src/lib/server/session-mailbox.ts +129 -0
- package/src/lib/server/session-run-manager.ts +512 -0
- package/src/lib/server/session-tools/connector.ts +124 -0
- package/src/lib/server/session-tools/context-mgmt.ts +103 -0
- package/src/lib/server/session-tools/context.ts +114 -0
- package/src/lib/server/session-tools/crud.ts +673 -0
- package/src/lib/server/session-tools/delegate.ts +708 -0
- package/src/lib/server/session-tools/file.ts +264 -0
- package/src/lib/server/session-tools/index.ts +164 -0
- package/src/lib/server/session-tools/memory.ts +230 -0
- package/src/lib/server/session-tools/session-info.ts +422 -0
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
- package/src/lib/server/session-tools/shell.ts +171 -0
- package/src/lib/server/session-tools/web.ts +408 -0
- package/src/lib/server/session-tools.ts +9 -0
- package/src/lib/server/skills-normalize.ts +130 -0
- package/src/lib/server/storage-mcp.test.ts +161 -0
- package/src/lib/server/storage.ts +670 -0
- package/src/lib/server/stream-agent-chat.ts +571 -0
- package/src/lib/server/task-reports.ts +122 -0
- package/src/lib/server/task-result.ts +161 -0
- package/src/lib/server/task-validation.test.ts +27 -0
- package/src/lib/server/task-validation.ts +90 -0
- package/src/lib/server/tool-capability-policy.test.ts +58 -0
- package/src/lib/server/tool-capability-policy.ts +262 -0
- package/src/lib/sessions.ts +68 -0
- package/src/lib/tasks.ts +20 -0
- package/src/lib/tts.ts +42 -0
- package/src/lib/upload.ts +10 -0
- package/src/lib/utils.ts +6 -0
- package/src/proxy.ts +43 -0
- package/src/stores/use-app-store.ts +468 -0
- package/src/stores/use-chat-store.ts +323 -0
- package/src/types/index.ts +621 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
let originalFetch: typeof globalThis.fetch
|
|
5
|
+
let originalEnv: string | undefined
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
originalFetch = globalThis.fetch
|
|
9
|
+
originalEnv = process.env.CLAWHUB_API_URL
|
|
10
|
+
delete process.env.CLAWHUB_API_URL
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
globalThis.fetch = originalFetch
|
|
15
|
+
if (originalEnv !== undefined) {
|
|
16
|
+
process.env.CLAWHUB_API_URL = originalEnv
|
|
17
|
+
} else {
|
|
18
|
+
delete process.env.CLAWHUB_API_URL
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('searchClawHub', () => {
|
|
23
|
+
// Module caches CLAWHUB_BASE_URL at import time, so we need dynamic import
|
|
24
|
+
// after setting env. However the default is baked in at module load.
|
|
25
|
+
// We'll test the default URL by importing once.
|
|
26
|
+
|
|
27
|
+
it('constructs correct URL with query params and returns parsed JSON', async () => {
|
|
28
|
+
const mockData = { skills: [{ id: 's1', name: 'test-skill' }], total: 1, page: 1 }
|
|
29
|
+
let capturedUrl = ''
|
|
30
|
+
|
|
31
|
+
globalThis.fetch = async (input: RequestInfo | URL) => {
|
|
32
|
+
capturedUrl = String(input)
|
|
33
|
+
return new Response(JSON.stringify(mockData), { status: 200 })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { searchClawHub } = await import('./clawhub-client.ts')
|
|
37
|
+
const result = await searchClawHub('hello world', 2, 10)
|
|
38
|
+
|
|
39
|
+
assert.ok(capturedUrl.includes('/skills?q=hello%20world&page=2&limit=10'))
|
|
40
|
+
assert.deepStrictEqual(result.skills, mockData.skills)
|
|
41
|
+
assert.equal(result.total, 1)
|
|
42
|
+
assert.equal(result.page, 1)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('uses default page=1 and limit=20', async () => {
|
|
46
|
+
let capturedUrl = ''
|
|
47
|
+
|
|
48
|
+
globalThis.fetch = async (input: RequestInfo | URL) => {
|
|
49
|
+
capturedUrl = String(input)
|
|
50
|
+
return new Response(JSON.stringify({ skills: [], total: 0, page: 1 }), { status: 200 })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { searchClawHub } = await import('./clawhub-client.ts')
|
|
54
|
+
await searchClawHub('test')
|
|
55
|
+
|
|
56
|
+
assert.ok(capturedUrl.includes('page=1'))
|
|
57
|
+
assert.ok(capturedUrl.includes('limit=20'))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('uses default base URL when CLAWHUB_API_URL is not set', async () => {
|
|
61
|
+
let capturedUrl = ''
|
|
62
|
+
|
|
63
|
+
globalThis.fetch = async (input: RequestInfo | URL) => {
|
|
64
|
+
capturedUrl = String(input)
|
|
65
|
+
return new Response(JSON.stringify({ skills: [], total: 0, page: 1 }), { status: 200 })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { searchClawHub } = await import('./clawhub-client.ts')
|
|
69
|
+
await searchClawHub('q')
|
|
70
|
+
|
|
71
|
+
assert.ok(capturedUrl.startsWith('https://clawhub.openclaw.dev/api/skills'))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns empty results on non-200 response', async () => {
|
|
75
|
+
globalThis.fetch = async () => {
|
|
76
|
+
return new Response('Not Found', { status: 404 })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { searchClawHub } = await import('./clawhub-client.ts')
|
|
80
|
+
const result = await searchClawHub('fail', 3)
|
|
81
|
+
|
|
82
|
+
assert.deepStrictEqual(result.skills, [])
|
|
83
|
+
assert.equal(result.total, 0)
|
|
84
|
+
assert.equal(result.page, 3)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns empty results on fetch network error', async () => {
|
|
88
|
+
globalThis.fetch = async () => {
|
|
89
|
+
throw new Error('network failure')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { searchClawHub } = await import('./clawhub-client.ts')
|
|
93
|
+
const result = await searchClawHub('err', 5)
|
|
94
|
+
|
|
95
|
+
assert.deepStrictEqual(result.skills, [])
|
|
96
|
+
assert.equal(result.total, 0)
|
|
97
|
+
assert.equal(result.page, 5)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('encodes special characters in query', async () => {
|
|
101
|
+
let capturedUrl = ''
|
|
102
|
+
|
|
103
|
+
globalThis.fetch = async (input: RequestInfo | URL) => {
|
|
104
|
+
capturedUrl = String(input)
|
|
105
|
+
return new Response(JSON.stringify({ skills: [], total: 0, page: 1 }), { status: 200 })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { searchClawHub } = await import('./clawhub-client.ts')
|
|
109
|
+
await searchClawHub('a&b=c')
|
|
110
|
+
|
|
111
|
+
assert.ok(capturedUrl.includes('q=a%26b%3Dc'))
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('fetchSkillContent', () => {
|
|
116
|
+
it('fetches raw URL and returns text content', async () => {
|
|
117
|
+
const content = '# Skill README\nHello world'
|
|
118
|
+
|
|
119
|
+
globalThis.fetch = async (input: RequestInfo | URL) => {
|
|
120
|
+
assert.equal(String(input), 'https://example.com/raw/skill.md')
|
|
121
|
+
return new Response(content, { status: 200 })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { fetchSkillContent } = await import('./clawhub-client.ts')
|
|
125
|
+
const result = await fetchSkillContent('https://example.com/raw/skill.md')
|
|
126
|
+
|
|
127
|
+
assert.equal(result, content)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('throws on non-200 response', async () => {
|
|
131
|
+
globalThis.fetch = async () => {
|
|
132
|
+
return new Response('Server Error', { status: 500 })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { fetchSkillContent } = await import('./clawhub-client.ts')
|
|
136
|
+
|
|
137
|
+
await assert.rejects(
|
|
138
|
+
() => fetchSkillContent('https://example.com/raw/fail.md'),
|
|
139
|
+
(err: Error) => {
|
|
140
|
+
assert.ok(err.message.includes('500'))
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('throws on network error', async () => {
|
|
147
|
+
globalThis.fetch = async () => {
|
|
148
|
+
throw new TypeError('fetch failed')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { fetchSkillContent } = await import('./clawhub-client.ts')
|
|
152
|
+
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
() => fetchSkillContent('https://down.example.com/skill.md'),
|
|
155
|
+
(err: Error) => {
|
|
156
|
+
assert.ok(err.message.includes('fetch failed'))
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ClawHubSkill } from '@/types'
|
|
2
|
+
|
|
3
|
+
export interface ClawHubSearchResult {
|
|
4
|
+
skills: ClawHubSkill[]
|
|
5
|
+
total: number
|
|
6
|
+
page: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CLAWHUB_BASE_URL = process.env.CLAWHUB_API_URL || 'https://clawhub.openclaw.dev/api'
|
|
10
|
+
|
|
11
|
+
export async function searchClawHub(query: string, page = 1, limit = 20): Promise<ClawHubSearchResult> {
|
|
12
|
+
try {
|
|
13
|
+
const url = `${CLAWHUB_BASE_URL}/skills?q=${encodeURIComponent(query)}&page=${page}&limit=${limit}`
|
|
14
|
+
const res = await fetch(url)
|
|
15
|
+
if (!res.ok) throw new Error(`ClawHub responded with ${res.status}`)
|
|
16
|
+
return await res.json()
|
|
17
|
+
} catch {
|
|
18
|
+
return { skills: [], total: 0, page }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function fetchSkillContent(rawUrl: string): Promise<string> {
|
|
23
|
+
const res = await fetch(rawUrl)
|
|
24
|
+
if (!res.ok) throw new Error(`Failed to fetch skill content: ${res.status}`)
|
|
25
|
+
return res.text()
|
|
26
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { getPlatform, isNoMessage, formatMediaLine, formatInboundUserText } from './manager.ts'
|
|
4
|
+
import { handleSignalEvent } from './signal.ts'
|
|
5
|
+
import type { PlatformConnector } from './types.ts'
|
|
6
|
+
import type { InboundMessage, InboundMedia } from './types.ts'
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// 1. Connector module resolution (getPlatform)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
describe('getPlatform — connector module resolution', () => {
|
|
12
|
+
const newPlatforms = ['matrix', 'googlechat', 'teams', 'signal'] as const
|
|
13
|
+
|
|
14
|
+
for (const name of newPlatforms) {
|
|
15
|
+
it(`returns a valid module for "${name}"`, async () => {
|
|
16
|
+
const mod = await getPlatform(name)
|
|
17
|
+
assert.ok(mod, `getPlatform("${name}") should return a module`)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it(`"${name}" module has a start function (PlatformConnector)`, async () => {
|
|
21
|
+
const mod: PlatformConnector = await getPlatform(name)
|
|
22
|
+
assert.equal(typeof mod.start, 'function')
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Legacy platforms still resolve
|
|
27
|
+
for (const name of ['discord', 'telegram', 'slack', 'whatsapp', 'openclaw'] as const) {
|
|
28
|
+
it(`resolves legacy platform "${name}"`, async () => {
|
|
29
|
+
const mod = await getPlatform(name)
|
|
30
|
+
assert.equal(typeof mod.start, 'function')
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it('throws on unknown platform', async () => {
|
|
35
|
+
await assert.rejects(() => getPlatform('nonexistent'), {
|
|
36
|
+
message: 'Unknown platform: nonexistent',
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// 2. Signal — handleSignalEvent message parsing
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
describe('handleSignalEvent — Signal stdio message parsing', () => {
|
|
45
|
+
function makeFakeConnector() {
|
|
46
|
+
return {
|
|
47
|
+
id: 'sig-test',
|
|
48
|
+
name: 'Signal Test',
|
|
49
|
+
platform: 'signal',
|
|
50
|
+
agentId: 'agent-1',
|
|
51
|
+
credentialId: null,
|
|
52
|
+
config: { phoneNumber: '+15551234567', signalCliPath: 'signal-cli', signalCliMode: 'http' },
|
|
53
|
+
isEnabled: true,
|
|
54
|
+
status: 'running' as const,
|
|
55
|
+
createdAt: Date.now(),
|
|
56
|
+
updatedAt: Date.now(),
|
|
57
|
+
} as any
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('parses envelope with dataMessage.message field', async () => {
|
|
61
|
+
const received: InboundMessage[] = []
|
|
62
|
+
const connector = makeFakeConnector()
|
|
63
|
+
const event = {
|
|
64
|
+
envelope: {
|
|
65
|
+
source: '+15559876543',
|
|
66
|
+
sourceName: 'Alice',
|
|
67
|
+
dataMessage: { message: 'Hello from Signal' },
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
await handleSignalEvent(event, connector, async (msg) => {
|
|
71
|
+
received.push(msg)
|
|
72
|
+
return 'NO_MESSAGE'
|
|
73
|
+
})
|
|
74
|
+
assert.equal(received.length, 1)
|
|
75
|
+
assert.equal(received[0].text, 'Hello from Signal')
|
|
76
|
+
assert.equal(received[0].senderId, '+15559876543')
|
|
77
|
+
assert.equal(received[0].senderName, 'Alice')
|
|
78
|
+
assert.equal(received[0].platform, 'signal')
|
|
79
|
+
assert.equal(received[0].channelId, '+15559876543') // DM uses sender as channel
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('parses envelope with dataMessage.body field', async () => {
|
|
83
|
+
const received: InboundMessage[] = []
|
|
84
|
+
const connector = makeFakeConnector()
|
|
85
|
+
const event = {
|
|
86
|
+
envelope: {
|
|
87
|
+
source: '+15550001111',
|
|
88
|
+
sourceName: 'Bob',
|
|
89
|
+
dataMessage: { body: 'Body variant' },
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
await handleSignalEvent(event, connector, async (msg) => {
|
|
93
|
+
received.push(msg)
|
|
94
|
+
return 'NO_MESSAGE'
|
|
95
|
+
})
|
|
96
|
+
assert.equal(received.length, 1)
|
|
97
|
+
assert.equal(received[0].text, 'Body variant')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('uses groupId as channelId when present', async () => {
|
|
101
|
+
const received: InboundMessage[] = []
|
|
102
|
+
const connector = makeFakeConnector()
|
|
103
|
+
const event = {
|
|
104
|
+
envelope: {
|
|
105
|
+
source: '+15550001111',
|
|
106
|
+
sourceName: 'Carol',
|
|
107
|
+
dataMessage: {
|
|
108
|
+
message: 'Group msg',
|
|
109
|
+
groupInfo: { groupId: 'grp-abc123' },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
await handleSignalEvent(event, connector, async (msg) => {
|
|
114
|
+
received.push(msg)
|
|
115
|
+
return 'NO_MESSAGE'
|
|
116
|
+
})
|
|
117
|
+
assert.equal(received[0].channelId, 'grp-abc123')
|
|
118
|
+
assert.equal(received[0].channelName, 'group:grp-abc123')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('handles flat event without .envelope wrapper', async () => {
|
|
122
|
+
const received: InboundMessage[] = []
|
|
123
|
+
const connector = makeFakeConnector()
|
|
124
|
+
// signal-cli can emit flat objects (envelope IS the top-level)
|
|
125
|
+
const event = {
|
|
126
|
+
source: '+15552222222',
|
|
127
|
+
sourceName: 'Dave',
|
|
128
|
+
dataMessage: { message: 'Flat format' },
|
|
129
|
+
}
|
|
130
|
+
await handleSignalEvent(event, connector, async (msg) => {
|
|
131
|
+
received.push(msg)
|
|
132
|
+
return 'NO_MESSAGE'
|
|
133
|
+
})
|
|
134
|
+
assert.equal(received.length, 1)
|
|
135
|
+
assert.equal(received[0].text, 'Flat format')
|
|
136
|
+
assert.equal(received[0].senderId, '+15552222222')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('ignores events without dataMessage', async () => {
|
|
140
|
+
const received: InboundMessage[] = []
|
|
141
|
+
const connector = makeFakeConnector()
|
|
142
|
+
// Typing indicator — no dataMessage
|
|
143
|
+
const event = { envelope: { source: '+15551111111', typingMessage: { action: 'STARTED' } } }
|
|
144
|
+
await handleSignalEvent(event, connector, async (msg) => {
|
|
145
|
+
received.push(msg)
|
|
146
|
+
return 'ok'
|
|
147
|
+
})
|
|
148
|
+
assert.equal(received.length, 0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('ignores events where dataMessage has no message or body', async () => {
|
|
152
|
+
const received: InboundMessage[] = []
|
|
153
|
+
const connector = makeFakeConnector()
|
|
154
|
+
// Receipt with empty dataMessage
|
|
155
|
+
const event = { envelope: { source: '+15551111111', dataMessage: {} } }
|
|
156
|
+
await handleSignalEvent(event, connector, async (msg) => {
|
|
157
|
+
received.push(msg)
|
|
158
|
+
return 'ok'
|
|
159
|
+
})
|
|
160
|
+
assert.equal(received.length, 0)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// 3. isNoMessage helper
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
describe('isNoMessage', () => {
|
|
168
|
+
it('matches exact sentinel', () => {
|
|
169
|
+
assert.ok(isNoMessage('NO_MESSAGE'))
|
|
170
|
+
})
|
|
171
|
+
it('matches case-insensitive', () => {
|
|
172
|
+
assert.ok(isNoMessage('no_message'))
|
|
173
|
+
assert.ok(isNoMessage('No_Message'))
|
|
174
|
+
})
|
|
175
|
+
it('trims whitespace', () => {
|
|
176
|
+
assert.ok(isNoMessage(' NO_MESSAGE \n'))
|
|
177
|
+
})
|
|
178
|
+
it('rejects non-sentinel text', () => {
|
|
179
|
+
assert.ok(!isNoMessage('hello'))
|
|
180
|
+
assert.ok(!isNoMessage('NO_MESSAGE extra'))
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// 4. formatMediaLine
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
describe('formatMediaLine', () => {
|
|
188
|
+
it('formats media with URL', () => {
|
|
189
|
+
const media: InboundMedia = { type: 'image', fileName: 'photo.jpg', sizeBytes: 2048, url: '/uploads/photo.jpg' }
|
|
190
|
+
const line = formatMediaLine(media)
|
|
191
|
+
assert.equal(line, '- IMAGE: photo.jpg (2 KB) -> /uploads/photo.jpg')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('formats media without URL', () => {
|
|
195
|
+
const media: InboundMedia = { type: 'document', mimeType: 'application/pdf', sizeBytes: 512 }
|
|
196
|
+
const line = formatMediaLine(media)
|
|
197
|
+
assert.equal(line, '- DOCUMENT: application/pdf (1 KB)')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('falls back to "attachment" when no fileName or mimeType', () => {
|
|
201
|
+
const media: InboundMedia = { type: 'file' }
|
|
202
|
+
const line = formatMediaLine(media)
|
|
203
|
+
assert.equal(line, '- FILE: attachment')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// 5. formatInboundUserText
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
describe('formatInboundUserText', () => {
|
|
211
|
+
it('formats basic text message', () => {
|
|
212
|
+
const msg = { platform: 'signal', channelId: 'ch1', senderId: 's1', senderName: 'Alice', text: 'Hello' } as InboundMessage
|
|
213
|
+
assert.equal(formatInboundUserText(msg), '[Alice] Hello')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('handles empty text with just sender name', () => {
|
|
217
|
+
const msg = { platform: 'signal', channelId: 'ch1', senderId: 's1', senderName: 'Bob', text: '' } as InboundMessage
|
|
218
|
+
assert.equal(formatInboundUserText(msg), '[Bob]')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('appends media lines', () => {
|
|
222
|
+
const msg: InboundMessage = {
|
|
223
|
+
platform: 'signal', channelId: 'ch1', senderId: 's1', senderName: 'Eve', text: 'Check this',
|
|
224
|
+
media: [{ type: 'image', fileName: 'cat.png', url: '/cat.png' }],
|
|
225
|
+
}
|
|
226
|
+
const result = formatInboundUserText(msg)
|
|
227
|
+
assert.ok(result.includes('[Eve] Check this'))
|
|
228
|
+
assert.ok(result.includes('Media received:'))
|
|
229
|
+
assert.ok(result.includes('- IMAGE: cat.png'))
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('truncates media list at 6 with overflow note', () => {
|
|
233
|
+
const media: InboundMedia[] = Array.from({ length: 8 }, (_, i) => ({
|
|
234
|
+
type: 'file' as const, fileName: `f${i}.txt`,
|
|
235
|
+
}))
|
|
236
|
+
const msg: InboundMessage = {
|
|
237
|
+
platform: 'signal', channelId: 'ch1', senderId: 's1', senderName: 'Fran', text: 'files',
|
|
238
|
+
media,
|
|
239
|
+
}
|
|
240
|
+
const result = formatInboundUserText(msg)
|
|
241
|
+
assert.ok(result.includes('...and 2 more attachment(s)'))
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Client, GatewayIntentBits, Events, Partials, AttachmentBuilder } from 'discord.js'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import type { Connector } from '@/types'
|
|
5
|
+
import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
|
|
6
|
+
import { inferInboundMediaType, mimeFromPath, isImageMime } from './media'
|
|
7
|
+
import { isNoMessage } from './manager'
|
|
8
|
+
|
|
9
|
+
const discord: PlatformConnector = {
|
|
10
|
+
async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
|
|
11
|
+
const client = new Client({
|
|
12
|
+
intents: [
|
|
13
|
+
GatewayIntentBits.Guilds,
|
|
14
|
+
GatewayIntentBits.GuildMessages,
|
|
15
|
+
GatewayIntentBits.MessageContent,
|
|
16
|
+
GatewayIntentBits.DirectMessages,
|
|
17
|
+
],
|
|
18
|
+
partials: [Partials.Channel], // Required to receive DM events
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Optional: restrict to specific channels
|
|
22
|
+
const allowedChannels = connector.config.channelIds
|
|
23
|
+
? connector.config.channelIds.split(',').map((s) => s.trim()).filter(Boolean)
|
|
24
|
+
: null
|
|
25
|
+
|
|
26
|
+
client.on(Events.MessageCreate, async (message) => {
|
|
27
|
+
console.log(`[discord] Message from ${message.author.username} in ${message.channel.type === 1 ? 'DM' : '#' + ('name' in message.channel ? (message.channel as any).name : message.channelId)}: ${message.content.slice(0, 80)}`)
|
|
28
|
+
// Ignore bot messages
|
|
29
|
+
if (message.author.bot) return
|
|
30
|
+
|
|
31
|
+
// Filter by allowed channels if configured
|
|
32
|
+
if (allowedChannels && !allowedChannels.includes(message.channelId)) return
|
|
33
|
+
|
|
34
|
+
const attachmentList = Array.from(message.attachments.values())
|
|
35
|
+
const media = attachmentList.map((a) => ({
|
|
36
|
+
type: inferInboundMediaType(a.contentType || undefined, a.name || undefined),
|
|
37
|
+
fileName: a.name || undefined,
|
|
38
|
+
mimeType: a.contentType || undefined,
|
|
39
|
+
sizeBytes: a.size || undefined,
|
|
40
|
+
url: a.url || undefined,
|
|
41
|
+
}))
|
|
42
|
+
const firstImage = media.find((m) => m.type === 'image' && m.url)
|
|
43
|
+
|
|
44
|
+
const inbound: InboundMessage = {
|
|
45
|
+
platform: 'discord',
|
|
46
|
+
channelId: message.channelId,
|
|
47
|
+
channelName: 'name' in message.channel ? (message.channel as any).name : 'DM',
|
|
48
|
+
senderId: message.author.id,
|
|
49
|
+
senderName: message.author.displayName || message.author.username,
|
|
50
|
+
text: message.content || (media.length > 0 ? '(media message)' : ''),
|
|
51
|
+
imageUrl: firstImage?.url,
|
|
52
|
+
media,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Show typing indicator
|
|
57
|
+
await message.channel.sendTyping()
|
|
58
|
+
const response = await onMessage(inbound)
|
|
59
|
+
|
|
60
|
+
if (isNoMessage(response)) return
|
|
61
|
+
|
|
62
|
+
// Discord has a 2000 char limit per message
|
|
63
|
+
if (response.length <= 2000) {
|
|
64
|
+
await message.channel.send(response)
|
|
65
|
+
} else {
|
|
66
|
+
// Split into chunks
|
|
67
|
+
const chunks = response.match(/[\s\S]{1,1990}/g) || [response]
|
|
68
|
+
for (const chunk of chunks) {
|
|
69
|
+
await message.channel.send(chunk)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
console.error(`[discord] Error handling message:`, err.message)
|
|
74
|
+
try {
|
|
75
|
+
await message.reply('Sorry, I encountered an error processing your message.')
|
|
76
|
+
} catch { /* ignore */ }
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
await client.login(botToken)
|
|
81
|
+
console.log(`[discord] Bot logged in as ${client.user?.tag}`)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
connector,
|
|
85
|
+
async sendMessage(channelId, text, options) {
|
|
86
|
+
const channel = await client.channels.fetch(channelId)
|
|
87
|
+
if (!channel || !('send' in channel) || typeof (channel as any).send !== 'function') {
|
|
88
|
+
throw new Error(`Cannot send to channel ${channelId}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const files: AttachmentBuilder[] = []
|
|
92
|
+
if (options?.mediaPath) {
|
|
93
|
+
if (!fs.existsSync(options.mediaPath)) throw new Error(`File not found: ${options.mediaPath}`)
|
|
94
|
+
files.push(new AttachmentBuilder(options.mediaPath, { name: options.fileName || path.basename(options.mediaPath) }))
|
|
95
|
+
} else if (options?.imageUrl) {
|
|
96
|
+
files.push(new AttachmentBuilder(options.imageUrl, { name: options.fileName || 'image.png' }))
|
|
97
|
+
} else if (options?.fileUrl) {
|
|
98
|
+
files.push(new AttachmentBuilder(options.fileUrl, { name: options.fileName || 'attachment' }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const content = options?.caption || text || undefined
|
|
102
|
+
const msg = await (channel as any).send({
|
|
103
|
+
content: content || (files.length ? undefined : '(empty)'),
|
|
104
|
+
files: files.length ? files : undefined,
|
|
105
|
+
})
|
|
106
|
+
return { messageId: msg.id }
|
|
107
|
+
},
|
|
108
|
+
async stop() {
|
|
109
|
+
client.destroy()
|
|
110
|
+
console.log(`[discord] Bot disconnected`)
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default discord
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { PlatformConnector, ConnectorInstance } from './types'
|
|
2
|
+
|
|
3
|
+
const googlechat: PlatformConnector = {
|
|
4
|
+
async start(connector, botToken, _onMessage): Promise<ConnectorInstance> {
|
|
5
|
+
const pkg = 'googleapis'
|
|
6
|
+
const { google } = await import(/* webpackIgnore: true */ pkg)
|
|
7
|
+
|
|
8
|
+
// Parse service account credentials from botToken
|
|
9
|
+
let credentials: any
|
|
10
|
+
try {
|
|
11
|
+
credentials = JSON.parse(botToken)
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error('botToken must be a valid JSON service account key')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const auth = new google.auth.GoogleAuth({
|
|
17
|
+
credentials,
|
|
18
|
+
scopes: ['https://www.googleapis.com/auth/chat.bot'],
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const chat = google.chat({ version: 'v1', auth })
|
|
22
|
+
|
|
23
|
+
// Optional: restrict to specific spaces
|
|
24
|
+
const allowedSpaces = connector.config.spaceIds
|
|
25
|
+
? connector.config.spaceIds.split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
26
|
+
: null
|
|
27
|
+
|
|
28
|
+
// Google Chat requires a webhook or Pub/Sub for real-time inbound messages.
|
|
29
|
+
// This connector supports outbound messaging. For inbound messages, configure
|
|
30
|
+
// a webhook endpoint at /api/connectors/[id]/webhook that POSTs events here.
|
|
31
|
+
// Polling is not supported by the Google Chat API for bot messages.
|
|
32
|
+
let stopped = false
|
|
33
|
+
|
|
34
|
+
console.log(`[googlechat] Bot authenticated via service account`)
|
|
35
|
+
if (allowedSpaces) {
|
|
36
|
+
console.log(`[googlechat] Filtering to spaces: ${allowedSpaces.join(', ')}`)
|
|
37
|
+
}
|
|
38
|
+
console.log(`[googlechat] Note: Inbound messages require a webhook or Pub/Sub subscription. This connector supports outbound sends.`)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
connector,
|
|
42
|
+
async sendMessage(channelId, text) {
|
|
43
|
+
if (stopped) throw new Error('Connector is stopped')
|
|
44
|
+
|
|
45
|
+
// channelId should be a space name like "spaces/AAAA"
|
|
46
|
+
const parent = channelId.startsWith('spaces/') ? channelId : `spaces/${channelId}`
|
|
47
|
+
|
|
48
|
+
if (allowedSpaces && !allowedSpaces.some((s) => parent.includes(s))) {
|
|
49
|
+
throw new Error(`Space ${parent} not in allowed spaceIds`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const res = await chat.spaces.messages.create({
|
|
53
|
+
parent,
|
|
54
|
+
requestBody: { text },
|
|
55
|
+
})
|
|
56
|
+
return { messageId: res.data.name || undefined }
|
|
57
|
+
},
|
|
58
|
+
async stop() {
|
|
59
|
+
stopped = true
|
|
60
|
+
console.log(`[googlechat] Bot disconnected`)
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default googlechat
|