-
Notifications
You must be signed in to change notification settings - Fork 2
Install OpenClaw channel plugin via installer #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
7ee07f1
Require OpenClaw channel adapter for OpenClaw agents
bglusman b74dcb4
Install OpenClaw channel plugin
bglusman 379bf62
Clarify managed agent install output
bglusman 7b89c5b
Use official OpenClaw plugin route API
bglusman a08da97
Address OpenClaw installer review feedback
bglusman File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,279 @@ | ||
| /** | ||
| * Calciforge OpenClaw channel plugin. | ||
| * | ||
| * This registers POST /calciforge/inbound as a native OpenClaw channel route. | ||
| * Calciforge sends inbound chat messages here, OpenClaw runs the selected | ||
| * agent lane, and this plugin posts the assistant reply back to Calciforge's | ||
| * /hooks/reply endpoint. | ||
| */ | ||
|
|
||
| async function getLegacyRegisterPluginHttpRoute() { | ||
| const mod = await import("/usr/lib/node_modules/openclaw/dist/plugin-sdk/plugin-runtime.js"); | ||
| return mod.registerPluginHttpRoute; | ||
| } | ||
|
|
||
| async function registerHttpRoute(api, route, log) { | ||
| const registerLegacyRoute = await getLegacyRegisterPluginHttpRoute(); | ||
| const unregister = registerLegacyRoute({ | ||
| ...route, | ||
| auth: "none", | ||
| pluginId: "calciforge-channel", | ||
| source: "calciforge-channel-plugin", | ||
| replaceExisting: true, | ||
| log: (msg) => log?.warn?.(msg), | ||
| }); | ||
| return { unregister, source: "legacy route registry" }; | ||
| } | ||
|
|
||
| export default function register(api) { | ||
| const pluginConfig = api.pluginConfig ?? {}; | ||
| const { authToken, replyWebhook, replyAuthToken } = pluginConfig; | ||
|
|
||
| if (authToken && replyWebhook && replyAuthToken) { | ||
| api.logger.info( | ||
| `[calciforge-channel] plugin loaded - replyWebhook=${replyWebhook}`, | ||
| ); | ||
|
|
||
| registerHttpRoute(api, { | ||
| path: "/calciforge/inbound", | ||
| match: "exact", | ||
| handler: async (req, res) => | ||
| handleInboundRequest({ | ||
| api, | ||
| req, | ||
| res, | ||
| authToken, | ||
| replyWebhook, | ||
| replyAuthToken, | ||
| log: api.logger, | ||
| }), | ||
| }, api.logger) | ||
| .then(({ source }) => { | ||
| api.logger.info( | ||
| `[calciforge-channel] registered POST /calciforge/inbound via ${source}`, | ||
| ); | ||
| }) | ||
| .catch((err) => { | ||
| api.logger.error( | ||
| `[calciforge-channel] failed to register HTTP route: ${err.message}`, | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| api.registerChannel({ | ||
| plugin: { | ||
| id: "calciforge-channel", | ||
| name: "Calciforge", | ||
| description: "Calciforge inbound channel", | ||
| configSchema: { type: "object", properties: {}, additionalProperties: true }, | ||
|
|
||
|
|
||
| listAccounts: async () => [{ accountId: "default", config: {} }], | ||
|
|
||
| resolveAccountSnapshot: ({ account }) => ({ | ||
| accountId: account.accountId, | ||
| config: account.config, | ||
| status: { kind: "connected", label: "Calciforge channel active" }, | ||
| }), | ||
|
|
||
| send: null, | ||
|
|
||
| gateway: { | ||
| startAccount: async (ctx) => { | ||
| const { abortSignal, log } = ctx; | ||
| log?.info?.("[calciforge-channel] channel account active"); | ||
|
|
||
| await new Promise((resolve) => { | ||
| abortSignal?.addEventListener("abort", () => { | ||
| log?.info?.("[calciforge-channel] channel stopped"); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| async function handleInboundRequest({ | ||
| api, | ||
| req, | ||
| res, | ||
| authToken, | ||
| replyWebhook, | ||
| replyAuthToken, | ||
| log, | ||
| }) { | ||
| if (req.method !== "POST") { | ||
| json(res, 405, { error: "Method not allowed" }); | ||
| return true; | ||
| } | ||
|
|
||
| if (!isAuthorized(req, authToken)) { | ||
| json(res, 401, { error: "Unauthorized" }); | ||
| return true; | ||
| } | ||
|
|
||
| let body; | ||
| try { | ||
| body = await readJsonBody(req); | ||
| } catch { | ||
| json(res, 400, { error: "Invalid JSON body" }); | ||
| return true; | ||
| } | ||
|
|
||
| const { message, sessionKey, channel, replyTo, agentId } = body; | ||
| if (!message || !sessionKey) { | ||
| json(res, 400, { error: "message and sessionKey are required" }); | ||
| return true; | ||
| } | ||
|
|
||
| json(res, 200, { ok: true }); | ||
|
|
||
| try { | ||
| const { runId } = await api.runtime.subagent.run({ | ||
| sessionKey, | ||
| message, | ||
| idempotencyKey: `calciforge:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, | ||
| ...(agentId ? { lane: agentId } : {}), | ||
| deliver: false, | ||
| }); | ||
|
|
||
| const result = await api.runtime.subagent.waitForRun({ | ||
| runId, | ||
| timeoutMs: 300000, | ||
| }); | ||
|
|
||
| if (result.status !== "ok") { | ||
| log?.warn?.( | ||
| `[calciforge-channel] agent run ${result.status} - runId=${runId}`, | ||
| ); | ||
| await deliverReply({ | ||
| replyWebhook, | ||
| replyAuthToken, | ||
| sessionKey, | ||
| message: `OpenClaw run ${result.status}`, | ||
| channel, | ||
| replyTo, | ||
| log, | ||
| }); | ||
| return true; | ||
| } | ||
|
|
||
| const replyText = await readLatestAssistantText( | ||
| api, | ||
| sessionKey, | ||
| ); | ||
| if (isSilentReply(replyText)) { | ||
| log?.info?.("[calciforge-channel] silent reply - not forwarding"); | ||
| return true; | ||
| } | ||
|
|
||
| await deliverReply({ | ||
| replyWebhook, | ||
| replyAuthToken, | ||
| sessionKey, | ||
| message: replyText, | ||
| channel, | ||
| replyTo, | ||
| log, | ||
| }); | ||
| } catch (err) { | ||
| log?.error?.(`[calciforge-channel] dispatch error - ${err.message}`); | ||
| await deliverReply({ | ||
| replyWebhook, | ||
| replyAuthToken, | ||
| sessionKey, | ||
| message: `OpenClaw dispatch failed: ${err.message}`, | ||
| channel, | ||
| replyTo, | ||
| log, | ||
| }); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| function isAuthorized(req, expectedToken) { | ||
| if (!expectedToken) return false; | ||
| const authHeader = req.headers["authorization"] ?? ""; | ||
| const token = authHeader.startsWith("Bearer ") | ||
| ? authHeader.slice("Bearer ".length) | ||
| : authHeader; | ||
| return token === expectedToken; | ||
| } | ||
|
|
||
| async function readJsonBody(req) { | ||
| const chunks = []; | ||
| await new Promise((resolve, reject) => { | ||
| req.on("data", (chunk) => chunks.push(chunk)); | ||
| req.on("end", resolve); | ||
| req.on("error", reject); | ||
| }); | ||
| return JSON.parse(Buffer.concat(chunks).toString("utf8")); | ||
|
Comment on lines
+205
to
+212
|
||
| } | ||
|
|
||
| async function readLatestAssistantText(api, sessionKey) { | ||
| const { messages } = await api.runtime.subagent.getSessionMessages({ | ||
| sessionKey, | ||
| limit: 10, | ||
| }); | ||
| const lastMsg = [...messages] | ||
| .reverse() | ||
| .find((msg) => msg?.role === "assistant"); | ||
| if (!lastMsg) return ""; | ||
|
|
||
| const content = lastMsg.content; | ||
| if (typeof content === "string") return content; | ||
| if (Array.isArray(content)) { | ||
| return content | ||
| .filter((part) => part.type === "text") | ||
| .map((part) => part.text ?? "") | ||
| .join("\n"); | ||
| } | ||
| return ""; | ||
| } | ||
|
|
||
| function isSilentReply(replyText) { | ||
| const trimmed = (replyText ?? "").trim(); | ||
| return !trimmed || trimmed === "NO_REPLY" || trimmed === "HEARTBEAT_OK"; | ||
| } | ||
|
|
||
| async function deliverReply({ | ||
| replyWebhook, | ||
| replyAuthToken, | ||
| sessionKey, | ||
| message, | ||
| channel, | ||
| replyTo, | ||
| log, | ||
| }) { | ||
| if (!replyWebhook || !replyAuthToken) { | ||
| log?.warn?.("[calciforge-channel] reply webhook/auth not configured"); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const resp = await fetch(replyWebhook, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${replyAuthToken}`, | ||
| }, | ||
| body: JSON.stringify({ sessionKey, message, channel, to: replyTo }), | ||
| signal: AbortSignal.timeout(30000), | ||
| }); | ||
|
|
||
| if (!resp.ok) { | ||
| log?.error?.(`[calciforge-channel] reply webhook failed - status=${resp.status}`); | ||
| } else { | ||
| log?.info?.("[calciforge-channel] reply delivered"); | ||
| } | ||
| } catch (err) { | ||
| log?.error?.(`[calciforge-channel] reply webhook error - ${err.message}`); | ||
| } | ||
| } | ||
|
|
||
| function json(res, status, body) { | ||
| res.writeHead(status, { "Content-Type": "application/json" }); | ||
| res.end(JSON.stringify(body)); | ||
| } | ||
25 changes: 25 additions & 0 deletions
25
crates/calciforge-openclaw-channel-plugin/openclaw.plugin.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| { | ||
| "id": "calciforge-channel", | ||
| "name": "Calciforge Channel", | ||
| "description": "Registers Calciforge as a native inbound channel so messages flow through OpenClaw's full agent runtime and replies are delivered back to Calciforge.", | ||
| "version": "0.1.0", | ||
| "channels": ["calciforge"], | ||
| "configSchema": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "properties": { | ||
| "authToken": { | ||
| "type": "string", | ||
| "description": "Bearer token Calciforge sends to /calciforge/inbound" | ||
| }, | ||
| "replyWebhook": { | ||
| "type": "string", | ||
| "description": "URL to POST agent responses back to Calciforge, e.g. http://calciforge.lan:18797/hooks/reply" | ||
| }, | ||
| "replyAuthToken": { | ||
| "type": "string", | ||
| "description": "Bearer token for authenticating callbacks to Calciforge's reply webhook" | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "name": "@openclaw/calciforge-channel", | ||
| "version": "0.1.0", | ||
| "description": "Calciforge native channel plugin for OpenClaw", | ||
| "type": "module", | ||
| "main": "index.js", | ||
| "peerDependencies": { | ||
| "openclaw": "*" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.