openclaw-channel-basicops 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/DESIGN.md ADDED
@@ -0,0 +1,374 @@
1
+ # BasicOps ↔ OpenClaw Messaging Channel — Design / Spec (Draft)
2
+
3
+ Status: draft (pre-coding)
4
+
5
+ This document specifies the architecture and implementation plan for a custom **OpenClaw channel plugin** that connects **BasicOps Messaging (all message containers via MCP/API)** to OpenClaw as a first-class messaging surface.
6
+
7
+ Key requirement (Justin): **The agent should be able to receive and respond when @mentioned on ANY BasicOps message type.**
8
+
9
+ ## 1) Goals
10
+
11
+ - Add **BasicOps as an OpenClaw messaging channel** (like Telegram/WhatsApp).
12
+ - Support **outbound** messages: OpenClaw → BasicOps (post a message into the chosen BasicOps thread).
13
+ - Support **inbound** messages: BasicOps → OpenClaw (webhook events become inbound messages in the correct OpenClaw session).
14
+ - Provide a clear **identity + conversation mapping** strategy.
15
+ - Keep secrets safe (API tokens, webhook secrets), and validate webhook authenticity.
16
+
17
+ ## 2) Non-goals (initially)
18
+
19
+ - Full parity with rich chat apps (typing indicators, read receipts, edits/deletes).
20
+ - Perfect threading semantics across all BasicOps entities.
21
+ - Complex file upload and media transformations (phase later).
22
+
23
+ ## 3) Key product decision: “What is a conversation?”
24
+
25
+ ### 3.1 Reality check: BasicOps now has real Messaging (via MCP/API)
26
+
27
+ BasicOps now supports multiple **message containers** (not just notes/comments). We will treat each container as a first-class conversation surface and normalize them into OpenClaw sessions.
28
+
29
+ From the BasicOps MCP server, the relevant messaging tools include:
30
+
31
+ - Project messages: `list_messages_in_project`, `create_message_in_project`
32
+ - Task messages: `list_messages_in_task`, `create_message_in_task`
33
+ - Channels: `list_messages_in_channel`, `create_message_in_channel`
34
+ - Chats (1:1 or generic chats): `list_messages_in_chat`, `create_message_in_chat`
35
+ - Groupchats: `list_messages_in_groupchat`, `create_message_in_groupchat`
36
+
37
+ Supporting tools (needed for richer behaviors / future phases):
38
+ - Message CRUD: `get_message`, `update_message`, `delete_message`
39
+ - Replies: `create_reply_in_message`, `list_replies_in_message`, `update_reply`, `delete_reply`
40
+ - Reactions: `toggle_reaction_in_message`, `list_reactions_in_message`, `toggle_reaction_in_reply`, `list_reactions_in_reply`
41
+
42
+ ### 3.2 Normalized conversation key
43
+
44
+ Define a stable normalized key:
45
+
46
+ - `project:<projectId>`
47
+ - `task:<taskId>`
48
+ - `channel:<channelId>`
49
+ - `chat:<chatId>`
50
+ - `groupchat:<groupchatId>`
51
+
52
+ This becomes the “target” in OpenClaw and the lookup key for session mapping.
53
+
54
+ ### 3.3 Mention-driven routing (hard requirement)
55
+
56
+ Mention semantics:
57
+ - A mention is `@<username>` in BasicOps.
58
+
59
+ Policy:
60
+ - The connector should **only invoke/route to the agent when the agent is mentioned** (configurable).
61
+ - The connector should still be able to fetch recent context in the thread (e.g., last N messages) before responding.
62
+
63
+ ## 4) OpenClaw integration surface
64
+
65
+ ### 4.1 Plugin type
66
+
67
+ - Implement as an OpenClaw **channel plugin** registered via:
68
+ - `api.registerChannel({ plugin })`
69
+
70
+ Reference: OpenClaw docs `docs/tools/plugin.md` → “Register a messaging channel”.
71
+
72
+ ### 4.2 Config location + shape
73
+
74
+ OpenClaw expects channel config under `channels.<id>` (not `plugins.entries`).
75
+
76
+ Proposed config:
77
+
78
+ ```json5
79
+ {
80
+ channels: {
81
+ basicops: {
82
+ accounts: {
83
+ default: {
84
+ enabled: true,
85
+
86
+ // REST (optional) + MCP (preferred)
87
+ apiBaseUrl: "https://api.basicops.com",
88
+ apiToken: "...",
89
+
90
+ // MCP settings (supplemental: context fetch, message send, recovery)
91
+ mcp: {
92
+ transport: "sse",
93
+ baseUrl: "https://app.basicops.com/mcp/sse",
94
+ // auth handled by OpenClaw secrets/persona integration
95
+ persona: "justin"
96
+ },
97
+
98
+ // Webhooks (required for Telegram-like responsiveness)
99
+ webhooks: {
100
+ enabled: true,
101
+ publicUrl: "https://<gateway-public>/channels/basicops/webhook/<pathToken>",
102
+ secret: "...", // optional if BasicOps does not sign; still useful for our own token
103
+ // BasicOps event types (observed via MCP list_webhooks)
104
+ eventTypes: [
105
+ "basicops.task.message.created",
106
+ "basicops.project.message.created",
107
+ "basicops.chat.message.created",
108
+ "basicops.groupchat.message.created",
109
+ "basicops.channel.message.created",
110
+ "basicops.task.reply.created",
111
+ "basicops.project.reply.created",
112
+ "basicops.chat.reply.created",
113
+ "basicops.groupchat.reply.created",
114
+ "basicops.channel.reply.created"
115
+ ]
116
+ },
117
+
118
+ // Mention policy
119
+ agentUsername: "Gus", // the BasicOps username to match after '@'
120
+ respondOnlyWhenMentioned: true,
121
+ mentionRegexOverride: null, // optional
122
+
123
+ // What containers to listen to
124
+ listen: {
125
+ project: true,
126
+ task: true,
127
+ channel: true,
128
+ chat: true,
129
+ groupchat: true
130
+ },
131
+
132
+ // Context fetching
133
+ context: {
134
+ fetchLastNMessages: 20
135
+ },
136
+
137
+ // optional: mapping store
138
+ mapping: {
139
+ provider: "sqlite" | "json",
140
+ path: "..."
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ Notes:
150
+ - `accounts.<id>` pattern matches OpenClaw’s multi-account model.
151
+ - `conversationMode` is required for correctness unless we infer from targets.
152
+
153
+ ### 4.3 Channel metadata
154
+
155
+ ```ts
156
+ meta: {
157
+ id: "basicops",
158
+ label: "BasicOps",
159
+ selectionLabel: "BasicOps Messaging",
160
+ docsPath: "/channels/basicops",
161
+ blurb: "BasicOps messaging connector",
162
+ aliases: ["bo", "basicops-messaging"],
163
+ }
164
+ ```
165
+
166
+ ## 5) High-level architecture
167
+
168
+ **Inbound requirement:** This connector MUST be **push-based** (webhooks) for near-real-time responsiveness (Telegram-like). Polling is not an acceptable primary mechanism; it is only a last-resort fallback for specific edge cases (e.g., missed events, replay/backfill).
169
+
170
+ ### 5.1 Components
171
+
172
+ 1) **OpenClaw Channel Plugin (this repo)**
173
+ - Outbound adapter(s): `sendText` (and later replies/reactions/attachments)
174
+ - Inbound adapter: **MCP event ingest** (preferred) and/or webhook receiver
175
+ - Mention router: detect `@agentUsername` and decide whether to forward to agent
176
+ - Routing/mapping: BasicOps conversation ↔ OpenClaw session
177
+
178
+ 2) **BasicOps MCP server (preferred for messaging)**
179
+ - Tooling to list/create messages in:
180
+ - projects, tasks, channels, chats, groupchats
181
+ - Tooling to read/update/delete messages + replies + reactions
182
+
183
+ 3) **BasicOps REST API (optional / supplemental)**
184
+ - Used when MCP lacks an operation, or for identity enrichment if needed.
185
+
186
+ 4) **Mapping Store**
187
+ - Persist mapping between:
188
+ - `basicopsConversationKey` ↔ `openclawSessionKey`
189
+ - `basicopsUserId` ↔ sender metadata
190
+ - optional: dedupe keys for inbound events
191
+
192
+ ### 5.2 Data flow
193
+
194
+ **Outbound (OpenClaw → BasicOps)**
195
+ 1. Agent produces `message.send(channel="basicops", target=..., message=...)`.
196
+ 2. OpenClaw calls channel plugin `outbound.sendText({ account, target, text })`.
197
+ 3. Plugin resolves `target` into a BasicOps destination:
198
+ - projectId/taskId or conversationId
199
+ 4. Plugin POSTs to the appropriate BasicOps endpoint to create a note/comment/message.
200
+ 5. Plugin returns `{ ok: true }` or `{ ok: false, error }`.
201
+
202
+ **Inbound (BasicOps → OpenClaw)**
203
+
204
+ Primary path (Webhooks; required):
205
+ 1. BasicOps emits webhook payloads to the connector’s public URL.
206
+ 2. **Payload shape (from existing Webhooks Shim MVP):** BasicOps may batch events:
207
+ - top-level `body.events[]`
208
+ - each event has `{ event: string, records: any[] }`
209
+
210
+ **Observed messaging event names (via MCP `list_webhooks`):**
211
+ - `basicops.task.message.created`
212
+ - `basicops.project.message.created`
213
+ - `basicops.chat.message.created`
214
+ - `basicops.groupchat.message.created`
215
+ - `basicops.channel.message.created`
216
+ - replies:
217
+ - `basicops.task.reply.created`
218
+ - `basicops.project.reply.created`
219
+ - `basicops.chat.reply.created`
220
+ - `basicops.groupchat.reply.created`
221
+ - `basicops.channel.reply.created`
222
+
223
+ (The older shim example also shows non-message events like `basicops:task:created`.)
224
+ 3. Validate authenticity.
225
+ - If BasicOps provides signatures: validate HMAC + timestamp/replay.
226
+ - If BasicOps does **not** provide signatures (current shim does not verify any): use an unguessable URL path token + optional allowlist + strict JSON validation + dedupe.
227
+ 4. Normalize each record into a common message model:
228
+ - `containerType` (project/task/channel/chat/groupchat)
229
+ - `containerId`
230
+ - `messageId`
231
+ - `sender` (userId + display)
232
+ - `text` (and later attachments)
233
+ 5. Detect mention:
234
+ - if `respondOnlyWhenMentioned=true`, only forward messages containing `@<agentUsername>`.
235
+ 6. Find/create the OpenClaw session key for `containerType:containerId`.
236
+ 7. Forward into that OpenClaw session with sender metadata.
237
+
238
+ Fallback/backfill (allowed but not primary):
239
+ - MCP can be used to backfill context, resolve missing sender names, or recover from missed webhooks.
240
+ - Short-interval polling is only acceptable for recovery or diagnostics, not as the main inbound mechanism.
241
+
242
+ ## 6) Routing + identity mapping
243
+
244
+ ### 6.1 Conversation mapping
245
+
246
+ We need stable keys:
247
+
248
+ - `basicopsConversationKey` (string):
249
+ - `project:<projectId>` OR `task:<taskId>`
250
+ - `openclawSessionKey`:
251
+ - Prefer deterministic mapping like: `basicops:<accountId>:<basicopsConversationKey>`
252
+ - Or use OpenClaw session creation + store the mapping.
253
+
254
+ **Recommendation:** deterministic session keys when OpenClaw allows it; otherwise persist mapping.
255
+
256
+ ### 6.2 User identity
257
+
258
+ Map BasicOps users into OpenClaw sender metadata:
259
+
260
+ - `sender.provider = "basicops"`
261
+ - `sender.id = <basicopsUserId>`
262
+ - `sender.label/name = <displayName>`
263
+
264
+ If webhook payload does not include display name, fetch `/v1/user/<id>` and cache.
265
+
266
+ ## 7) Webhook security
267
+
268
+ Target: Telegram-like responsiveness with production-grade verification.
269
+
270
+ Reality (per existing Webhooks Shim MVP): current shim code does **not** verify signatures, implying BasicOps webhooks may not currently ship an HMAC signature header.
271
+
272
+ Therefore the spec supports two modes:
273
+
274
+ 1) **Signed webhooks (ideal, if BasicOps provides it)**
275
+ - Require a `webhooks.secret`.
276
+ - Validate request signature (e.g., HMAC SHA256 of raw body) + timestamp/replay.
277
+
278
+ 2) **Unsigned webhooks (acceptable fallback)**
279
+ - Use an unguessable path token, e.g. `/channels/basicops/webhook/<random>`.
280
+ - Optionally add IP allowlisting at the reverse proxy.
281
+ - Strict JSON schema validation.
282
+ - Deduplicate by event id / hash.
283
+
284
+ Reference MVP:
285
+ - Receiver: `basicops-ops/scripts/webhook-receiver.mjs`
286
+ - Example webhook creation: `basicops-ops/scripts/create-project-tag-webhook.mjs`
287
+
288
+ ## 8) Reliability & retries
289
+
290
+ ### 8.1 Outbound
291
+ - Retry on transient failures (429/5xx) with backoff.
292
+ - Log request ids / BasicOps response ids.
293
+ - Return actionable error strings (status + body snippet).
294
+
295
+ ### 8.2 Inbound
296
+ - Acknowledge webhooks quickly (2xx) and process asynchronously if supported.
297
+ - Deduplicate events if BasicOps sends retries:
298
+ - Keep a small cache of event ids/hashes.
299
+
300
+ ## 9) Observability
301
+
302
+ - Add `status` adapter (optional) to surface:
303
+ - token validity check
304
+ - webhook configured state
305
+ - last inbound event time
306
+ - Structured logs:
307
+ - `accountId`, `conversationKey`, `basicopsEventId`
308
+
309
+ ## 10) OpenClaw “what changed since Feb 15?” (investigation)
310
+
311
+ Local OpenClaw version on this host:
312
+ - **OpenClaw 2026.3.13 (61d171a)**
313
+
314
+ Key plugin/channel API notes from current `docs/tools/plugin.md`:
315
+ - Channel plugins still register via `api.registerChannel({ plugin })`.
316
+ - Config remains under `channels.<id>`.
317
+ - **Newer/clarified capabilities** since earlier docs:
318
+ - `meta.preferOver`, `meta.detailLabel`, `meta.systemImage` (richer UI metadata)
319
+ - **Channel onboarding hooks** are documented and appear to be first-class now:
320
+ - `plugin.onboarding.configure`, `configureInteractive`, `configureWhenConfigured`
321
+ - These can be used to guide users through token + webhook setup.
322
+
323
+ No breaking change was observed in the minimal channel registration pattern; however, the onboarding hooks are worth using to reduce setup friction.
324
+
325
+ (If you want a stricter diff vs Feb 15, we can compare the OpenClaw npm package changelog or git tags between those dates—this host only shows the currently installed docs.)
326
+
327
+ ## 11) Implementation plan (phased)
328
+
329
+ ### Phase 0 — Confirm webhook event schema + mention semantics
330
+ - Confirm BasicOps webhook events for **message created** across all containers.
331
+ - Confirm the BasicOps username to treat as the agent mention target (e.g., `@Gus`).
332
+ - Collect:
333
+ - example webhook payloads for each message container (project/task/channel/chat/groupchat)
334
+ - signature scheme (headers, algorithm, timestamp/replay)
335
+ - how to fetch “recent context” in-thread via MCP (tool args + paging)
336
+
337
+ ### Phase 1 — Outbound-only MVP
338
+ - Replace placeholder `/v1/messages/send` with real endpoint(s).
339
+ - Implement:
340
+ - input validation
341
+ - request building
342
+ - error handling
343
+ - Add unit tests for payload mapping.
344
+
345
+ ### Phase 2 — Inbound webhook
346
+ - Add webhook handler entrypoint (OpenClaw gateway route / channel inbound adapter depending on SDK expectations).
347
+ - Implement signature verification.
348
+ - Implement mapping store (sqlite recommended).
349
+ - Route inbound events into sessions.
350
+
351
+ ### Phase 3 — Quality
352
+ - Deduplication
353
+ - richer metadata (sender name, links back to BasicOps)
354
+ - health/status commands
355
+
356
+ ### Phase 4 — Rich features
357
+ - attachments (download/thumbnail endpoints exist for tasks)
358
+ - reactions (if supported)
359
+ - edits/deletes
360
+
361
+ ## 12) Open questions / required inputs
362
+
363
+ 1) What is “messaging” in BasicOps for this connector?
364
+ - project notes, task comments, or separate messaging API?
365
+ 2) What is the webhook payload schema + signature scheme?
366
+ 3) Do we need multi-workspace support (multiple BasicOps orgs) per OpenClaw account?
367
+ 4) What should `target` look like from OpenClaw?
368
+ - raw id (`123`), or typed (`project:123`)?
369
+
370
+ ---
371
+
372
+ Appendix: Scaffold location
373
+ - `/home/gus/.openclaw/workspace/basicops-openclaw-channel/`
374
+ - `src/index.ts` registers channel + has outbound stub (currently placeholder endpoint).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # openclaw-channel-basicops
2
+
3
+ BasicOps messaging channel connector for OpenClaw.
4
+
5
+ - **Inbound:** BasicOps Webhooks (push-based, Telegram-like responsiveness)
6
+ - **Outbound:** BasicOps MCP tools (create message in project/task/channel/chat/groupchat)
7
+ - **Agent triggering:** mention-gated (`@<agentUsername>`) by default
8
+
9
+ ## Install (npm)
10
+
11
+ ```bash
12
+ npm i openclaw-channel-basicops
13
+ ```
14
+
15
+ ## Enable in OpenClaw
16
+
17
+ ### 1) Load the plugin
18
+
19
+ Add to your OpenClaw config:
20
+
21
+ ```json5
22
+ {
23
+ plugins: {
24
+ load: {
25
+ paths: [
26
+ // if installed into the same environment as the gateway
27
+ "./node_modules/openclaw-channel-basicops/dist/index.js"
28
+ ]
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ ### 2) Configure the channel
35
+
36
+ ```json5
37
+ {
38
+ channels: {
39
+ basicops: {
40
+ accounts: {
41
+ default: {
42
+ enabled: true,
43
+
44
+ // BasicOps MCP (MVP): paste an OAuth access token
45
+ mcp: {
46
+ accessToken: "...",
47
+ baseUrl: "https://app.basicops.com/mcp/sse"
48
+ },
49
+
50
+ // Mention routing
51
+ agentUsername: "Gus",
52
+ respondOnlyWhenMentioned: true,
53
+
54
+ // Webhook auth (recommended): unguessable path token
55
+ webhooks: {
56
+ enabled: true,
57
+ pathToken: "<random-long-token>"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Configure BasicOps Webhooks
67
+
68
+ Point BasicOps webhooks at:
69
+
70
+ - `https://<your-gateway-public>/channels/basicops/webhook/<pathToken>`
71
+
72
+ Subscribe to these event types (observed via MCP `list_webhooks`):
73
+
74
+ - `basicops.task.message.created`
75
+ - `basicops.project.message.created`
76
+ - `basicops.chat.message.created`
77
+ - `basicops.groupchat.message.created`
78
+ - `basicops.channel.message.created`
79
+ - `basicops.task.reply.created`
80
+ - `basicops.project.reply.created`
81
+ - `basicops.chat.reply.created`
82
+ - `basicops.groupchat.reply.created`
83
+ - `basicops.channel.reply.created`
84
+
85
+ ## Sending messages from OpenClaw
86
+
87
+ Use typed targets:
88
+
89
+ - `task:<id>`
90
+ - `project:<id>`
91
+ - `channel:<id>`
92
+ - `chat:<id>`
93
+ - `groupchat:<id>`
94
+
95
+ Example (tool call semantics vary by your agent tooling):
96
+
97
+ - send to a task thread: `target="task:2103656"`
98
+
99
+ ## Notes / Roadmap
100
+
101
+ - OAuth onboarding (no more pasted tokens)
102
+ - Signature verification if/when BasicOps provides webhook signatures
103
+ - Rich features: attachments, reactions, edits/deletes
@@ -0,0 +1,108 @@
1
+ // Minimal BasicOps MCP-over-SSE client (ported from skills/basicops-mcp/scripts/basicops_mcp.py)
2
+ //
3
+ // Notes:
4
+ // - This intentionally keeps things simple: each tool call performs an SSE handshake
5
+ // to obtain a message endpoint, then POSTs a single JSON-RPC request.
6
+ // - For production we may want connection reuse, retries, and token refresh.
7
+ const DEFAULT_SSE_URL = "https://app.basicops.com/mcp/sse";
8
+ const DEFAULT_PROTOCOL_VERSION = "2024-11-05";
9
+ async function handshake(opts) {
10
+ const initPayload = {
11
+ jsonrpc: "2.0",
12
+ id: 1,
13
+ method: "initialize",
14
+ params: {
15
+ protocolVersion: opts.protocolVersion,
16
+ capabilities: {},
17
+ clientInfo: { name: "OpenClaw", version: "0.1" },
18
+ },
19
+ };
20
+ const res = await fetch(opts.baseUrl, {
21
+ method: "POST",
22
+ headers: {
23
+ Authorization: `Bearer ${opts.accessToken}`,
24
+ Accept: "text/event-stream",
25
+ "Content-Type": "application/json",
26
+ "Cache-Control": "no-cache",
27
+ },
28
+ body: JSON.stringify(initPayload),
29
+ });
30
+ if (!res.ok || !res.body) {
31
+ const body = await res.text().catch(() => "");
32
+ throw new Error(`MCP handshake failed (${res.status}): ${body}`);
33
+ }
34
+ // Read SSE lines until we see the first `data: ...` line (endpoint)
35
+ const reader = res.body.getReader();
36
+ const decoder = new TextDecoder();
37
+ let buf = "";
38
+ const deadline = Date.now() + 20_000;
39
+ while (Date.now() < deadline) {
40
+ const { value, done } = await reader.read();
41
+ if (done)
42
+ break;
43
+ buf += decoder.decode(value, { stream: true });
44
+ // SSE lines end with \n. We'll parse line-by-line.
45
+ let idx;
46
+ while ((idx = buf.indexOf("\n")) !== -1) {
47
+ const line = buf.slice(0, idx).trimEnd();
48
+ buf = buf.slice(idx + 1);
49
+ if (line.startsWith("data: ")) {
50
+ let endpoint = line.slice("data: ".length).trim();
51
+ if (!endpoint)
52
+ continue;
53
+ if (endpoint.startsWith("/"))
54
+ endpoint = `https://app.basicops.com${endpoint}`;
55
+ return endpoint;
56
+ }
57
+ }
58
+ }
59
+ throw new Error("MCP handshake failed: no endpoint returned");
60
+ }
61
+ export async function callMcpTool(opts) {
62
+ const baseUrl = opts.mcp.baseUrl || DEFAULT_SSE_URL;
63
+ const accessToken = opts.mcp.accessToken;
64
+ const protocolVersion = opts.mcp.protocolVersion || DEFAULT_PROTOCOL_VERSION;
65
+ if (!accessToken) {
66
+ throw new Error("Missing BasicOps MCP access token (channels.basicops.accounts.<id>.mcp.accessToken)");
67
+ }
68
+ const endpoint = await handshake({ baseUrl, accessToken, protocolVersion });
69
+ const payload = {
70
+ jsonrpc: "2.0",
71
+ id: opts.requestId ?? 2,
72
+ method: "tools/call",
73
+ params: {
74
+ name: opts.tool,
75
+ arguments: opts.args ?? {},
76
+ },
77
+ };
78
+ const res = await fetch(endpoint, {
79
+ method: "POST",
80
+ headers: {
81
+ Authorization: `Bearer ${accessToken}`,
82
+ Accept: "application/json",
83
+ "Content-Type": "application/json",
84
+ },
85
+ body: JSON.stringify(payload),
86
+ });
87
+ const raw = await res.text().catch(() => "");
88
+ if (!res.ok) {
89
+ throw new Error(`MCP tool call failed (${res.status}): ${raw}`);
90
+ }
91
+ let json;
92
+ try {
93
+ json = raw ? JSON.parse(raw) : {};
94
+ }
95
+ catch {
96
+ throw new Error(`MCP tool call returned non-JSON: ${raw.slice(0, 400)}`);
97
+ }
98
+ // BasicOps MCP may return { result: { isError: true, content: [...] } }
99
+ const isError = Boolean(json?.result?.isError);
100
+ if (isError) {
101
+ const content = json?.result?.content;
102
+ const text = Array.isArray(content)
103
+ ? content.map((c) => (c && typeof c === "object" ? c.text : "")).join(" ")
104
+ : "";
105
+ throw new Error(`MCP tool error: ${text || "(unknown)"}`);
106
+ }
107
+ return json;
108
+ }
package/dist/index.js ADDED
@@ -0,0 +1,272 @@
1
+ import { callMcpTool } from "./basicops-mcp.js";
2
+ function listAccountIds(cfg) {
3
+ return Object.keys(cfg?.channels?.basicops?.accounts ?? {});
4
+ }
5
+ function resolveAccount(cfg, accountId) {
6
+ const resolved = cfg?.channels?.basicops?.accounts?.[accountId ?? "default"];
7
+ const account = resolved ?? { accountId };
8
+ // Minimal configured heuristic
9
+ account.configured = Boolean(account?.enabled && (account?.mcp?.accessToken || account?.apiToken));
10
+ return account;
11
+ }
12
+ function normalizeTarget(raw) {
13
+ const s = String(raw || "").trim();
14
+ const m = s.match(/^(project|task|channel|chat|groupchat)\s*:\s*(.+)$/i);
15
+ if (m)
16
+ return { containerType: m[1].toLowerCase(), containerId: String(m[2]).trim() };
17
+ // If not typed, assume task id (common) — caller should prefer typed targets.
18
+ return { containerType: "task", containerId: s };
19
+ }
20
+ function buildMentionRegex(agentUsername) {
21
+ // Match @Username with a loose boundary so @Gus, @Gus, etc.
22
+ // We intentionally do not enforce word boundaries too strictly because some UIs
23
+ // include punctuation.
24
+ const escaped = agentUsername.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ return new RegExp(`(^|\\s)@${escaped}(\\b|$)`, "i");
26
+ }
27
+ function extractText(record) {
28
+ // Best-effort: BasicOps record shapes may vary per container.
29
+ return (record?.message ??
30
+ record?.text ??
31
+ record?.body ??
32
+ record?.content ??
33
+ record?.Message ??
34
+ record?.Text ??
35
+ "").toString();
36
+ }
37
+ function extractSender(record) {
38
+ const senderId = (record?.UserId ??
39
+ record?.userId ??
40
+ record?.SenderId ??
41
+ record?.senderId ??
42
+ record?.FromUserId ??
43
+ record?.fromUserId ??
44
+ "unknown").toString();
45
+ const senderLabel = (record?.UserName ??
46
+ record?.username ??
47
+ record?.SenderName ??
48
+ record?.senderName ??
49
+ record?.FromUserName ??
50
+ record?.fromUserName ??
51
+ undefined);
52
+ return { senderId, senderLabel };
53
+ }
54
+ function extractContainerFromEvent(eventName, record) {
55
+ const e = String(eventName || "").toLowerCase();
56
+ if (e.includes("task.")) {
57
+ const id = record?.TaskId ?? record?.taskId ?? record?.TaskID ?? record?.taskID;
58
+ return { containerType: "task", containerId: String(id ?? record?.Task ?? record?.task ?? "") };
59
+ }
60
+ if (e.includes("project.")) {
61
+ const id = record?.ProjectId ?? record?.projectId ?? record?.ProjectID ?? record?.projectID;
62
+ return { containerType: "project", containerId: String(id ?? record?.Project ?? record?.project ?? "") };
63
+ }
64
+ if (e.includes("groupchat.")) {
65
+ const id = record?.GroupchatId ?? record?.groupchatId ?? record?.GroupChatId ?? record?.groupChatId;
66
+ return { containerType: "groupchat", containerId: String(id ?? "") };
67
+ }
68
+ if (e.includes("channel.")) {
69
+ const id = record?.ChannelId ?? record?.channelId;
70
+ return { containerType: "channel", containerId: String(id ?? "") };
71
+ }
72
+ if (e.includes("chat.")) {
73
+ const id = record?.ChatId ?? record?.chatId;
74
+ return { containerType: "chat", containerId: String(id ?? "") };
75
+ }
76
+ // Fallback: try obvious keys
77
+ for (const k of ["taskId", "TaskId", "projectId", "ProjectId", "chatId", "ChatId", "groupchatId", "GroupchatId", "channelId", "ChannelId"]) {
78
+ if (record?.[k] != null) {
79
+ const v = String(record[k]);
80
+ const type = k.toLowerCase().includes("task")
81
+ ? "task"
82
+ : k.toLowerCase().includes("project")
83
+ ? "project"
84
+ : k.toLowerCase().includes("group")
85
+ ? "groupchat"
86
+ : k.toLowerCase().includes("channel")
87
+ ? "channel"
88
+ : "chat";
89
+ return { containerType: type, containerId: v };
90
+ }
91
+ }
92
+ return { containerType: "task", containerId: "" };
93
+ }
94
+ async function sendViaMcp(opts) {
95
+ const { account, target, text } = opts;
96
+ const mcp = account?.mcp;
97
+ if (!mcp?.accessToken) {
98
+ return { ok: false, error: "Missing channels.basicops.accounts.<id>.mcp.accessToken" };
99
+ }
100
+ const { containerType, containerId } = normalizeTarget(target);
101
+ const toolByType = {
102
+ project: "create_message_in_project",
103
+ task: "create_message_in_task",
104
+ channel: "create_message_in_channel",
105
+ chat: "create_message_in_chat",
106
+ groupchat: "create_message_in_groupchat",
107
+ };
108
+ const tool = toolByType[containerType];
109
+ if (!tool) {
110
+ return { ok: false, error: `Unsupported BasicOps target container: ${containerType}` };
111
+ }
112
+ // NOTE: BasicOps tools use different arg names; we standardize to id + message.
113
+ // We learned at least one schema expectation: create_message_in_chat expects `message` not `text`.
114
+ const idKeyByType = {
115
+ project: "projectId",
116
+ task: "taskId",
117
+ channel: "channelId",
118
+ chat: "chatId",
119
+ groupchat: "groupchatId",
120
+ };
121
+ const args = {
122
+ [idKeyByType[containerType]]: containerId,
123
+ message: text,
124
+ };
125
+ await callMcpTool({ mcp, tool, args });
126
+ return { ok: true };
127
+ }
128
+ const basicopsChannel = {
129
+ id: "basicops",
130
+ meta: {
131
+ id: "basicops",
132
+ label: "BasicOps",
133
+ selectionLabel: "BasicOps Messaging",
134
+ docsPath: "/channels/basicops",
135
+ blurb: "BasicOps messaging connector",
136
+ aliases: ["bo", "basicops-messaging"],
137
+ },
138
+ capabilities: {
139
+ chatTypes: ["direct", "group"],
140
+ },
141
+ reload: { configPrefixes: ["channels.basicops"] },
142
+ config: {
143
+ listAccountIds,
144
+ resolveAccount,
145
+ // Helps status/doctor flows avoid materializing secrets
146
+ inspectAccount: (cfg, accountId) => {
147
+ const a = resolveAccount(cfg, accountId);
148
+ return {
149
+ accountId: a.accountId ?? accountId ?? "default",
150
+ enabled: Boolean(a.enabled),
151
+ configured: Boolean(a.configured),
152
+ hasMcpToken: Boolean(a?.mcp?.accessToken),
153
+ hasApiToken: Boolean(a?.apiToken),
154
+ };
155
+ },
156
+ },
157
+ messaging: {
158
+ normalizeTarget: (target) => {
159
+ const { containerType, containerId } = normalizeTarget(target);
160
+ return `${containerType}:${containerId}`;
161
+ },
162
+ targetResolver: {
163
+ looksLikeId: (input) => /^(project|task|channel|chat|groupchat)\s*:\s*\S+$/i.test(input.trim()),
164
+ hint: "project:<id> | task:<id> | channel:<id> | chat:<id> | groupchat:<id>",
165
+ },
166
+ },
167
+ outbound: {
168
+ deliveryMode: "direct",
169
+ sendText: async ({ account, target, to, text }) => {
170
+ const dest = target ?? to;
171
+ if (!dest)
172
+ return { ok: false, error: "Missing target/to" };
173
+ return sendViaMcp({ account, target: dest, text: text ?? "" });
174
+ },
175
+ },
176
+ };
177
+ export default function register(api) {
178
+ api.registerChannel({ plugin: basicopsChannel });
179
+ // Webhook route (plugin-managed auth)
180
+ // Recommended gateway path: /channels/basicops/webhook/<pathToken>
181
+ api.registerHttpRoute({
182
+ path: "/channels/basicops/webhook",
183
+ auth: "plugin",
184
+ match: "prefix",
185
+ handler: async (req, res) => {
186
+ try {
187
+ if (req.method !== "POST") {
188
+ res.statusCode = 200;
189
+ res.end("ok");
190
+ return true;
191
+ }
192
+ // Read raw body
193
+ const chunks = [];
194
+ await new Promise((resolve, reject) => {
195
+ req.on("data", (c) => chunks.push(c));
196
+ req.on("end", () => resolve());
197
+ req.on("error", (e) => reject(e));
198
+ });
199
+ const raw = Buffer.concat(chunks).toString("utf8");
200
+ const body = raw ? JSON.parse(raw) : {};
201
+ // Determine accountId; default only for now.
202
+ const cfg = api.runtime.config.loadConfig();
203
+ const accountId = "default";
204
+ const account = resolveAccount(cfg, accountId);
205
+ // Path token check (if configured)
206
+ const token = account?.webhooks?.pathToken;
207
+ if (token) {
208
+ const url = String(req.url || "");
209
+ // req.url includes the path from this route, e.g. /channels/basicops/webhook/<token>
210
+ const parts = url.split("?")[0].split("/").filter(Boolean);
211
+ const idx = parts.findIndex((p) => p === "webhook");
212
+ const got = idx >= 0 ? parts[idx + 1] : null;
213
+ if (got !== token) {
214
+ res.statusCode = 401;
215
+ res.end("unauthorized");
216
+ return true;
217
+ }
218
+ }
219
+ const agentUsername = (account.agentUsername || "Gus").trim();
220
+ const mentionRe = buildMentionRegex(agentUsername);
221
+ const mentionOnly = account.respondOnlyWhenMentioned !== false;
222
+ const events = Array.isArray(body?.events) ? body.events : [];
223
+ // Ack quickly; OpenClaw pipeline work happens after parsing anyway.
224
+ res.statusCode = 200;
225
+ res.setHeader("content-type", "application/json");
226
+ res.end(JSON.stringify({ ok: true }));
227
+ // Process events after ACK
228
+ for (const ev of events) {
229
+ const eventName = String(ev?.event || ev?.name || "");
230
+ const records = Array.isArray(ev?.records) ? ev.records : [];
231
+ for (const record of records) {
232
+ const text = extractText(record);
233
+ if (!text)
234
+ continue;
235
+ if (mentionOnly && !mentionRe.test(text))
236
+ continue;
237
+ const { senderId, senderLabel } = extractSender(record);
238
+ const { containerType, containerId } = extractContainerFromEvent(eventName, record);
239
+ if (!containerId)
240
+ continue;
241
+ const chatId = `${containerType}:${containerId}`;
242
+ const chatType = containerType === "chat" ? "direct" : "group";
243
+ const runtime = api.runtime;
244
+ await runtime.channel.reply.handleInboundMessage?.({
245
+ channel: "basicops",
246
+ accountId,
247
+ senderId,
248
+ senderLabel,
249
+ chatType,
250
+ chatId,
251
+ text,
252
+ // Provide a reply callback that posts back into the same container.
253
+ reply: async (responseText) => {
254
+ await sendViaMcp({ account, target: chatId, text: responseText });
255
+ },
256
+ });
257
+ }
258
+ }
259
+ return true;
260
+ }
261
+ catch (err) {
262
+ api.logger?.error?.(`basicops webhook error: ${err?.message || String(err)}`);
263
+ try {
264
+ res.statusCode = 500;
265
+ res.end("error");
266
+ }
267
+ catch { }
268
+ return true;
269
+ }
270
+ },
271
+ });
272
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "basicops",
3
+ "name": "BasicOps",
4
+ "description": "BasicOps messaging channel connector (webhooks inbound + MCP outbound)",
5
+ "version": "0.1.0",
6
+ "entry": "dist/index.js",
7
+ "tags": ["channel", "basicops", "messaging"],
8
+ "docs": {
9
+ "channelDocsPath": "/channels/basicops"
10
+ }
11
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "openclaw-channel-basicops",
3
+ "version": "0.1.0",
4
+ "description": "BasicOps messaging channel connector for OpenClaw (webhooks inbound + MCP outbound)",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/",
12
+ "openclaw.plugin.json",
13
+ "README.md",
14
+ "DESIGN.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "dev": "tsc -w -p tsconfig.json",
20
+ "prepack": "npm run build"
21
+ },
22
+ "dependencies": {
23
+ "openclaw": ">=2026.3.0"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^5.9.2"
27
+ },
28
+ "license": "MIT"
29
+ }