Compare commits
28 Commits
7fed6d07f6
...
feat/fabri
| Author | SHA1 | Date | |
|---|---|---|---|
| cd36d1b9e2 | |||
| 9c910f082b | |||
| c5fd091f5a | |||
| c5a33c33ec | |||
| 28f5083679 | |||
| a060ff98a2 | |||
| b9a5456d57 | |||
| d1d5ad10ca | |||
| 92945b777d | |||
| 8774cfd7cc | |||
| ab126825ef | |||
| bb63a57384 | |||
| fc6edaabfd | |||
| c03562046d | |||
| fac6debfa5 | |||
| aaabb0ddb0 | |||
| 26c12533fb | |||
| 9d0fa1d5c8 | |||
| 892db9f9be | |||
| fc7efd0227 | |||
| d79a04b8a3 | |||
| cc655ffcc3 | |||
| 42228e0a23 | |||
| 2abd0000e6 | |||
| 25473384d8 | |||
| f59c693186 | |||
| 9cb262367e | |||
| ece77ff2c7 |
136
README.md
136
README.md
@@ -1,51 +1,139 @@
|
||||
# Fabric.OpenclawPlugin
|
||||
|
||||
An **OpenClaw channel plugin** that connects OpenClaw agents to a Fabric guild.
|
||||
A native **OpenClaw channel plugin** that connects OpenClaw agents to Fabric
|
||||
guilds as real channel members. Independent of OpenClaw's source — it only
|
||||
uses the public plugin SDK.
|
||||
|
||||
## Model
|
||||
|
||||
- `kind: "channel"` plugin (like the bundled discord channel). OpenClaw **core
|
||||
owns dispatch** (inbound → agent run) and the reply pipeline via the channel
|
||||
turn kernel `runtime.channel.turn.run(...)`.
|
||||
- Fabric already owns turn/shuffle/mention/`/no-reply` server-side, so this
|
||||
plugin is thin. Fabric's per-recipient **`wakeup`** maps to channel-turn
|
||||
**admission**:
|
||||
- `wakeup === true` → `dispatch` (agent runs, may reply)
|
||||
- otherwise → `{ kind: "drop", recordHistory: true }` (kept as context)
|
||||
- **No sidecar, no fake no-reply model, no `before_model_resolve` gating.**
|
||||
- `kind: "channel"` plugin (like the bundled discord channel). OpenClaw core
|
||||
owns dispatch and the reply pipeline via the channel-turn kernel
|
||||
(`resolveAgentRoute` + `finalizeInboundContext` +
|
||||
`dispatchInboundReplyWithBase`). Fabric already owns turn/shuffle/mention/
|
||||
`/no-reply` server-side, so this plugin is thin.
|
||||
- Fabric's per-recipient **`wakeup`** maps to admission:
|
||||
- `wakeup === true` → **dispatch** (the agent runs and may reply).
|
||||
- `wakeup !== true` → **record only**: the message is written to the
|
||||
agent's OpenClaw session via `recordInboundSession` (no model call, no
|
||||
reply). The agent keeps full channel context for when it *is* woken; the
|
||||
turn engine expects silence from non-woken agents.
|
||||
- Replies are forced to **automatic** delivery
|
||||
(`replyOptions.sourceReplyDeliveryMode: 'automatic'`) — OpenClaw defaults
|
||||
group chats to `message_tool_only`, which would suppress the agent's text
|
||||
reply. Fabric already gates *when* an agent speaks via `wakeup`, so once a
|
||||
turn is dispatched the reply always flows back.
|
||||
- One Fabric socket per agent identity. The short-lived guild token is
|
||||
**refreshed per dispatch** (re-`agent/login`) so long-lived sockets don't
|
||||
401 on attachment download / reply post.
|
||||
|
||||
## Files to agents
|
||||
|
||||
When an inbound message has `attachments[]`, the plugin downloads each file
|
||||
(with the agent's guild token) to a temp dir and sets local
|
||||
`MediaPaths`/`MediaTypes` on the inbound context so the agent receives the
|
||||
files. `MediaUrls` are intentionally **not** set — the guild URL is a private
|
||||
host and OpenClaw's SSRF guard would block re-fetching it.
|
||||
|
||||
## Auth
|
||||
|
||||
Each agent has a Fabric Center **API key** (mint via Center CLI:
|
||||
`node dist/cli.js user apikey --email <agent-email>`). The key is exchanged for
|
||||
a user session (`POST /auth/agent/login`) used to receive (socket) and post
|
||||
replies. Bind a key to an agent via the `fabric-register` tool, or pre-populate
|
||||
the identity file.
|
||||
`node dist/cli.js user apikey --email <agent-email>`). The key is exchanged
|
||||
for a user session (`POST /auth/agent/login`).
|
||||
|
||||
### Binding a key to an agent (one-time)
|
||||
|
||||
Two ways, both write the same identity registry the transport reads:
|
||||
|
||||
1. **Static config** — set `channels.fabric.accounts.<agentId> =
|
||||
{ fabricApiKey, enabled }`. The agent never runs anything.
|
||||
2. **`fabric-register` script** — installed to `~/.openclaw/bin/fabric-register`
|
||||
by the installer (it is **not** an OpenClaw tool):
|
||||
|
||||
```bash
|
||||
# agent id from $AGENT_ID (set in the agent runtime):
|
||||
~/.openclaw/bin/fabric-register --api-key fak_…
|
||||
# or pass it explicitly:
|
||||
~/.openclaw/bin/fabric-register --agent-id <agent> --api-key fak_…
|
||||
```
|
||||
|
||||
It validates the key against Center, then writes
|
||||
`~/.openclaw/fabric-identity.json`. One-time and persistent — *not* per
|
||||
login; the plugin's transport logs in and stays connected on its own.
|
||||
**Only `AGENT_ID` is read from the environment** — if unset, `--agent-id`
|
||||
is required. `--api-key` is flag-only (never from the env). Other flags:
|
||||
`--center`, `--identity-file`, `--openclaw-path` (sensible defaults;
|
||||
`--center` also falls back to `openclaw.json`). Restart the gateway
|
||||
afterwards.
|
||||
|
||||
## Config
|
||||
|
||||
- `channels.fabric.centerApiBase` — e.g. `http://localhost:7001/api`
|
||||
- `channels.fabric.commandsSyncKey` — **required**; must equal the guild's
|
||||
`FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY` (Guild C-2). Read it from the
|
||||
guild with `docker exec fabric-backend-guild node dist/cli/print-commands-sync-key.js`.
|
||||
Sourced from config only — never from the environment.
|
||||
- `channels.fabric.coalesce` — default `true`. OpenClaw splits one agent
|
||||
turn whose blocks are `text → thinking/tool → text` into multiple
|
||||
`deliver()` calls; this buffers them and posts ONE Fabric message at the
|
||||
deterministic turn boundary (right after the inbound reply dispatch
|
||||
resolves — no hooks, no timers, no idle guessing). `false` = raw
|
||||
per-segment posting.
|
||||
- `channels.fabric.accounts.<agentId>` = `{ fabricApiKey, enabled }`
|
||||
(**agent = account**; the account id is the OpenClaw agentId)
|
||||
- plugin `identityFilePath` — default `~/.openclaw/fabric-identity.json`
|
||||
(`{ entries: [{ agentId, fabricApiKey }] }`)
|
||||
|
||||
### Required: route binding (account → agent)
|
||||
|
||||
OpenClaw routes a channel turn via `cfg.bindings`; without a Fabric binding
|
||||
it falls back to the default agent. One per account:
|
||||
|
||||
```json
|
||||
{ "agentId": "<agent>", "match": { "channel": "fabric", "accountId": "<account>" } }
|
||||
```
|
||||
|
||||
Then `openclaw gateway restart`.
|
||||
|
||||
## Tools
|
||||
|
||||
- `fabric-register` — bind this agent's Fabric API key
|
||||
(Key binding is **not** a tool — see *Binding a key to an agent* above.)
|
||||
|
||||
- `create-chat-channel` (general) / `create-work-channel` (work) /
|
||||
`create-report-channel` (report) / `create-discussion-channel` (discuss)
|
||||
- `discussion-complete` — post a summary then close the channel
|
||||
(Fabric `POST /channels/:id/close`; closed → history readable, posts → 409)
|
||||
- `discussion-complete` — post a summary, then close the channel
|
||||
(closed → history readable; new posts → `409`)
|
||||
- `fabric-canvas` — manage a channel's single pinned canvas document; one
|
||||
tool, four `action`s: `read` (current canvas or null) · `share`
|
||||
(create/replace; caller becomes sharer) · `update` (edit in place;
|
||||
sharer-only) · `close` (remove; sharer-only). `share` needs
|
||||
`title`/`format`(`md`|`html`|`text`)/`source`.
|
||||
- `fabric-channel` — channel membership; one tool, three `action`s:
|
||||
`members` (list the channel's member userIds) · `join` (this agent
|
||||
joins) · `leave` (this agent leaves).
|
||||
|
||||
## Transport (Phase 1 = B1)
|
||||
## Slash-command catalog
|
||||
|
||||
One Fabric socket per agent identity, in the plugin runtime. Firehose (B2) is
|
||||
a later drop-in behind the same `dispatch()` seam.
|
||||
On `gateway_start` the plugin syncs OpenClaw's native-command catalog to
|
||||
each guild (`command-sync.ts`: `listNativeCommandSpecsForConfig` +
|
||||
`findCommandByNativeName`, dynamic arg `choices` snapshotted via
|
||||
`resolveCommandArgChoices`) → `PUT /api/commands`. This is exactly the data
|
||||
Discord registers as slash commands; Fabric's frontend uses it for `/`
|
||||
autocomplete. Fabric deliberately does **not** advertise the
|
||||
`nativeCommands` channel capability — it stays a text-command surface, so a
|
||||
`/<cmd>` message is delivered normally and OpenClaw's command system
|
||||
executes it (text-command + command session + auth), reusing the standard
|
||||
inbound path. Only `/no-reply` and `/force-proceed` stay
|
||||
server-intercepted by the guild.
|
||||
|
||||
## Build
|
||||
## Install / build
|
||||
|
||||
```bash
|
||||
npm install && npm run build
|
||||
node install.mjs # build + copy to ~/.openclaw/plugins/fabric + configure
|
||||
```
|
||||
|
||||
> The plugin compiles against the host's OpenClaw SDK
|
||||
> (`openclaw/plugin-sdk/*`); build inside the OpenClaw plugin environment.
|
||||
`install.mjs` mirrors the PaddedCell-style installer (also `--uninstall`).
|
||||
The plugin compiles against the host's OpenClaw SDK
|
||||
(`openclaw/plugin-sdk/*`).
|
||||
|
||||
> Transport is Phase 1 (one socket per agent). A firehose variant (B2) is a
|
||||
> later drop-in behind the same `dispatch()` seam.
|
||||
|
||||
161
bin/fabric-register.mjs
Normal file
161
bin/fabric-register.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fabric-register — bind an OpenClaw agent to a Fabric Center API key.
|
||||
*
|
||||
* One-time, self-contained (no plugin deps). Installed to
|
||||
* ~/.openclaw/bin/fabric-register by the plugin installer.
|
||||
*
|
||||
* AGENT_ID is read from the environment if set; otherwise --agent-id is
|
||||
* required. The API key is validated against Center (POST
|
||||
* /auth/agent/login) and, on success, written to the plugin's identity
|
||||
* file so the Fabric channel plugin can connect that agent.
|
||||
*
|
||||
* Usage:
|
||||
* fabric-register --api-key fak_xxx # uses $AGENT_ID
|
||||
* fabric-register --agent-id echo --api-key fak_xxx
|
||||
*
|
||||
* Only AGENT_ID is read from the environment; everything else is a flag.
|
||||
*
|
||||
* Flags:
|
||||
* --agent-id <id> required unless the AGENT_ID env var is set
|
||||
* --api-key <fak_…> required (flag only — never from the environment)
|
||||
* --center <url> else openclaw.json channels.fabric.centerApiBase;
|
||||
* else http://localhost:7001/api
|
||||
* --identity-file <path> default ~/.openclaw/fabric-identity.json
|
||||
* --openclaw-path <dir> default ~/.openclaw
|
||||
* -h | --help
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '-h' || a === '--help') out.help = true;
|
||||
else if (a.startsWith('--')) {
|
||||
const key = a.slice(2);
|
||||
const v = argv[i + 1];
|
||||
if (v === undefined || v.startsWith('--')) out[key] = true;
|
||||
else {
|
||||
out[key] = v;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HELP = `fabric-register — bind an OpenClaw agent to a Fabric Center API key
|
||||
|
||||
fabric-register --api-key fak_xxx # agent id from $AGENT_ID
|
||||
fabric-register --agent-id <id> --api-key fak_xxx
|
||||
|
||||
--agent-id <id> required unless the AGENT_ID env var is set
|
||||
--api-key <fak_…> required (flag only — never read from the env)
|
||||
--center <url> Center API base (else openclaw.json
|
||||
channels.fabric.centerApiBase; else
|
||||
http://localhost:7001/api)
|
||||
--identity-file <path> default ~/.openclaw/fabric-identity.json
|
||||
--openclaw-path <dir> default ~/.openclaw
|
||||
-h, --help
|
||||
|
||||
Only AGENT_ID is taken from the environment; everything else is a flag.
|
||||
`;
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`fabric-register: ${msg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const a = parseArgs(process.argv.slice(2));
|
||||
if (a.help) {
|
||||
process.stdout.write(HELP);
|
||||
return;
|
||||
}
|
||||
|
||||
// agent id: env AGENT_ID wins; else --agent-id is required.
|
||||
const agentId =
|
||||
(process.env.AGENT_ID && process.env.AGENT_ID.trim()) ||
|
||||
(typeof a['agent-id'] === 'string' && a['agent-id'].trim());
|
||||
if (!agentId) {
|
||||
fail('no agent id: set the AGENT_ID environment variable or pass --agent-id <id>');
|
||||
}
|
||||
|
||||
// api key: flag ONLY — never from the environment.
|
||||
const apiKey = typeof a['api-key'] === 'string' && a['api-key'].trim();
|
||||
if (!apiKey) fail('missing --api-key <fak_…> (flag only)');
|
||||
|
||||
const openclawPath = resolve(
|
||||
(typeof a['openclaw-path'] === 'string' && a['openclaw-path']) ||
|
||||
join(homedir(), '.openclaw'),
|
||||
);
|
||||
|
||||
// center api base: flag > openclaw.json > default
|
||||
let center = (typeof a.center === 'string' && a.center) || '';
|
||||
if (!center) {
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(join(openclawPath, 'openclaw.json'), 'utf8'));
|
||||
center = cfg?.channels?.fabric?.centerApiBase || '';
|
||||
} catch {
|
||||
/* fall through to default */
|
||||
}
|
||||
}
|
||||
if (!center) center = 'http://localhost:7001/api';
|
||||
center = center.replace(/\/+$/, '');
|
||||
|
||||
const identityFile = resolve(
|
||||
(typeof a['identity-file'] === 'string' && a['identity-file']) ||
|
||||
join(openclawPath, 'fabric-identity.json'),
|
||||
);
|
||||
|
||||
// 1) validate the key against Center (also resolves the Fabric identity)
|
||||
let session;
|
||||
try {
|
||||
const res = await fetch(`${center}/auth/agent/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => '');
|
||||
fail(`Center rejected the key: POST ${center}/auth/agent/login -> ${res.status} ${t}`);
|
||||
}
|
||||
session = await res.json();
|
||||
} catch (e) {
|
||||
fail(`could not reach Center at ${center}: ${e?.message || e}`);
|
||||
}
|
||||
|
||||
// 2) upsert into the identity file (merge by agentId)
|
||||
let file = { entries: [] };
|
||||
if (existsSync(identityFile)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(identityFile, 'utf8'));
|
||||
if (parsed && Array.isArray(parsed.entries)) file = parsed;
|
||||
} catch {
|
||||
/* corrupt -> overwrite */
|
||||
}
|
||||
}
|
||||
const entry = {
|
||||
agentId,
|
||||
fabricApiKey: apiKey,
|
||||
fabricUserId: session?.user?.id,
|
||||
displayName: session?.user?.name,
|
||||
};
|
||||
const i = file.entries.findIndex((e) => e && e.agentId === agentId);
|
||||
if (i >= 0) file.entries[i] = { ...file.entries[i], ...entry };
|
||||
else file.entries.push(entry);
|
||||
|
||||
mkdirSync(dirname(identityFile), { recursive: true });
|
||||
writeFileSync(identityFile, JSON.stringify(file, null, 2));
|
||||
|
||||
console.log(
|
||||
`fabric-register: bound agent "${agentId}" -> Fabric user ` +
|
||||
`${session?.user?.email || session?.user?.id} (${identityFile}). ` +
|
||||
`Restart the gateway to connect: openclaw gateway restart`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => fail(e?.message || String(e)));
|
||||
76
dist/fabric/index.js
vendored
76
dist/fabric/index.js
vendored
@@ -1,42 +1,72 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/channel-core';
|
||||
// Fabric channel plugin entry.
|
||||
// COMPAT NOTE (openclaw v2026.5.7): defineChannelPluginEntry signature
|
||||
// { id, name, description, plugin, setRuntime?, registerFull? }. setRuntime
|
||||
// receives the PluginRuntime (has channel.turn kernel); registerFull receives
|
||||
// the OpenClawPluginApi for runtime startup (transport + tools).
|
||||
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
|
||||
import { fabricChannelPlugin } from './src/channel.js';
|
||||
import { flushAllFabric } from './src/coalesce.js';
|
||||
import { FabricInbound } from './src/inbound.js';
|
||||
import { listEnabledFabricAccounts } from './src/accounts.js';
|
||||
import { registerFabricTools } from './src/tools.js';
|
||||
import { FabricClient } from './src/fabric-client.js';
|
||||
import { IdentityRegistry } from './src/identity.js';
|
||||
import { FabricInbound } from './src/inbound.js';
|
||||
import { buildFabricChannelPlugin } from './src/channel.js';
|
||||
import { registerFabricTools } from './src/tools.js';
|
||||
function centerApiBase(api) {
|
||||
const section = api.config?.channels?.['fabric'];
|
||||
return section?.centerApiBase ?? 'http://localhost:7001/api';
|
||||
}
|
||||
import { syncFabricCommands } from './src/command-sync.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
let runtimeRef = null;
|
||||
let inbound = null;
|
||||
export { fabricChannelPlugin } from './src/channel.js';
|
||||
export default defineChannelPluginEntry({
|
||||
id: 'fabric',
|
||||
name: 'Fabric',
|
||||
description: 'Fabric channel plugin — OpenClaw agents speak in Fabric guilds',
|
||||
// Channel object: config/security/outbound. Visible turn replies flow
|
||||
// through the inbound channel-turn delivery adapter; outbound.sendText
|
||||
// covers proactive sends via the shared message tool.
|
||||
plugin: buildFabricChannelPlugin(async () => ({ messageId: undefined })),
|
||||
// registerFull: runtime pieces (transport, tools). Guarded so the long-lived
|
||||
// Fabric connections start once per gateway process.
|
||||
registerFull(api) {
|
||||
const cfg = (api.pluginConfig ?? {});
|
||||
const identityFilePath = cfg.identityFilePath ?? path.join(os.homedir(), '.openclaw', 'fabric-identity.json');
|
||||
const client = new FabricClient(centerApiBase(api));
|
||||
const identity = new IdentityRegistry(identityFilePath);
|
||||
plugin: fabricChannelPlugin,
|
||||
setRuntime(runtime) {
|
||||
runtimeRef = runtime;
|
||||
},
|
||||
registerFull(apiRaw) {
|
||||
// COMPAT: access the subset we use through a loose view so SDK type
|
||||
// drift in unrelated api members doesn't break the build.
|
||||
const api = apiRaw;
|
||||
const cfg = (api.config ?? {});
|
||||
const centerApiBase = cfg.channels?.fabric?.centerApiBase ?? 'http://localhost:7001/api';
|
||||
const idFile = api.pluginConfig?.identityFilePath ??
|
||||
path.join(os.homedir(), '.openclaw', 'fabric-identity.json');
|
||||
// tools operate against a default Center; per-account keys come from config
|
||||
const client = new FabricClient(centerApiBase);
|
||||
const identity = new IdentityRegistry(idFile);
|
||||
registerFabricTools({ registerTool: (d) => api.registerTool(d), logger: api.logger }, client, identity);
|
||||
api.on('gateway_start', () => {
|
||||
const _G = globalThis;
|
||||
if (_G._fabricInboundStarted)
|
||||
return;
|
||||
_G._fabricInboundStarted = true;
|
||||
inbound = new FabricInbound(api.runtime, client, identity, api.logger);
|
||||
const accounts = listEnabledFabricAccounts(cfg).map((a) => ({
|
||||
agentId: a.accountId,
|
||||
fabricApiKey: a.fabricApiKey,
|
||||
}));
|
||||
// also include any tool-registered identities
|
||||
for (const e of identity.list()) {
|
||||
if (!accounts.some((x) => x.agentId === e.agentId)) {
|
||||
accounts.push({ agentId: e.agentId, fabricApiKey: e.fabricApiKey });
|
||||
}
|
||||
}
|
||||
if (!runtimeRef) {
|
||||
api.logger.warn('fabric: runtime not set; inbound disabled');
|
||||
return;
|
||||
}
|
||||
inbound = new FabricInbound(runtimeRef, api.config, client, identity, api.logger, accounts);
|
||||
void inbound.start();
|
||||
api.logger.info('fabric: inbound transport started');
|
||||
api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`);
|
||||
void syncFabricCommands(client, cfg, accounts, api.logger);
|
||||
});
|
||||
// Note: the per-turn coalesce flush happens deterministically in
|
||||
// inbound.ts right after dispatchInboundReplyWithBase resolves (that
|
||||
// is the real "all deliveries done" boundary; the agent_end hook fires
|
||||
// BEFORE deliver()). gateway_stop only flushes any leftover buffer.
|
||||
api.on('gateway_stop', () => {
|
||||
void flushAllFabric();
|
||||
inbound?.stop();
|
||||
inbound = null;
|
||||
});
|
||||
|
||||
52
dist/fabric/src/accounts.js
vendored
Normal file
52
dist/fabric/src/accounts.js
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
// agent = openclaw channel account.
|
||||
// Config shape:
|
||||
// channels.fabric.centerApiBase = "http://localhost:7001/api" (shared)
|
||||
// channels.fabric.accounts.<agentId> = { fabricApiKey, centerApiBase? }
|
||||
// Each account id IS the openclaw agentId that owns that Fabric identity.
|
||||
const DEFAULT_CENTER = 'http://localhost:7001/api';
|
||||
function section(cfg) {
|
||||
return cfg.channels?.fabric ?? {};
|
||||
}
|
||||
// The commands-sync shared secret (channel-level only). Empty string when
|
||||
// unconfigured — callers decide how to handle (slash-command sync is then
|
||||
// rejected by the guild).
|
||||
export function resolveCommandsSyncKey(cfg) {
|
||||
return (section(cfg).commandsSyncKey ?? '').trim();
|
||||
}
|
||||
// Whether to coalesce a split agent turn into one Fabric message
|
||||
// (channel-level). Default true.
|
||||
export function resolveCoalesce(cfg) {
|
||||
return (cfg.channels?.fabric ?? {}).coalesce !== false;
|
||||
}
|
||||
export function listFabricAccountIds(cfg) {
|
||||
const accts = section(cfg).accounts ?? {};
|
||||
const ids = Object.keys(accts);
|
||||
return ids.length ? ids : ['default'];
|
||||
}
|
||||
export function resolveDefaultFabricAccountId(cfg) {
|
||||
const s = section(cfg);
|
||||
if (s.defaultAccount)
|
||||
return s.defaultAccount;
|
||||
const ids = listFabricAccountIds(cfg);
|
||||
return ids[0] ?? 'default';
|
||||
}
|
||||
export function resolveFabricAccount(cfg, accountId) {
|
||||
const s = section(cfg);
|
||||
const id = accountId ?? resolveDefaultFabricAccountId(cfg);
|
||||
const acc = s.accounts?.[id] ?? {};
|
||||
const fabricApiKey = (acc.fabricApiKey ?? '').trim();
|
||||
const centerApiBase = (acc.centerApiBase ?? s.centerApiBase ?? DEFAULT_CENTER).trim();
|
||||
return {
|
||||
accountId: id,
|
||||
enabled: acc.enabled !== false && s.enabled !== false,
|
||||
centerApiBase,
|
||||
fabricApiKey,
|
||||
allowFrom: acc.allowFrom ?? s.allowFrom ?? [],
|
||||
dmPolicy: acc.dmPolicy ?? s.dmPolicy,
|
||||
};
|
||||
}
|
||||
export function listEnabledFabricAccounts(cfg) {
|
||||
return listFabricAccountIds(cfg)
|
||||
.map((id) => resolveFabricAccount(cfg, id))
|
||||
.filter((a) => a.enabled && a.fabricApiKey);
|
||||
}
|
||||
197
dist/fabric/src/channel.js
vendored
197
dist/fabric/src/channel.js
vendored
@@ -1,46 +1,157 @@
|
||||
import { createChatChannelPlugin, createChannelPluginBase, } from 'openclaw/plugin-sdk/channel-core';
|
||||
export function resolveFabricAccount(cfg, accountId) {
|
||||
const section = cfg.channels?.['fabric'];
|
||||
const centerApiBase = section?.centerApiBase;
|
||||
if (!centerApiBase)
|
||||
throw new Error('fabric: channels.fabric.centerApiBase is required');
|
||||
return {
|
||||
accountId: accountId ?? null,
|
||||
centerApiBase,
|
||||
allowFrom: section?.allowFrom ?? [],
|
||||
dmPolicy: section?.dmSecurity,
|
||||
};
|
||||
// Fabric channel plugin object (third-party `createChatChannelPlugin` path).
|
||||
//
|
||||
// COMPAT NOTE (openclaw v2026.5.7 plugin SDK):
|
||||
// We depend on these generic SDK shapes — re-verify on openclaw upgrade:
|
||||
// - createChannelPluginBase requires `capabilities`
|
||||
// - ChannelSetupAdapter: only `applyAccountConfig` is required
|
||||
// - outbound attachedResults.sendText returns Omit<OutboundDeliveryResult,
|
||||
// "channel"> (so `messageId: string` is required)
|
||||
// Casts at the createChatChannelPlugin boundary are intentional and
|
||||
// localized; keep them here so upgrades touch one file.
|
||||
import { createChatChannelPlugin, createChannelPluginBase, buildChannelOutboundSessionRoute, } from 'openclaw/plugin-sdk/core';
|
||||
import { FabricClient } from './fabric-client.js';
|
||||
import { listFabricAccountIds, resolveFabricAccount, resolveDefaultFabricAccountId, } from './accounts.js';
|
||||
// ---- target grammar: fabric:<channelId> ----
|
||||
export function stripFabricTargetPrefix(raw) {
|
||||
let s = (raw ?? '').trim();
|
||||
if (!s)
|
||||
return undefined;
|
||||
if (s.toLowerCase().startsWith('fabric:'))
|
||||
s = s.slice('fabric:'.length).trim();
|
||||
if (s.toLowerCase().startsWith('channel:'))
|
||||
s = s.slice('channel:'.length).trim();
|
||||
return s || undefined;
|
||||
}
|
||||
// Outbound is wired by the entry (it needs the identity registry + client to
|
||||
// post as the right agent). Channel-turn visible replies go through the
|
||||
// inbound adapter's delivery callback; this object owns config/security only.
|
||||
export function buildFabricChannelPlugin(sendText) {
|
||||
return createChatChannelPlugin({
|
||||
base: createChannelPluginBase({
|
||||
id: 'fabric',
|
||||
setup: {
|
||||
resolveAccount: resolveFabricAccount,
|
||||
inspectAccount(cfg, accountId) {
|
||||
const section = cfg.channels?.['fabric'];
|
||||
const ok = Boolean(section?.centerApiBase);
|
||||
return { enabled: ok, configured: ok, tokenStatus: ok ? 'available' : 'missing' };
|
||||
},
|
||||
},
|
||||
}),
|
||||
security: {
|
||||
dm: {
|
||||
channelKey: 'fabric',
|
||||
resolvePolicy: (a) => a.dmPolicy,
|
||||
resolveAllowFrom: (a) => a.allowFrom,
|
||||
defaultPolicy: 'allowlist',
|
||||
},
|
||||
},
|
||||
// Fabric replies thread by being posted into the same channel.
|
||||
threading: { topLevelReplyToMode: 'channel' },
|
||||
outbound: {
|
||||
attachedResults: {
|
||||
sendText: async (params) => sendText(params),
|
||||
},
|
||||
},
|
||||
export function normalizeFabricTarget(raw) {
|
||||
const id = stripFabricTargetPrefix(raw);
|
||||
return id ? `fabric:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
export function looksLikeFabricTargetId(raw) {
|
||||
const t = (raw ?? '').trim();
|
||||
if (!t)
|
||||
return false;
|
||||
if (/^fabric:/i.test(t))
|
||||
return true;
|
||||
return /^[a-z0-9-]{8,}$/i.test(t);
|
||||
}
|
||||
export function resolveFabricOutboundSessionRoute(params) {
|
||||
const id = stripFabricTargetPrefix(params.target);
|
||||
if (!id)
|
||||
return null;
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: 'fabric',
|
||||
accountId: params.accountId,
|
||||
peer: { kind: 'group', id },
|
||||
chatType: 'group',
|
||||
from: `fabric:channel:${id}`,
|
||||
to: `fabric:${id}`,
|
||||
});
|
||||
}
|
||||
// Posts an agent's reply to Fabric. `to` is the Fabric channelId; `accountId`
|
||||
// is the agentId (= Fabric identity). One auth concept: account apiKey ->
|
||||
// agent/login -> guild token -> POST message.
|
||||
async function sendToFabric(cfg, accountId, to, text) {
|
||||
const channelId = stripFabricTargetPrefix(to) ?? to;
|
||||
const acc = resolveFabricAccount(cfg, accountId);
|
||||
if (!acc.fabricApiKey)
|
||||
throw new Error(`fabric account ${acc.accountId} has no fabricApiKey`);
|
||||
const client = new FabricClient(acc.centerApiBase);
|
||||
const session = await client.agentLogin(acc.fabricApiKey);
|
||||
// find which guild owns this channel by probing each guild's channel list
|
||||
for (const g of session.guilds) {
|
||||
const gt = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
|
||||
if (!gt)
|
||||
continue;
|
||||
const res = await fetch(`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`, {
|
||||
headers: { authorization: `Bearer ${gt}` },
|
||||
});
|
||||
const channels = res.ok ? (await res.json()) : [];
|
||||
if (channels.some((c) => c.id === channelId)) {
|
||||
await client.postMessage(g.endpoint, gt, channelId, text, session.user.id);
|
||||
return { messageId: `${channelId}:${Date.now()}` };
|
||||
}
|
||||
}
|
||||
// fallback: first guild
|
||||
const g = session.guilds[0];
|
||||
const gt = session.guildAccessTokens.find((t) => t.guildNodeId === g?.nodeId)?.token;
|
||||
if (g && gt) {
|
||||
await client.postMessage(g.endpoint, gt, channelId, text, session.user.id);
|
||||
return { messageId: `${channelId}:${Date.now()}` };
|
||||
}
|
||||
throw new Error('fabric: no guild available to deliver');
|
||||
}
|
||||
export const fabricChannelPlugin = createChatChannelPlugin({
|
||||
base: {
|
||||
...createChannelPluginBase({
|
||||
id: 'fabric',
|
||||
meta: { id: 'fabric', label: 'Fabric', blurb: 'Connect OpenClaw agents to a Fabric guild.' },
|
||||
capabilities: {
|
||||
chatTypes: ['channel', 'group', 'direct'],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: false,
|
||||
nativeCommands: false,
|
||||
// Fabric has no message-length limit and we never want a reply split
|
||||
// into multiple messages -> no block streaming.
|
||||
blockStreaming: false,
|
||||
},
|
||||
reload: { configPrefixes: ['channels.fabric'] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => listFabricAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveFabricAccount(cfg, accountId),
|
||||
defaultAccountId: (cfg) => resolveDefaultFabricAccountId(cfg),
|
||||
isConfigured: (account) => Boolean(account.fabricApiKey),
|
||||
},
|
||||
// Minimal setup adapter: Fabric is configured directly under
|
||||
// channels.fabric.* (no interactive wizard). applyAccountConfig is the
|
||||
// only required member.
|
||||
setup: {
|
||||
applyAccountConfig: ({ cfg }) => cfg,
|
||||
},
|
||||
}),
|
||||
messaging: {
|
||||
normalizeTarget: normalizeFabricTarget,
|
||||
resolveSessionTarget: ({ id }) => normalizeFabricTarget(`fabric:${id}`),
|
||||
resolveOutboundSessionRoute: (params) => resolveFabricOutboundSessionRoute(params),
|
||||
targetResolver: { looksLikeId: looksLikeFabricTargetId, hint: '<channelId|fabric:ID>' },
|
||||
},
|
||||
},
|
||||
security: {
|
||||
dm: {
|
||||
channelKey: 'fabric',
|
||||
resolvePolicy: (a) => a.dmPolicy,
|
||||
resolveAllowFrom: (a) => a.allowFrom,
|
||||
defaultPolicy: 'allowlist',
|
||||
},
|
||||
},
|
||||
threading: { topLevelReplyToMode: 'channel' },
|
||||
outbound: {
|
||||
base: {
|
||||
deliveryMode: 'direct',
|
||||
// Fabric has no length limit: never chunk — always one message.
|
||||
chunker: (text) => [text],
|
||||
textChunkLimit: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
attachedResults: {
|
||||
channel: 'fabric',
|
||||
sendText: async (ctx) => {
|
||||
// openclaw passes config under cfg or config depending on path.
|
||||
// Note: inbound agent replies go through inbound.ts `deliver`
|
||||
// (where turn coalescing happens). This path is for any direct
|
||||
// outbound sends and posts immediately.
|
||||
const cfg = (ctx.cfg ?? ctx.config ?? {});
|
||||
try {
|
||||
const r = await sendToFabric(cfg, ctx.accountId ?? null, ctx.to, ctx.text);
|
||||
console.log(`[fabric] outbound.sendText -> ${ctx.to} ok`);
|
||||
return r;
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`[fabric] outbound.sendText FAILED to=${ctx.to}: ${String(e)}`);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
75
dist/fabric/src/coalesce.js
vendored
Normal file
75
dist/fabric/src/coalesce.js
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
// Deterministic turn coalescer.
|
||||
//
|
||||
// OpenClaw calls the Fabric `deliver` callback once per assistant text
|
||||
// segment; a thinking/tool block between two text blocks is a delivery
|
||||
// boundary, so one agent turn of `text → thinking/tool → text` arrives as
|
||||
// multiple deliver() calls. There is no turn id on the delivery, so we
|
||||
// BUFFER segments by Fabric channelId and post the merged message when the
|
||||
// turn truly ends. The flush is driven by inbound.ts right after
|
||||
// `dispatchInboundReplyWithBase` resolves — that only happens AFTER every
|
||||
// deliver() of the turn, a deterministic boundary (NOT a timer, NOT the
|
||||
// agent_end hook, which fires before deliver()). `coalesce=false` posts
|
||||
// each segment immediately.
|
||||
const SAFETY_FLUSH_MS = 120_000; // leak-guard only; not the flush mechanism
|
||||
export function normChannelId(x) {
|
||||
const s = String(x ?? '');
|
||||
return s.startsWith('fabric:') ? s.slice('fabric:'.length) : s;
|
||||
}
|
||||
const pendingByChannel = new Map();
|
||||
async function flushChannel(channelId, reason) {
|
||||
const p = pendingByChannel.get(channelId);
|
||||
if (!p)
|
||||
return;
|
||||
pendingByChannel.delete(channelId);
|
||||
clearTimeout(p.safety);
|
||||
const text = p.parts.join('\n\n').trim();
|
||||
if (!text)
|
||||
return;
|
||||
try {
|
||||
await p.post(text);
|
||||
p.log?.(`fabric: flushed ${p.parts.length} segment(s) channel=${channelId} (${reason})`);
|
||||
}
|
||||
catch (e) {
|
||||
p.log?.(`fabric: flush FAILED channel=${channelId} (${reason}): ${String(e)}`);
|
||||
}
|
||||
}
|
||||
// Buffer one delivered segment (or send immediately when coalesce=false).
|
||||
// `post` performs the real Fabric postMessage with the caller's already
|
||||
// resolved guild/token; on flush it is called once with the merged text.
|
||||
export async function enqueueDelivery(params) {
|
||||
const cid = normChannelId(params.channelId);
|
||||
const text = (params.text ?? '').trim();
|
||||
if (!text)
|
||||
return;
|
||||
if (!params.coalesce) {
|
||||
await params.post(text);
|
||||
return;
|
||||
}
|
||||
const existing = pendingByChannel.get(cid);
|
||||
if (existing) {
|
||||
existing.parts.push(text);
|
||||
existing.post = params.post; // freshest guild/token closure
|
||||
existing.log = params.log;
|
||||
}
|
||||
else {
|
||||
pendingByChannel.set(cid, {
|
||||
parts: [text],
|
||||
post: params.post,
|
||||
log: params.log,
|
||||
safety: setTimeout(() => void flushChannel(cid, 'safety-timeout'), SAFETY_FLUSH_MS),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Called by the agent_end hook with the hook ctx's channelId (bare or
|
||||
// fabric:-prefixed). Deterministic per-turn boundary.
|
||||
export async function flushFabricForChannel(rawChannelId) {
|
||||
const cid = normChannelId(rawChannelId);
|
||||
if (cid)
|
||||
await flushChannel(cid, 'dispatch-end');
|
||||
}
|
||||
// gateway_stop: flush anything still buffered.
|
||||
export async function flushAllFabric() {
|
||||
for (const cid of [...pendingByChannel.keys()]) {
|
||||
await flushChannel(cid, 'gateway_stop');
|
||||
}
|
||||
}
|
||||
109
dist/fabric/src/command-sync.js
vendored
Normal file
109
dist/fabric/src/command-sync.js
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
// Build the Fabric slash-command catalog from OpenClaw's native-command
|
||||
// specs (the same source Discord uses to register slash commands) and push
|
||||
// it to each connected guild. Fabric is a TEXT-command surface: a /<cmd>
|
||||
// message is delivered normally and OpenClaw's command system executes it —
|
||||
// this catalog only drives the frontend `/` autocomplete, so we resolve any
|
||||
// dynamic arg `choices` to a static snapshot here (like Discord does at
|
||||
// registration time).
|
||||
import { listNativeCommandSpecsForConfig, findCommandByNativeName, resolveCommandArgChoices, } from 'openclaw/plugin-sdk/native-command-registry';
|
||||
import { resolveCommandsSyncKey } from './accounts.js';
|
||||
function normChoice(c) {
|
||||
if (typeof c === 'string')
|
||||
return { value: c, label: c };
|
||||
const o = c;
|
||||
return { value: String(o.value ?? ''), label: String(o.label ?? o.value ?? '') };
|
||||
}
|
||||
export function buildFabricCommandSpecs(cfg) {
|
||||
const specs = listNativeCommandSpecsForConfig(cfg, {
|
||||
provider: 'fabric',
|
||||
});
|
||||
return specs.map((s) => {
|
||||
// ChatCommandDefinition (for argsParsing + dynamic choices provider)
|
||||
const def = findCommandByNativeName(s.name, 'fabric');
|
||||
const args = (s.args ?? []).map((a) => {
|
||||
const raw = a.choices;
|
||||
let choices = null;
|
||||
if (Array.isArray(raw)) {
|
||||
choices = raw.map(normChoice);
|
||||
}
|
||||
else if (typeof raw === 'function' && def) {
|
||||
try {
|
||||
const r = resolveCommandArgChoices({
|
||||
command: def,
|
||||
arg: a,
|
||||
cfg: cfg,
|
||||
provider: 'fabric',
|
||||
});
|
||||
choices = r.map((x) => ({ value: x.value, label: x.label }));
|
||||
}
|
||||
catch {
|
||||
choices = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: String(a.name ?? ''),
|
||||
description: String(a.description ?? ''),
|
||||
type: String(a.type ?? 'string'),
|
||||
required: !!a.required,
|
||||
captureRemaining: !!a.captureRemaining,
|
||||
preferAutocomplete: !!a.preferAutocomplete,
|
||||
choices,
|
||||
};
|
||||
});
|
||||
return {
|
||||
name: s.name,
|
||||
nativeName: s.name,
|
||||
description: s.description,
|
||||
acceptsArgs: !!s.acceptsArgs,
|
||||
args,
|
||||
argsParsing: def?.argsParsing ?? 'positional',
|
||||
};
|
||||
});
|
||||
}
|
||||
// Push the catalog to every guild the known agents belong to (idempotent;
|
||||
// the catalog is OpenClaw-global, so one PUT per guild is enough).
|
||||
export async function syncFabricCommands(client, cfg, accounts, log) {
|
||||
// Guild C-2: the sync key comes from the channel config only (schema
|
||||
// marks it required). Without it the guild rejects the catalog write.
|
||||
const syncKey = resolveCommandsSyncKey(cfg);
|
||||
if (!syncKey) {
|
||||
log.warn('fabric: channels.fabric.commandsSyncKey is not set — skipping ' +
|
||||
'slash-command sync (set it to the guild FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY)');
|
||||
return;
|
||||
}
|
||||
let specs;
|
||||
try {
|
||||
specs = buildFabricCommandSpecs(cfg);
|
||||
}
|
||||
catch (err) {
|
||||
log.warn(`fabric: build command specs failed: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (!specs.length)
|
||||
return;
|
||||
const done = new Set();
|
||||
for (const a of accounts) {
|
||||
let session;
|
||||
try {
|
||||
session = await client.agentLogin(a.fabricApiKey);
|
||||
}
|
||||
catch {
|
||||
continue;
|
||||
}
|
||||
for (const g of session.guilds) {
|
||||
if (done.has(g.nodeId))
|
||||
continue;
|
||||
const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
|
||||
if (!tok)
|
||||
continue;
|
||||
try {
|
||||
await client.syncCommands(g.endpoint, tok, specs, syncKey);
|
||||
done.add(g.nodeId);
|
||||
log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`);
|
||||
}
|
||||
catch (err) {
|
||||
log.warn(`fabric: command sync failed ${g.nodeId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
dist/fabric/src/fabric-client.js
vendored
55
dist/fabric/src/fabric-client.js
vendored
@@ -21,6 +21,25 @@ export class FabricClient {
|
||||
}
|
||||
return (await res.json());
|
||||
}
|
||||
// Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null
|
||||
// (Fabric returns an empty body when a channel has no canvas).
|
||||
async req(method, url, auth, body, extraHeaders) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
...(auth ? { authorization: `Bearer ${auth}` } : {}),
|
||||
...(extraHeaders ?? {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${url} -> ${res.status} ${text}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : null);
|
||||
}
|
||||
// Exchange an agent API key for a Fabric user session (+ guild tokens).
|
||||
agentLogin(apiKey) {
|
||||
return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey });
|
||||
@@ -50,4 +69,40 @@ export class FabricClient {
|
||||
joinChannel(guildEndpoint, guildToken, channelId) {
|
||||
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
|
||||
}
|
||||
leaveChannel(guildEndpoint, guildToken, channelId) {
|
||||
return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken);
|
||||
}
|
||||
// Register the OpenClaw slash-command catalog with this guild (idempotent
|
||||
// full replace). The frontend GETs it for `/` autocomplete; execution
|
||||
// still flows as a normal /<cmd> message into OpenClaw's command system.
|
||||
syncCommands(guildEndpoint, guildToken, commands, syncKey) {
|
||||
// Guild C-2: the shared key is sourced from the channel config
|
||||
// (channels.fabric.commandsSyncKey) and must equal the guild's
|
||||
// FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY for the catalog write.
|
||||
return this.req('PUT', `${guildEndpoint}/api/commands`, guildToken, { commands }, syncKey ? { 'x-commands-sync-key': syncKey } : undefined);
|
||||
}
|
||||
// [{ userId, bypass }] — bypass is true only for discuss/work bypass-list
|
||||
channelMembers(guildEndpoint, guildToken, channelId) {
|
||||
return this.req('GET', `${guildEndpoint}/api/channels/${channelId}/members`, guildToken);
|
||||
}
|
||||
// ---- channel canvas (one pinned doc per channel) ----
|
||||
canvasUrl(endpoint, channelId) {
|
||||
return `${endpoint}/api/channels/${channelId}/canvas`;
|
||||
}
|
||||
// null when the channel has no canvas
|
||||
getCanvas(endpoint, token, channelId) {
|
||||
return this.req('GET', this.canvasUrl(endpoint, channelId), token);
|
||||
}
|
||||
// share / replace (caller becomes the sharer)
|
||||
shareCanvas(endpoint, token, channelId, body) {
|
||||
return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body);
|
||||
}
|
||||
// update in place (original sharer only — else the guild returns 403)
|
||||
updateCanvas(endpoint, token, channelId, body) {
|
||||
return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body);
|
||||
}
|
||||
// remove ("close") the canvas (original sharer only)
|
||||
removeCanvas(endpoint, token, channelId) {
|
||||
return this.req('DELETE', this.canvasUrl(endpoint, channelId), token);
|
||||
}
|
||||
}
|
||||
|
||||
364
dist/fabric/src/inbound.js
vendored
364
dist/fabric/src/inbound.js
vendored
@@ -1,23 +1,71 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { io } from 'socket.io-client';
|
||||
// One live Fabric connection per agent identity (Phase 1 = B1). Lives in the
|
||||
// channel-plugin runtime (no separate sidecar). Firehose (B2) would replace
|
||||
// this class behind the same dispatch() call.
|
||||
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
||||
import { resolveCoalesce } from './accounts.js';
|
||||
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
|
||||
export class FabricInbound {
|
||||
runtime;
|
||||
core;
|
||||
cfg;
|
||||
client;
|
||||
identity;
|
||||
log;
|
||||
accounts;
|
||||
sockets = [];
|
||||
timers = [];
|
||||
seen = new Set();
|
||||
constructor(runtime, client, identity, log) {
|
||||
this.runtime = runtime;
|
||||
// Timers that periodically re-sync channel membership per (agent, guild).
|
||||
// Without this, the agent's socket.io subscriptions are a snapshot taken
|
||||
// at connect time — any channel the agent joins later (e.g. a fresh DM
|
||||
// created by another user) is unreachable until the gateway restarts.
|
||||
channelSyncTimers = [];
|
||||
// Resync cadence. Backend doesn't push a `channel.joined` event, so we
|
||||
// poll. 60s keeps the lag bounded without hammering the backend.
|
||||
static CHANNEL_SYNC_INTERVAL_MS = 60_000;
|
||||
// Guild access tokens are short-lived (~15 min). The socket survives via
|
||||
// socket.io reconnect, but the token captured at connect time goes stale,
|
||||
// so HTTP calls (attachment download, posting the reply) start 401ing.
|
||||
// Re-login per agent on a short TTL to keep a fresh token.
|
||||
tokenCache = new Map();
|
||||
static TOKEN_TTL_MS = 8 * 60 * 1000;
|
||||
// Return a fresh guild access token for the agent, re-authenticating with
|
||||
// the agent's Fabric API key when the cached session is stale. Falls back
|
||||
// to the connect-time session token if re-login fails.
|
||||
async freshGuildToken(agentId, guildNodeId, fallback) {
|
||||
const pick = (s) => s.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
||||
const now = Date.now();
|
||||
const cached = this.tokenCache.get(agentId);
|
||||
if (cached && now - cached.at < FabricInbound.TOKEN_TTL_MS) {
|
||||
return pick(cached.session) ?? pick(fallback);
|
||||
}
|
||||
const apiKey = this.identity.findByAgentId(agentId)?.fabricApiKey;
|
||||
if (apiKey) {
|
||||
try {
|
||||
const s = await this.client.agentLogin(apiKey);
|
||||
this.tokenCache.set(agentId, { session: s, at: now });
|
||||
return pick(s) ?? pick(fallback);
|
||||
}
|
||||
catch (err) {
|
||||
this.log.warn(`fabric: token refresh failed agent=${agentId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return pick(fallback);
|
||||
}
|
||||
constructor(core, // PluginRuntime
|
||||
cfg, // OpenClawConfig
|
||||
client, identity, log, accounts = []) {
|
||||
this.core = core;
|
||||
this.cfg = cfg;
|
||||
this.client = client;
|
||||
this.identity = identity;
|
||||
this.log = log;
|
||||
this.accounts = accounts;
|
||||
}
|
||||
async start() {
|
||||
for (const entry of this.identity.list()) {
|
||||
const entries = this.accounts.length > 0
|
||||
? this.accounts.map((a) => ({ agentId: a.agentId, fabricApiKey: a.fabricApiKey }))
|
||||
: this.identity.list();
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const session = await this.client.agentLogin(entry.fabricApiKey);
|
||||
this.identity.upsert({
|
||||
@@ -35,12 +83,12 @@ export class FabricInbound {
|
||||
}
|
||||
}
|
||||
stop() {
|
||||
for (const t of this.timers)
|
||||
for (const t of this.channelSyncTimers)
|
||||
clearInterval(t);
|
||||
this.channelSyncTimers = [];
|
||||
for (const s of this.sockets)
|
||||
s.disconnect();
|
||||
this.sockets = [];
|
||||
this.timers = [];
|
||||
}
|
||||
async connectAgent(agentId, session) {
|
||||
const selfUserId = session.user.id;
|
||||
@@ -53,25 +101,87 @@ export class FabricInbound {
|
||||
auth: { token: tok },
|
||||
autoConnect: false,
|
||||
});
|
||||
const joinAll = async () => {
|
||||
// Tracked socket.io rooms for this (agent, guild). The initial fetch
|
||||
// on `connect` seeds it; the periodic resync diffs against it so we
|
||||
// only emit `join_channel` for genuinely new channels (and
|
||||
// `leave_channel` for ones the agent is no longer in).
|
||||
const joined = new Set();
|
||||
const syncChannels = async (kind) => {
|
||||
let freshTok;
|
||||
try {
|
||||
const res = await fetch(`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`, {
|
||||
headers: { authorization: `Bearer ${tok}` },
|
||||
});
|
||||
const channels = res.ok ? (await res.json()) : [];
|
||||
for (const c of channels)
|
||||
socket.emit('join_channel', { channelId: c.id });
|
||||
freshTok = await this.freshGuildToken(agentId, g.nodeId, session);
|
||||
}
|
||||
catch {
|
||||
/* best effort */
|
||||
freshTok = tok;
|
||||
}
|
||||
const authTok = freshTok ?? tok;
|
||||
try {
|
||||
const res = await fetch(`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`, { headers: { authorization: `Bearer ${authTok}` } });
|
||||
if (!res.ok)
|
||||
return;
|
||||
const channels = (await res.json());
|
||||
const current = new Set(channels.map((c) => c.id));
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const id of current) {
|
||||
if (!joined.has(id)) {
|
||||
socket.emit('join_channel', { channelId: id });
|
||||
joined.add(id);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
for (const id of [...joined]) {
|
||||
if (!current.has(id)) {
|
||||
socket.emit('leave_channel', { channelId: id });
|
||||
joined.delete(id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (kind === 'initial') {
|
||||
this.log.info(`fabric: agent ${agentId} joined ${current.size} channel(s) on ${g.nodeId}`);
|
||||
}
|
||||
else if (added > 0 || removed > 0) {
|
||||
this.log.info(`fabric: agent ${agentId} channel resync on ${g.nodeId}: +${added} -${removed} (now ${joined.size})`);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
/* best effort — next tick will retry */
|
||||
}
|
||||
};
|
||||
socket.on('connect', () => void joinAll());
|
||||
socket.on('connect', () => {
|
||||
// On every (re)connect the server forgets prior subscriptions, so
|
||||
// reset our local view and seed from a fresh fetch.
|
||||
joined.clear();
|
||||
void syncChannels('initial');
|
||||
});
|
||||
// Push-based membership events from the backend (companion to
|
||||
// Fabric.Backend.Guild's RealtimeGateway.emitToUser). When the
|
||||
// server tells us this user was added to / removed from a
|
||||
// channel, we sub/unsub the socket.io room immediately — no
|
||||
// 60s wait for the polling resync. Polling remains as a safety
|
||||
// net for missed events.
|
||||
socket.on('channel.joined', (evt) => {
|
||||
const id = evt?.channelId;
|
||||
if (!id || joined.has(id))
|
||||
return;
|
||||
socket.emit('join_channel', { channelId: id });
|
||||
joined.add(id);
|
||||
this.log.info(`fabric: agent ${agentId} channel.joined push on ${g.nodeId}: ${id} (now ${joined.size})`);
|
||||
});
|
||||
socket.on('channel.left', (evt) => {
|
||||
const id = evt?.channelId;
|
||||
if (!id || !joined.has(id))
|
||||
return;
|
||||
socket.emit('leave_channel', { channelId: id });
|
||||
joined.delete(id);
|
||||
this.log.info(`fabric: agent ${agentId} channel.left push on ${g.nodeId}: ${id} (now ${joined.size})`);
|
||||
});
|
||||
const syncTimer = setInterval(() => void syncChannels('resync'), FabricInbound.CHANNEL_SYNC_INTERVAL_MS);
|
||||
this.channelSyncTimers.push(syncTimer);
|
||||
socket.on('message.created', (m) => {
|
||||
const channelId = m.channelId ?? '';
|
||||
if (!channelId)
|
||||
return;
|
||||
// self-echo guard + dedupe
|
||||
if (m.authorUserId && m.authorUserId === selfUserId)
|
||||
return;
|
||||
const key = `${agentId}:${m.messageId}`;
|
||||
@@ -80,67 +190,181 @@ export class FabricInbound {
|
||||
this.seen.add(key);
|
||||
if (this.seen.size > 5000)
|
||||
this.seen.clear();
|
||||
void this.dispatch(agentId, g, channelId, m);
|
||||
void this.dispatch(agentId, g, channelId, m, session);
|
||||
});
|
||||
socket.connect();
|
||||
this.sockets.push(socket);
|
||||
}
|
||||
}
|
||||
// Hand the inbound Fabric message to OpenClaw's channel-turn kernel.
|
||||
// wakeup === true -> dispatch (agent runs, may reply)
|
||||
// wakeup !== true -> drop but keep as group history/context
|
||||
async dispatch(agentId, guild, channelId, m) {
|
||||
const admit = m.wakeup === true;
|
||||
// Download a message's attachments to a temp dir using the agent's guild
|
||||
// token; returns local paths/types/urls for the inbound media context.
|
||||
async fetchAttachments(agentId, endpoint, token, m) {
|
||||
const out = { paths: [], types: [], urls: [] };
|
||||
const list = m.attachments ?? [];
|
||||
if (!list.length || !token)
|
||||
return out;
|
||||
const dir = join(tmpdir(), `fabric-media-${agentId}-${m.messageId}`.replace(/[^\w.-]/g, '_'));
|
||||
try {
|
||||
await this.runtime.channel.turn.run({
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
catch {
|
||||
return out;
|
||||
}
|
||||
let i = 0;
|
||||
for (const a of list) {
|
||||
try {
|
||||
const abs = a.url.startsWith('http') ? a.url : `${endpoint}${a.url}`;
|
||||
const res = await fetch(abs, { headers: { authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) {
|
||||
this.log.warn(`fabric: attachment fetch ${res.status} ${abs}`);
|
||||
continue;
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const safe = (a.name ?? `file-${i}`).replace(/[^\w.-]/g, '_').slice(0, 120) || `file-${i}`;
|
||||
const p = join(dir, `${i}-${safe}`);
|
||||
await fs.writeFile(p, buf);
|
||||
out.paths.push(p);
|
||||
out.types.push(a.mimeType ||
|
||||
res.headers.get('content-type')?.split(';')[0] ||
|
||||
'application/octet-stream');
|
||||
out.urls.push(abs);
|
||||
i++;
|
||||
}
|
||||
catch (err) {
|
||||
this.log.warn(`fabric: attachment fetch failed agent=${agentId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
if (out.paths.length)
|
||||
this.log.info(`fabric: fetched ${out.paths.length} attachment(s) agent=${agentId}`);
|
||||
return out;
|
||||
}
|
||||
async dispatch(agentId, guild, channelId, m, session) {
|
||||
const core = this.core;
|
||||
const cfg = this.cfg;
|
||||
try {
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: this.cfg,
|
||||
channel: 'fabric',
|
||||
accountId: agentId,
|
||||
raw: m,
|
||||
adapter: {
|
||||
ingest: (raw) => ({
|
||||
id: raw.messageId,
|
||||
timestamp: raw.createdAt ? Date.parse(raw.createdAt) : Date.now(),
|
||||
rawText: raw.content,
|
||||
textForAgent: raw.content,
|
||||
}),
|
||||
classify: () => ({ kind: 'message', canStartAgentTurn: admit }),
|
||||
preflight: () => admit ? {} : { admission: { kind: 'drop', reason: 'no-wakeup', recordHistory: true } },
|
||||
resolveTurn: (input) => ({
|
||||
route: {
|
||||
agentId,
|
||||
routeSessionKey: `agent:${agentId}:fabric:channel:${channelId}`,
|
||||
createIfMissing: true,
|
||||
},
|
||||
conversation: { kind: 'channel', id: channelId, label: `fabric:${guild.nodeId}` },
|
||||
reply: { to: channelId, nativeChannelId: channelId },
|
||||
message: {
|
||||
body: m.content,
|
||||
rawBody: m.content,
|
||||
bodyForAgent: m.content,
|
||||
envelopeFrom: m.authorUserId ?? 'fabric',
|
||||
},
|
||||
delivery: {
|
||||
deliver: async (payload) => {
|
||||
const text = typeof payload?.text === 'string' ? payload.text : '';
|
||||
if (!text.trim())
|
||||
return { visibleReplySent: false };
|
||||
const entry = this.identity.findByAgentId(agentId);
|
||||
const session = entry ? await this.client.agentLogin(entry.fabricApiKey) : null;
|
||||
const gt = session?.guildAccessTokens.find((t) => t.guildNodeId === guild.nodeId)?.token;
|
||||
if (!session || !gt)
|
||||
return { visibleReplySent: false };
|
||||
await this.client.postMessage(guild.endpoint, gt, channelId, text, session.user.id);
|
||||
return { visibleReplySent: true };
|
||||
},
|
||||
},
|
||||
meta: { admission: admit ? { kind: 'dispatch' } : { kind: 'drop', recordHistory: true } },
|
||||
}),
|
||||
},
|
||||
log: (e) => this.runtime.log?.debug?.(`fabric.turn.${e?.stage}`),
|
||||
peer: { kind: 'group', id: channelId },
|
||||
});
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const baseCtx = {
|
||||
Body: m.content,
|
||||
BodyForAgent: m.content,
|
||||
RawBody: m.content,
|
||||
CommandBody: m.content,
|
||||
From: `fabric:channel:${channelId}`,
|
||||
To: `fabric:${channelId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId ?? agentId,
|
||||
ChatType: 'group',
|
||||
ConversationLabel: `fabric:${guild.nodeId}`,
|
||||
SenderId: m.authorUserId ?? 'fabric',
|
||||
Provider: 'fabric',
|
||||
Surface: 'fabric',
|
||||
MessageSid: m.messageId,
|
||||
Timestamp: m.createdAt ? Date.parse(m.createdAt) : Date.now(),
|
||||
OriginatingChannel: 'fabric',
|
||||
OriginatingTo: `fabric:${channelId}`,
|
||||
};
|
||||
// Non-wakeup: Fabric has already decided this agent is NOT the speaker
|
||||
// this round. Do NOT run the model and do NOT send anything back — the
|
||||
// discuss/work turn engine expects silence from non-woken agents (only
|
||||
// the woken speaker emits a normal message or /no-reply). We still
|
||||
// record the message into the agent's session so it has the full
|
||||
// channel conversation as context whenever it IS later woken.
|
||||
//
|
||||
// Exception: dm channels are 1:1 — there is no turn/wakeup gating;
|
||||
// any message that isn't the agent's own (already filtered above) is
|
||||
// always delivered to the model.
|
||||
if (m.xType !== 'dm' && m.wakeup !== true) {
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext(baseCtx);
|
||||
await core.channel.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
createIfMissing: true,
|
||||
onRecordError: (err) => this.log.warn(`fabric: history record failed agent=${agentId}: ${String(err)}`),
|
||||
});
|
||||
this.log.info(`fabric: recorded (no wakeup, history only) agent=${agentId} channel=${channelId}`);
|
||||
return;
|
||||
}
|
||||
this.log.info(`fabric: dispatch agent=${agentId} channel=${channelId}`);
|
||||
const gt = await this.freshGuildToken(agentId, guild.nodeId, session);
|
||||
// Fetch any uploaded files for the agent: download to a temp dir and
|
||||
// hand openclaw local MediaPaths (+types) so the model receives them.
|
||||
const media = await this.fetchAttachments(agentId, guild.endpoint, gt, m);
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
...baseCtx,
|
||||
// Provide ONLY local paths. The guild file URL is on a private host
|
||||
// (e.g. localhost); openclaw's SSRF guard blocks re-fetching it, so
|
||||
// passing MediaUrls is both redundant (we already downloaded the
|
||||
// bytes) and noisy. Local MediaPaths is the reliable delivery.
|
||||
...(media.paths.length
|
||||
? {
|
||||
MediaPaths: media.paths,
|
||||
MediaTypes: media.types,
|
||||
MediaPath: media.paths[0],
|
||||
MediaType: media.types[0],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await dispatchInboundReplyWithBase({
|
||||
cfg: this.cfg,
|
||||
channel: 'fabric',
|
||||
accountId: agentId,
|
||||
route,
|
||||
storePath,
|
||||
ctxPayload: ctxPayload,
|
||||
core: this.core,
|
||||
deliver: async (payload) => {
|
||||
const text = (payload?.text ?? '').trim();
|
||||
this.log.info(`fabric: deliver agent=${agentId} channel=${channelId} len=${text.length}`);
|
||||
if (!text || !gt)
|
||||
return;
|
||||
// Buffer segments; the merged message is posted right after
|
||||
// dispatch returns (the deterministic turn boundary, see the
|
||||
// finally below). Disable per channel: channels.fabric.coalesce.
|
||||
await enqueueDelivery({
|
||||
channelId,
|
||||
text,
|
||||
coalesce: resolveCoalesce(this.cfg),
|
||||
post: (t) => this.client.postMessage(guild.endpoint, gt, channelId, t, session.user.id),
|
||||
log: (m) => this.log.info(m),
|
||||
});
|
||||
},
|
||||
onRecordError: (err) => this.log.warn(`fabric: session record failed agent=${agentId}: ${String(err)}`),
|
||||
onDispatchError: (err, info) => this.log.warn(`fabric: ${info.kind} dispatch failed agent=${agentId}: ${String(err)}`),
|
||||
// - disableBlockStreaming: Fabric has no length limit, deliver the
|
||||
// whole reply as ONE message.
|
||||
// - sourceReplyDeliveryMode 'automatic': OpenClaw defaults group
|
||||
// chats to "message_tool_only", which SUPPRESSES auto-delivery of
|
||||
// the agent's text reply (it expects the agent to call a message
|
||||
// tool). Fabric already gates *when* an agent speaks via the
|
||||
// per-recipient wakeup flag, so once a turn is dispatched the
|
||||
// reply must always flow back through `deliver`. Forcing
|
||||
// 'automatic' overrides the group default so the reply is
|
||||
// delivered. (source-reply-delivery-mode: a truthy `requested`
|
||||
// wins unless it's message_tool_only with no tool available.)
|
||||
replyOptions: {
|
||||
disableBlockStreaming: true,
|
||||
sourceReplyDeliveryMode: 'automatic',
|
||||
},
|
||||
});
|
||||
this.log.info(`fabric: dispatch returned agent=${agentId} channel=${channelId}`);
|
||||
}
|
||||
catch (err) {
|
||||
this.log.warn(`fabric: turn.run failed agent=${agentId} channel=${channelId}: ${String(err)}`);
|
||||
this.log.warn(`fabric: dispatch failed agent=${agentId} channel=${channelId}: ${String(err)}`);
|
||||
}
|
||||
finally {
|
||||
// Deterministic per-turn boundary: dispatchInboundReplyWithBase only
|
||||
// resolves AFTER every deliver() call of this turn has run, so the
|
||||
// buffer now holds all segments — flush them as ONE Fabric message.
|
||||
// No hooks, no timers, no idle guessing.
|
||||
await flushFabricForChannel(channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
155
dist/fabric/src/tools.js
vendored
155
dist/fabric/src/tools.js
vendored
@@ -9,7 +9,9 @@ export function registerFabricTools(api, client, identity) {
|
||||
const ctxGuild = async (agentId, guildNodeId) => {
|
||||
const entry = identity.findByAgentId(agentId);
|
||||
if (!entry)
|
||||
throw new Error(`agent ${agentId} not registered (call fabric-register)`);
|
||||
throw new Error(`agent ${agentId} not registered — run: AGENT_ID=${agentId} ` +
|
||||
`~/.openclaw/bin/fabric-register --api-key <fak_…> (or set ` +
|
||||
`channels.fabric.accounts.${agentId}); then restart the gateway`);
|
||||
const session = await client.agentLogin(entry.fabricApiKey);
|
||||
const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
|
||||
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
||||
@@ -17,36 +19,14 @@ export function registerFabricTools(api, client, identity) {
|
||||
throw new Error(`agent not a member of guild ${guildNodeId}`);
|
||||
return { session, guild, token };
|
||||
};
|
||||
// fabric-register: bind this agent to a Fabric API key.
|
||||
api.registerTool((ctx) => ({
|
||||
name: 'fabric-register',
|
||||
description: "Register this agent's Fabric API key (minted via Center CLI `user apikey`).",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['fabricApiKey'],
|
||||
properties: {
|
||||
fabricApiKey: { type: 'string', description: 'Fabric Center API key (fak_…)' },
|
||||
},
|
||||
},
|
||||
handler: async (params) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
const session = await client.agentLogin(params.fabricApiKey);
|
||||
identity.upsert({
|
||||
agentId,
|
||||
fabricApiKey: params.fabricApiKey,
|
||||
fabricUserId: session.user.id,
|
||||
displayName: session.user.name,
|
||||
});
|
||||
return { ok: true, user: session.user };
|
||||
},
|
||||
}));
|
||||
// NOTE: binding an agent's Fabric API key is intentionally NOT a tool.
|
||||
// It's a one-time step done out-of-band via the installed script
|
||||
// ~/.openclaw/bin/fabric-register --api-key <fak_…> (AGENT_ID or --agent-id)
|
||||
// or via static config (channels.fabric.accounts.<agentId>).
|
||||
const makeCreate = (kind) => api.registerTool((ctx) => ({
|
||||
name: `create-${kind}-channel`,
|
||||
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`,
|
||||
inputSchema: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId', 'name'],
|
||||
@@ -59,7 +39,7 @@ export function registerFabricTools(api, client, identity) {
|
||||
listeners: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
handler: async (p) => {
|
||||
execute: async (p) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
@@ -83,7 +63,7 @@ export function registerFabricTools(api, client, identity) {
|
||||
api.registerTool((ctx) => ({
|
||||
name: 'discussion-complete',
|
||||
description: 'Conclude a discussion: post a summary then close the channel.',
|
||||
inputSchema: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId', 'channelId', 'summary'],
|
||||
@@ -94,7 +74,7 @@ export function registerFabricTools(api, client, identity) {
|
||||
callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' },
|
||||
},
|
||||
},
|
||||
handler: async (p) => {
|
||||
execute: async (p) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
@@ -109,4 +89,117 @@ export function registerFabricTools(api, client, identity) {
|
||||
return { ok: true, closed: true };
|
||||
},
|
||||
}));
|
||||
// fabric-canvas: share / update / read / close the channel's single
|
||||
// pinned canvas document (one tool, four actions). update/close are
|
||||
// sharer-only server-side (the guild returns 403 otherwise).
|
||||
api.registerTool((ctx) => ({
|
||||
name: 'fabric-canvas',
|
||||
description: "Manage a channel's pinned canvas document. action: " +
|
||||
"read (current canvas or null) | share (create/replace; you become " +
|
||||
'the sharer) | update (edit in place; sharer only) | close (remove; ' +
|
||||
'sharer only).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action', 'guildNodeId', 'channelId'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['read', 'share', 'update', 'close'] },
|
||||
guildNodeId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
title: { type: 'string', description: 'share: required; update: optional' },
|
||||
format: {
|
||||
type: 'string',
|
||||
enum: ['md', 'html', 'text'],
|
||||
description: 'share: required; update: optional',
|
||||
},
|
||||
source: {
|
||||
type: 'string',
|
||||
description: 'document body. share: required; update: optional',
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: async (p) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const ep = guild.endpoint;
|
||||
switch (p.action) {
|
||||
case 'read': {
|
||||
const canvas = await client.getCanvas(ep, token, p.channelId);
|
||||
return { ok: true, canvas };
|
||||
}
|
||||
case 'share': {
|
||||
if (!p.title || !p.format || p.source === undefined) {
|
||||
return { ok: false, error: 'share requires title, format, and source' };
|
||||
}
|
||||
const canvas = await client.shareCanvas(ep, token, p.channelId, {
|
||||
title: p.title,
|
||||
format: p.format,
|
||||
source: p.source,
|
||||
});
|
||||
return { ok: true, canvas };
|
||||
}
|
||||
case 'update': {
|
||||
const body = {};
|
||||
if (p.title !== undefined)
|
||||
body.title = p.title;
|
||||
if (p.format !== undefined)
|
||||
body.format = p.format;
|
||||
if (p.source !== undefined)
|
||||
body.source = p.source;
|
||||
if (Object.keys(body).length === 0) {
|
||||
return { ok: false, error: 'update needs at least one of title/format/source' };
|
||||
}
|
||||
const canvas = await client.updateCanvas(ep, token, p.channelId, body);
|
||||
return { ok: true, canvas };
|
||||
}
|
||||
case 'close': {
|
||||
await client.removeCanvas(ep, token, p.channelId);
|
||||
return { ok: true, removed: true };
|
||||
}
|
||||
default:
|
||||
return { ok: false, error: `unknown action ${String(p.action)}` };
|
||||
}
|
||||
},
|
||||
}));
|
||||
// fabric-channel: channel membership (one tool, three actions).
|
||||
api.registerTool((ctx) => ({
|
||||
name: 'fabric-channel',
|
||||
description: 'Channel membership. action: members (list channel member userIds) | ' +
|
||||
'join (this agent joins the channel) | leave (this agent leaves).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action', 'guildNodeId', 'channelId'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['members', 'join', 'leave'] },
|
||||
guildNodeId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
execute: async (p) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId)
|
||||
return { ok: false, error: 'no agent context' };
|
||||
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const ep = guild.endpoint;
|
||||
switch (p.action) {
|
||||
case 'members': {
|
||||
const members = await client.channelMembers(ep, token, p.channelId);
|
||||
return { ok: true, members };
|
||||
}
|
||||
case 'join': {
|
||||
await client.joinChannel(ep, token, p.channelId);
|
||||
return { ok: true, joined: true };
|
||||
}
|
||||
case 'leave': {
|
||||
await client.leaveChannel(ep, token, p.channelId);
|
||||
return { ok: true, left: true };
|
||||
}
|
||||
default:
|
||||
return { ok: false, error: `unknown action ${String(p.action)}` };
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
95
index.ts
95
index.ts
@@ -1,46 +1,57 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/channel-core';
|
||||
// Fabric channel plugin entry.
|
||||
// COMPAT NOTE (openclaw v2026.5.7): defineChannelPluginEntry signature
|
||||
// { id, name, description, plugin, setRuntime?, registerFull? }. setRuntime
|
||||
// receives the PluginRuntime (has channel.turn kernel); registerFull receives
|
||||
// the OpenClawPluginApi for runtime startup (transport + tools).
|
||||
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
|
||||
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core';
|
||||
import { fabricChannelPlugin } from './src/channel.js';
|
||||
import { flushAllFabric } from './src/coalesce.js';
|
||||
import { FabricInbound } from './src/inbound.js';
|
||||
import { listEnabledFabricAccounts } from './src/accounts.js';
|
||||
import { registerFabricTools } from './src/tools.js';
|
||||
import { FabricClient } from './src/fabric-client.js';
|
||||
import { IdentityRegistry } from './src/identity.js';
|
||||
import { FabricInbound } from './src/inbound.js';
|
||||
import { buildFabricChannelPlugin } from './src/channel.js';
|
||||
import { registerFabricTools } from './src/tools.js';
|
||||
|
||||
type PluginConfig = { identityFilePath?: string; debugMode?: boolean };
|
||||
|
||||
function centerApiBase(api: OpenClawPluginApi): string {
|
||||
const section = ((api.config as Record<string, unknown>)?.channels as Record<string, any>)?.[
|
||||
'fabric'
|
||||
];
|
||||
return section?.centerApiBase ?? 'http://localhost:7001/api';
|
||||
}
|
||||
import { syncFabricCommands } from './src/command-sync.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
let runtimeRef: unknown = null;
|
||||
let inbound: FabricInbound | null = null;
|
||||
|
||||
export { fabricChannelPlugin } from './src/channel.js';
|
||||
|
||||
export default defineChannelPluginEntry({
|
||||
id: 'fabric',
|
||||
name: 'Fabric',
|
||||
description: 'Fabric channel plugin — OpenClaw agents speak in Fabric guilds',
|
||||
plugin: fabricChannelPlugin,
|
||||
|
||||
// Channel object: config/security/outbound. Visible turn replies flow
|
||||
// through the inbound channel-turn delivery adapter; outbound.sendText
|
||||
// covers proactive sends via the shared message tool.
|
||||
plugin: buildFabricChannelPlugin(async () => ({ messageId: undefined })),
|
||||
setRuntime(runtime: unknown) {
|
||||
runtimeRef = runtime;
|
||||
},
|
||||
|
||||
// registerFull: runtime pieces (transport, tools). Guarded so the long-lived
|
||||
// Fabric connections start once per gateway process.
|
||||
registerFull(api: OpenClawPluginApi) {
|
||||
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
||||
const identityFilePath =
|
||||
cfg.identityFilePath ?? path.join(os.homedir(), '.openclaw', 'fabric-identity.json');
|
||||
|
||||
const client = new FabricClient(centerApiBase(api));
|
||||
const identity = new IdentityRegistry(identityFilePath);
|
||||
registerFull(apiRaw: OpenClawPluginApi) {
|
||||
// COMPAT: access the subset we use through a loose view so SDK type
|
||||
// drift in unrelated api members doesn't break the build.
|
||||
const api = apiRaw as unknown as {
|
||||
config?: unknown;
|
||||
pluginConfig?: { identityFilePath?: string };
|
||||
logger: { info: (m: string) => void; warn: (m: string) => void };
|
||||
on: (ev: string, fn: (...args: unknown[]) => unknown) => void;
|
||||
registerTool: (d: unknown) => void;
|
||||
};
|
||||
const cfg = (api.config ?? {}) as { channels?: { fabric?: { centerApiBase?: string } } };
|
||||
const centerApiBase = cfg.channels?.fabric?.centerApiBase ?? 'http://localhost:7001/api';
|
||||
const idFile =
|
||||
api.pluginConfig?.identityFilePath ??
|
||||
path.join(os.homedir(), '.openclaw', 'fabric-identity.json');
|
||||
|
||||
// tools operate against a default Center; per-account keys come from config
|
||||
const client = new FabricClient(centerApiBase);
|
||||
const identity = new IdentityRegistry(idFile);
|
||||
registerFabricTools(
|
||||
{ registerTool: (d) => api.registerTool(d as never), logger: api.logger },
|
||||
{ registerTool: (d) => api.registerTool(d), logger: api.logger },
|
||||
client,
|
||||
identity,
|
||||
);
|
||||
@@ -49,17 +60,39 @@ export default defineChannelPluginEntry({
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
if (_G._fabricInboundStarted) return;
|
||||
_G._fabricInboundStarted = true;
|
||||
const accounts = listEnabledFabricAccounts(cfg as never).map((a) => ({
|
||||
agentId: a.accountId,
|
||||
fabricApiKey: a.fabricApiKey,
|
||||
}));
|
||||
// also include any tool-registered identities
|
||||
for (const e of identity.list()) {
|
||||
if (!accounts.some((x) => x.agentId === e.agentId)) {
|
||||
accounts.push({ agentId: e.agentId, fabricApiKey: e.fabricApiKey });
|
||||
}
|
||||
}
|
||||
if (!runtimeRef) {
|
||||
api.logger.warn('fabric: runtime not set; inbound disabled');
|
||||
return;
|
||||
}
|
||||
inbound = new FabricInbound(
|
||||
(api as unknown as { runtime: any }).runtime,
|
||||
runtimeRef,
|
||||
api.config,
|
||||
client,
|
||||
identity,
|
||||
api.logger,
|
||||
accounts,
|
||||
);
|
||||
void inbound.start();
|
||||
api.logger.info('fabric: inbound transport started');
|
||||
api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`);
|
||||
void syncFabricCommands(client, cfg, accounts, api.logger);
|
||||
});
|
||||
|
||||
// Note: the per-turn coalesce flush happens deterministically in
|
||||
// inbound.ts right after dispatchInboundReplyWithBase resolves (that
|
||||
// is the real "all deliveries done" boundary; the agent_end hook fires
|
||||
// BEFORE deliver()). gateway_stop only flushes any leftover buffer.
|
||||
api.on('gateway_stop', () => {
|
||||
void flushAllFabric();
|
||||
inbound?.stop();
|
||||
inbound = null;
|
||||
});
|
||||
|
||||
27
install.mjs
27
install.mjs
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs';
|
||||
import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync, chmodSync } from 'fs';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { homedir } from 'os';
|
||||
@@ -109,12 +109,30 @@ function build() {
|
||||
ok('compiled -> dist/fabric');
|
||||
}
|
||||
|
||||
function binTarget(base) {
|
||||
return join(base, 'bin', 'fabric-register');
|
||||
}
|
||||
|
||||
function installBinScript(base) {
|
||||
const src = join(__dirname, 'bin', 'fabric-register.mjs');
|
||||
const dst = binTarget(base);
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
copyFileSync(src, dst);
|
||||
chmodSync(dst, 0o755);
|
||||
ok(`fabric-register -> ${dst}`);
|
||||
}
|
||||
|
||||
function clearInstall(base) {
|
||||
const dest = join(base, 'plugins', PLUGIN_ID);
|
||||
if (existsSync(dest)) {
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
ok(`removed ${dest}`);
|
||||
}
|
||||
const bin = binTarget(base);
|
||||
if (existsSync(bin)) {
|
||||
rmSync(bin, { force: true });
|
||||
ok(`removed ${bin}`);
|
||||
}
|
||||
}
|
||||
function cleanupConfig(base) {
|
||||
const dest = join(base, 'plugins', PLUGIN_ID);
|
||||
@@ -149,6 +167,7 @@ function install() {
|
||||
ok(`plugin files -> ${dest}`);
|
||||
exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose });
|
||||
ok('runtime deps installed');
|
||||
installBinScript(base);
|
||||
return { base, dest };
|
||||
}
|
||||
|
||||
@@ -200,8 +219,10 @@ function main() {
|
||||
console.log('');
|
||||
log('Install complete. Next:', 'blue');
|
||||
log(' 1. Mint an agent key: (in Center) node dist/cli.js user apikey --email <agent-email>', 'cyan');
|
||||
log(' 2. openclaw gateway restart', 'cyan');
|
||||
log(' 3. As an agent, call the fabric-register tool with that key', 'cyan');
|
||||
log(' 2. Bind it to an agent (one-time), either:', 'cyan');
|
||||
log(' AGENT_ID=<agent> ~/.openclaw/bin/fabric-register --api-key <fak_…>', 'cyan');
|
||||
log(' (or pass --agent-id <agent>; or set channels.fabric.accounts.<agent>)', 'cyan');
|
||||
log(' 3. openclaw gateway restart', 'cyan');
|
||||
console.log('');
|
||||
} catch (e) {
|
||||
log(`\nInstall failed: ${e.message}`, 'red');
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
"create-work-channel",
|
||||
"create-report-channel",
|
||||
"create-discussion-channel",
|
||||
"discussion-complete"
|
||||
"discussion-complete",
|
||||
"fabric-canvas",
|
||||
"fabric-channel",
|
||||
"fabric-send-message",
|
||||
"fabric-channel-list",
|
||||
"fabric-message-history"
|
||||
]
|
||||
},
|
||||
"configSchema": {
|
||||
@@ -39,12 +44,41 @@
|
||||
"type": "string",
|
||||
"description": "Fabric Center API base, e.g. http://localhost:7001/api"
|
||||
},
|
||||
"commandsSyncKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Shared secret that must equal the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY. Required to register the slash-command catalog (Guild C-2). Read it from the guild via: docker exec fabric-backend-guild node dist/cli/print-commands-sync-key.js"
|
||||
},
|
||||
"coalesce": {
|
||||
"type": "boolean",
|
||||
"description": "Merge a split agent turn (text → thinking/tool → text) into ONE Fabric message. Flushed deterministically on the agent_end hook. Default true; false = raw per-segment posting."
|
||||
},
|
||||
"dmSecurity": { "type": "string" },
|
||||
"allowFrom": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
"dmPolicy": { "type": "string" },
|
||||
"enabled": { "type": "boolean" },
|
||||
"allowFrom": { "type": "array", "items": { "type": "string" } },
|
||||
"defaultAccount": { "type": "string" },
|
||||
"accounts": {
|
||||
"type": "object",
|
||||
"description": "agent = account; key is the openclaw agentId",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fabricApiKey": { "type": "string" },
|
||||
"centerApiBase": { "type": "string" },
|
||||
"enabled": { "type": "boolean" },
|
||||
"dmPolicy": { "type": "string" },
|
||||
"allowFrom": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["commandsSyncKey"]
|
||||
},
|
||||
"uiHints": {
|
||||
"centerApiBase": { "label": "Center API base" }
|
||||
"centerApiBase": { "label": "Center API base" },
|
||||
"commandsSyncKey": { "label": "Commands sync key" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
src/accounts.ts
Normal file
93
src/accounts.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// agent = openclaw channel account.
|
||||
// Config shape:
|
||||
// channels.fabric.centerApiBase = "http://localhost:7001/api" (shared)
|
||||
// channels.fabric.accounts.<agentId> = { fabricApiKey, centerApiBase? }
|
||||
// Each account id IS the openclaw agentId that owns that Fabric identity.
|
||||
|
||||
export type FabricAccountConfig = {
|
||||
fabricApiKey?: string;
|
||||
centerApiBase?: string;
|
||||
enabled?: boolean;
|
||||
allowFrom?: string[];
|
||||
dmPolicy?: string;
|
||||
};
|
||||
|
||||
export type FabricChannelConfig = {
|
||||
centerApiBase?: string;
|
||||
// Shared secret matching the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY
|
||||
// (Guild C-2). Required by the channel config schema; sourced from config
|
||||
// only — never from the environment.
|
||||
commandsSyncKey?: string;
|
||||
// Coalesce an agent turn that OpenClaw split into multiple deliveries
|
||||
// (text → thinking/tool → text => N sendText calls) into ONE Fabric
|
||||
// message. The flush boundary is the deterministic `agent_end` hook (not
|
||||
// a timer). Default true; set false for raw per-segment posting.
|
||||
coalesce?: boolean;
|
||||
accounts?: Record<string, FabricAccountConfig>;
|
||||
defaultAccount?: string;
|
||||
} & FabricAccountConfig;
|
||||
|
||||
type Cfg = { channels?: { fabric?: FabricChannelConfig }; [k: string]: unknown };
|
||||
|
||||
export type ResolvedFabricAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
centerApiBase: string;
|
||||
fabricApiKey: string;
|
||||
allowFrom: string[];
|
||||
dmPolicy: string | undefined;
|
||||
};
|
||||
|
||||
const DEFAULT_CENTER = 'http://localhost:7001/api';
|
||||
|
||||
function section(cfg: Cfg): FabricChannelConfig {
|
||||
return cfg.channels?.fabric ?? {};
|
||||
}
|
||||
|
||||
// The commands-sync shared secret (channel-level only). Empty string when
|
||||
// unconfigured — callers decide how to handle (slash-command sync is then
|
||||
// rejected by the guild).
|
||||
export function resolveCommandsSyncKey(cfg: Cfg): string {
|
||||
return (section(cfg).commandsSyncKey ?? '').trim();
|
||||
}
|
||||
|
||||
// Whether to coalesce a split agent turn into one Fabric message
|
||||
// (channel-level). Default true.
|
||||
export function resolveCoalesce(cfg: Cfg): boolean {
|
||||
return (cfg.channels?.fabric ?? {}).coalesce !== false;
|
||||
}
|
||||
|
||||
export function listFabricAccountIds(cfg: Cfg): string[] {
|
||||
const accts = section(cfg).accounts ?? {};
|
||||
const ids = Object.keys(accts);
|
||||
return ids.length ? ids : ['default'];
|
||||
}
|
||||
|
||||
export function resolveDefaultFabricAccountId(cfg: Cfg): string {
|
||||
const s = section(cfg);
|
||||
if (s.defaultAccount) return s.defaultAccount;
|
||||
const ids = listFabricAccountIds(cfg);
|
||||
return ids[0] ?? 'default';
|
||||
}
|
||||
|
||||
export function resolveFabricAccount(cfg: Cfg, accountId?: string | null): ResolvedFabricAccount {
|
||||
const s = section(cfg);
|
||||
const id = accountId ?? resolveDefaultFabricAccountId(cfg);
|
||||
const acc = s.accounts?.[id] ?? {};
|
||||
const fabricApiKey = (acc.fabricApiKey ?? '').trim();
|
||||
const centerApiBase = (acc.centerApiBase ?? s.centerApiBase ?? DEFAULT_CENTER).trim();
|
||||
return {
|
||||
accountId: id,
|
||||
enabled: acc.enabled !== false && s.enabled !== false,
|
||||
centerApiBase,
|
||||
fabricApiKey,
|
||||
allowFrom: acc.allowFrom ?? s.allowFrom ?? [],
|
||||
dmPolicy: acc.dmPolicy ?? s.dmPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledFabricAccounts(cfg: Cfg): ResolvedFabricAccount[] {
|
||||
return listFabricAccountIds(cfg)
|
||||
.map((id) => resolveFabricAccount(cfg, id))
|
||||
.filter((a) => a.enabled && a.fabricApiKey);
|
||||
}
|
||||
225
src/channel.ts
225
src/channel.ts
@@ -1,63 +1,178 @@
|
||||
// Fabric channel plugin object (third-party `createChatChannelPlugin` path).
|
||||
//
|
||||
// COMPAT NOTE (openclaw v2026.5.7 plugin SDK):
|
||||
// We depend on these generic SDK shapes — re-verify on openclaw upgrade:
|
||||
// - createChannelPluginBase requires `capabilities`
|
||||
// - ChannelSetupAdapter: only `applyAccountConfig` is required
|
||||
// - outbound attachedResults.sendText returns Omit<OutboundDeliveryResult,
|
||||
// "channel"> (so `messageId: string` is required)
|
||||
// Casts at the createChatChannelPlugin boundary are intentional and
|
||||
// localized; keep them here so upgrades touch one file.
|
||||
import {
|
||||
createChatChannelPlugin,
|
||||
createChannelPluginBase,
|
||||
} from 'openclaw/plugin-sdk/channel-core';
|
||||
import type { OpenClawConfig } from 'openclaw/plugin-sdk/channel-core';
|
||||
buildChannelOutboundSessionRoute,
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from 'openclaw/plugin-sdk/core';
|
||||
import { FabricClient } from './fabric-client.js';
|
||||
import {
|
||||
listFabricAccountIds,
|
||||
resolveFabricAccount,
|
||||
resolveDefaultFabricAccountId,
|
||||
type ResolvedFabricAccount,
|
||||
} from './accounts.js';
|
||||
|
||||
export type ResolvedFabricAccount = {
|
||||
accountId: string | null;
|
||||
centerApiBase: string;
|
||||
allowFrom: string[];
|
||||
dmPolicy: string | undefined;
|
||||
};
|
||||
type AnyCfg = { channels?: { fabric?: unknown }; [k: string]: unknown };
|
||||
|
||||
export function resolveFabricAccount(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): ResolvedFabricAccount {
|
||||
const section = (cfg.channels as Record<string, any>)?.['fabric'];
|
||||
const centerApiBase: string | undefined = section?.centerApiBase;
|
||||
if (!centerApiBase) throw new Error('fabric: channels.fabric.centerApiBase is required');
|
||||
return {
|
||||
accountId: accountId ?? null,
|
||||
centerApiBase,
|
||||
allowFrom: section?.allowFrom ?? [],
|
||||
dmPolicy: section?.dmSecurity,
|
||||
};
|
||||
// ---- target grammar: fabric:<channelId> ----
|
||||
export function stripFabricTargetPrefix(raw: string): string | undefined {
|
||||
let s = (raw ?? '').trim();
|
||||
if (!s) return undefined;
|
||||
if (s.toLowerCase().startsWith('fabric:')) s = s.slice('fabric:'.length).trim();
|
||||
if (s.toLowerCase().startsWith('channel:')) s = s.slice('channel:'.length).trim();
|
||||
return s || undefined;
|
||||
}
|
||||
|
||||
// Outbound is wired by the entry (it needs the identity registry + client to
|
||||
// post as the right agent). Channel-turn visible replies go through the
|
||||
// inbound adapter's delivery callback; this object owns config/security only.
|
||||
export function buildFabricChannelPlugin(
|
||||
sendText: (params: { accountId?: string | null; to: string; text: string }) => Promise<{ messageId?: string }>,
|
||||
) {
|
||||
return createChatChannelPlugin<ResolvedFabricAccount>({
|
||||
base: createChannelPluginBase({
|
||||
id: 'fabric',
|
||||
setup: {
|
||||
resolveAccount: resolveFabricAccount,
|
||||
inspectAccount(cfg, accountId) {
|
||||
const section = (cfg.channels as Record<string, any>)?.['fabric'];
|
||||
const ok = Boolean(section?.centerApiBase);
|
||||
return { enabled: ok, configured: ok, tokenStatus: ok ? 'available' : 'missing' };
|
||||
},
|
||||
},
|
||||
}),
|
||||
security: {
|
||||
dm: {
|
||||
channelKey: 'fabric',
|
||||
resolvePolicy: (a) => a.dmPolicy,
|
||||
resolveAllowFrom: (a) => a.allowFrom,
|
||||
defaultPolicy: 'allowlist',
|
||||
},
|
||||
},
|
||||
// Fabric replies thread by being posted into the same channel.
|
||||
threading: { topLevelReplyToMode: 'channel' },
|
||||
outbound: {
|
||||
attachedResults: {
|
||||
sendText: async (params) => sendText(params),
|
||||
},
|
||||
},
|
||||
export function normalizeFabricTarget(raw: string): string | undefined {
|
||||
const id = stripFabricTargetPrefix(raw);
|
||||
return id ? `fabric:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
export function looksLikeFabricTargetId(raw: string): boolean {
|
||||
const t = (raw ?? '').trim();
|
||||
if (!t) return false;
|
||||
if (/^fabric:/i.test(t)) return true;
|
||||
return /^[a-z0-9-]{8,}$/i.test(t);
|
||||
}
|
||||
export function resolveFabricOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||
const id = stripFabricTargetPrefix(params.target);
|
||||
if (!id) return null;
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: 'fabric',
|
||||
accountId: params.accountId,
|
||||
peer: { kind: 'group', id },
|
||||
chatType: 'group',
|
||||
from: `fabric:channel:${id}`,
|
||||
to: `fabric:${id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Posts an agent's reply to Fabric. `to` is the Fabric channelId; `accountId`
|
||||
// is the agentId (= Fabric identity). One auth concept: account apiKey ->
|
||||
// agent/login -> guild token -> POST message.
|
||||
async function sendToFabric(
|
||||
cfg: AnyCfg,
|
||||
accountId: string | null | undefined,
|
||||
to: string,
|
||||
text: string,
|
||||
): Promise<{ messageId: string }> {
|
||||
const channelId = stripFabricTargetPrefix(to) ?? to;
|
||||
const acc = resolveFabricAccount(cfg as never, accountId);
|
||||
if (!acc.fabricApiKey) throw new Error(`fabric account ${acc.accountId} has no fabricApiKey`);
|
||||
const client = new FabricClient(acc.centerApiBase);
|
||||
const session = await client.agentLogin(acc.fabricApiKey);
|
||||
// find which guild owns this channel by probing each guild's channel list
|
||||
for (const g of session.guilds) {
|
||||
const gt = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
|
||||
if (!gt) continue;
|
||||
const res = await fetch(`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`, {
|
||||
headers: { authorization: `Bearer ${gt}` },
|
||||
});
|
||||
const channels = res.ok ? ((await res.json()) as Array<{ id: string }>) : [];
|
||||
if (channels.some((c) => c.id === channelId)) {
|
||||
await client.postMessage(g.endpoint, gt, channelId, text, session.user.id);
|
||||
return { messageId: `${channelId}:${Date.now()}` };
|
||||
}
|
||||
}
|
||||
// fallback: first guild
|
||||
const g = session.guilds[0];
|
||||
const gt = session.guildAccessTokens.find((t) => t.guildNodeId === g?.nodeId)?.token;
|
||||
if (g && gt) {
|
||||
await client.postMessage(g.endpoint, gt, channelId, text, session.user.id);
|
||||
return { messageId: `${channelId}:${Date.now()}` };
|
||||
}
|
||||
throw new Error('fabric: no guild available to deliver');
|
||||
}
|
||||
|
||||
export const fabricChannelPlugin = createChatChannelPlugin<ResolvedFabricAccount>({
|
||||
base: {
|
||||
...createChannelPluginBase<ResolvedFabricAccount>({
|
||||
id: 'fabric',
|
||||
meta: { id: 'fabric', label: 'Fabric', blurb: 'Connect OpenClaw agents to a Fabric guild.' },
|
||||
capabilities: {
|
||||
chatTypes: ['channel', 'group', 'direct'],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: false,
|
||||
nativeCommands: false,
|
||||
// Fabric has no message-length limit and we never want a reply split
|
||||
// into multiple messages -> no block streaming.
|
||||
blockStreaming: false,
|
||||
},
|
||||
reload: { configPrefixes: ['channels.fabric'] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => listFabricAccountIds(cfg as never),
|
||||
resolveAccount: (cfg, accountId) => resolveFabricAccount(cfg as never, accountId),
|
||||
defaultAccountId: (cfg) => resolveDefaultFabricAccountId(cfg as never),
|
||||
isConfigured: (account: ResolvedFabricAccount) => Boolean(account.fabricApiKey),
|
||||
},
|
||||
// Minimal setup adapter: Fabric is configured directly under
|
||||
// channels.fabric.* (no interactive wizard). applyAccountConfig is the
|
||||
// only required member.
|
||||
setup: {
|
||||
applyAccountConfig: ({ cfg }: { cfg: unknown }) => cfg as never,
|
||||
} as never,
|
||||
}),
|
||||
messaging: {
|
||||
normalizeTarget: normalizeFabricTarget,
|
||||
resolveSessionTarget: ({ id }: { id: string }) => normalizeFabricTarget(`fabric:${id}`),
|
||||
resolveOutboundSessionRoute: (params: ChannelOutboundSessionRouteParams) =>
|
||||
resolveFabricOutboundSessionRoute(params),
|
||||
targetResolver: { looksLikeId: looksLikeFabricTargetId, hint: '<channelId|fabric:ID>' },
|
||||
},
|
||||
} as never,
|
||||
|
||||
security: {
|
||||
dm: {
|
||||
channelKey: 'fabric',
|
||||
resolvePolicy: (a: ResolvedFabricAccount) => a.dmPolicy,
|
||||
resolveAllowFrom: (a: ResolvedFabricAccount) => a.allowFrom,
|
||||
defaultPolicy: 'allowlist',
|
||||
},
|
||||
},
|
||||
|
||||
threading: { topLevelReplyToMode: 'channel' },
|
||||
|
||||
outbound: {
|
||||
base: {
|
||||
deliveryMode: 'direct',
|
||||
// Fabric has no length limit: never chunk — always one message.
|
||||
chunker: (text: string) => [text],
|
||||
textChunkLimit: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
attachedResults: {
|
||||
channel: 'fabric',
|
||||
sendText: async (ctx: {
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
cfg?: unknown;
|
||||
config?: unknown;
|
||||
}) => {
|
||||
// openclaw passes config under cfg or config depending on path.
|
||||
// Note: inbound agent replies go through inbound.ts `deliver`
|
||||
// (where turn coalescing happens). This path is for any direct
|
||||
// outbound sends and posts immediately.
|
||||
const cfg = (ctx.cfg ?? ctx.config ?? {}) as AnyCfg;
|
||||
try {
|
||||
const r = await sendToFabric(cfg, ctx.accountId ?? null, ctx.to, ctx.text);
|
||||
console.log(`[fabric] outbound.sendText -> ${ctx.to} ok`);
|
||||
return r;
|
||||
} catch (e) {
|
||||
console.log(`[fabric] outbound.sendText FAILED to=${ctx.to}: ${String(e)}`);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
93
src/coalesce.ts
Normal file
93
src/coalesce.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Deterministic turn coalescer.
|
||||
//
|
||||
// OpenClaw calls the Fabric `deliver` callback once per assistant text
|
||||
// segment; a thinking/tool block between two text blocks is a delivery
|
||||
// boundary, so one agent turn of `text → thinking/tool → text` arrives as
|
||||
// multiple deliver() calls. There is no turn id on the delivery, so we
|
||||
// BUFFER segments by Fabric channelId and post the merged message when the
|
||||
// turn truly ends. The flush is driven by inbound.ts right after
|
||||
// `dispatchInboundReplyWithBase` resolves — that only happens AFTER every
|
||||
// deliver() of the turn, a deterministic boundary (NOT a timer, NOT the
|
||||
// agent_end hook, which fires before deliver()). `coalesce=false` posts
|
||||
// each segment immediately.
|
||||
|
||||
const SAFETY_FLUSH_MS = 120_000; // leak-guard only; not the flush mechanism
|
||||
|
||||
export function normChannelId(x: string | null | undefined): string {
|
||||
const s = String(x ?? '');
|
||||
return s.startsWith('fabric:') ? s.slice('fabric:'.length) : s;
|
||||
}
|
||||
|
||||
type Pending = {
|
||||
parts: string[];
|
||||
post: (text: string) => Promise<void>;
|
||||
log?: (m: string) => void;
|
||||
safety: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
const pendingByChannel = new Map<string, Pending>();
|
||||
|
||||
async function flushChannel(channelId: string, reason: string): Promise<void> {
|
||||
const p = pendingByChannel.get(channelId);
|
||||
if (!p) return;
|
||||
pendingByChannel.delete(channelId);
|
||||
clearTimeout(p.safety);
|
||||
const text = p.parts.join('\n\n').trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
await p.post(text);
|
||||
p.log?.(`fabric: flushed ${p.parts.length} segment(s) channel=${channelId} (${reason})`);
|
||||
} catch (e) {
|
||||
p.log?.(`fabric: flush FAILED channel=${channelId} (${reason}): ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer one delivered segment (or send immediately when coalesce=false).
|
||||
// `post` performs the real Fabric postMessage with the caller's already
|
||||
// resolved guild/token; on flush it is called once with the merged text.
|
||||
export async function enqueueDelivery(params: {
|
||||
channelId: string;
|
||||
text: string;
|
||||
coalesce: boolean;
|
||||
post: (text: string) => Promise<void>;
|
||||
log?: (m: string) => void;
|
||||
}): Promise<void> {
|
||||
const cid = normChannelId(params.channelId);
|
||||
const text = (params.text ?? '').trim();
|
||||
if (!text) return;
|
||||
if (!params.coalesce) {
|
||||
await params.post(text);
|
||||
return;
|
||||
}
|
||||
const existing = pendingByChannel.get(cid);
|
||||
if (existing) {
|
||||
existing.parts.push(text);
|
||||
existing.post = params.post; // freshest guild/token closure
|
||||
existing.log = params.log;
|
||||
} else {
|
||||
pendingByChannel.set(cid, {
|
||||
parts: [text],
|
||||
post: params.post,
|
||||
log: params.log,
|
||||
safety: setTimeout(
|
||||
() => void flushChannel(cid, 'safety-timeout'),
|
||||
SAFETY_FLUSH_MS,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Called by the agent_end hook with the hook ctx's channelId (bare or
|
||||
// fabric:-prefixed). Deterministic per-turn boundary.
|
||||
export async function flushFabricForChannel(
|
||||
rawChannelId: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const cid = normChannelId(rawChannelId);
|
||||
if (cid) await flushChannel(cid, 'dispatch-end');
|
||||
}
|
||||
|
||||
// gateway_stop: flush anything still buffered.
|
||||
export async function flushAllFabric(): Promise<void> {
|
||||
for (const cid of [...pendingByChannel.keys()]) {
|
||||
await flushChannel(cid, 'gateway_stop');
|
||||
}
|
||||
}
|
||||
149
src/command-sync.ts
Normal file
149
src/command-sync.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// Build the Fabric slash-command catalog from OpenClaw's native-command
|
||||
// specs (the same source Discord uses to register slash commands) and push
|
||||
// it to each connected guild. Fabric is a TEXT-command surface: a /<cmd>
|
||||
// message is delivered normally and OpenClaw's command system executes it —
|
||||
// this catalog only drives the frontend `/` autocomplete, so we resolve any
|
||||
// dynamic arg `choices` to a static snapshot here (like Discord does at
|
||||
// registration time).
|
||||
import {
|
||||
listNativeCommandSpecsForConfig,
|
||||
findCommandByNativeName,
|
||||
resolveCommandArgChoices,
|
||||
} from 'openclaw/plugin-sdk/native-command-registry';
|
||||
import type { FabricClient } from './fabric-client.js';
|
||||
import { resolveCommandsSyncKey } from './accounts.js';
|
||||
|
||||
type Logger = { info: (m: string) => void; warn: (m: string) => void };
|
||||
|
||||
type FabricArg = {
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
captureRemaining: boolean;
|
||||
preferAutocomplete: boolean;
|
||||
choices: Array<{ value: string; label: string }> | null;
|
||||
};
|
||||
type FabricCommand = {
|
||||
name: string;
|
||||
nativeName: string;
|
||||
description: string;
|
||||
acceptsArgs: boolean;
|
||||
args: FabricArg[];
|
||||
argsParsing: string;
|
||||
};
|
||||
|
||||
function normChoice(c: unknown): { value: string; label: string } {
|
||||
if (typeof c === 'string') return { value: c, label: c };
|
||||
const o = c as { value?: string; label?: string };
|
||||
return { value: String(o.value ?? ''), label: String(o.label ?? o.value ?? '') };
|
||||
}
|
||||
|
||||
export function buildFabricCommandSpecs(cfg: unknown): FabricCommand[] {
|
||||
const specs = listNativeCommandSpecsForConfig(cfg as never, {
|
||||
provider: 'fabric',
|
||||
}) as Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
acceptsArgs?: boolean;
|
||||
args?: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
return specs.map((s) => {
|
||||
// ChatCommandDefinition (for argsParsing + dynamic choices provider)
|
||||
const def = findCommandByNativeName(s.name, 'fabric') as
|
||||
| { argsParsing?: string; args?: Array<Record<string, unknown>> }
|
||||
| undefined;
|
||||
|
||||
const args: FabricArg[] = (s.args ?? []).map((a) => {
|
||||
const raw = a.choices;
|
||||
let choices: Array<{ value: string; label: string }> | null = null;
|
||||
if (Array.isArray(raw)) {
|
||||
choices = raw.map(normChoice);
|
||||
} else if (typeof raw === 'function' && def) {
|
||||
try {
|
||||
const r = resolveCommandArgChoices({
|
||||
command: def as never,
|
||||
arg: a as never,
|
||||
cfg: cfg as never,
|
||||
provider: 'fabric',
|
||||
}) as Array<{ value: string; label: string }>;
|
||||
choices = r.map((x) => ({ value: x.value, label: x.label }));
|
||||
} catch {
|
||||
choices = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: String(a.name ?? ''),
|
||||
description: String(a.description ?? ''),
|
||||
type: String(a.type ?? 'string'),
|
||||
required: !!a.required,
|
||||
captureRemaining: !!a.captureRemaining,
|
||||
preferAutocomplete: !!a.preferAutocomplete,
|
||||
choices,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name: s.name,
|
||||
nativeName: s.name,
|
||||
description: s.description,
|
||||
acceptsArgs: !!s.acceptsArgs,
|
||||
args,
|
||||
argsParsing: def?.argsParsing ?? 'positional',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Push the catalog to every guild the known agents belong to (idempotent;
|
||||
// the catalog is OpenClaw-global, so one PUT per guild is enough).
|
||||
export async function syncFabricCommands(
|
||||
client: FabricClient,
|
||||
cfg: unknown,
|
||||
accounts: Array<{ agentId: string; fabricApiKey: string }>,
|
||||
log: Logger,
|
||||
): Promise<void> {
|
||||
// Guild C-2: the sync key comes from the channel config only (schema
|
||||
// marks it required). Without it the guild rejects the catalog write.
|
||||
const syncKey = resolveCommandsSyncKey(cfg as never);
|
||||
if (!syncKey) {
|
||||
log.warn(
|
||||
'fabric: channels.fabric.commandsSyncKey is not set — skipping ' +
|
||||
'slash-command sync (set it to the guild FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let specs: FabricCommand[];
|
||||
try {
|
||||
specs = buildFabricCommandSpecs(cfg);
|
||||
} catch (err) {
|
||||
log.warn(`fabric: build command specs failed: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (!specs.length) return;
|
||||
|
||||
const done = new Set<string>();
|
||||
for (const a of accounts) {
|
||||
let session;
|
||||
try {
|
||||
session = await client.agentLogin(a.fabricApiKey);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const g of session.guilds) {
|
||||
if (done.has(g.nodeId)) continue;
|
||||
const tok = session.guildAccessTokens.find(
|
||||
(t) => t.guildNodeId === g.nodeId,
|
||||
)?.token;
|
||||
if (!tok) continue;
|
||||
try {
|
||||
await client.syncCommands(g.endpoint, tok, specs, syncKey);
|
||||
done.add(g.nodeId);
|
||||
log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`);
|
||||
} catch (err) {
|
||||
log.warn(`fabric: command sync failed ${g.nodeId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,32 @@ export class FabricClient {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null
|
||||
// (Fabric returns an empty body when a channel has no canvas).
|
||||
private async req<T>(
|
||||
method: string,
|
||||
url: string,
|
||||
auth?: string,
|
||||
body?: unknown,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
...(auth ? { authorization: `Bearer ${auth}` } : {}),
|
||||
...(extraHeaders ?? {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${url} -> ${res.status} ${text}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
// Exchange an agent API key for a Fabric user session (+ guild tokens).
|
||||
agentLogin(apiKey: string): Promise<FabricSession> {
|
||||
return this.post<FabricSession>(`${this.centerApiBase}/auth/agent/login`, { apiKey });
|
||||
@@ -86,4 +112,165 @@ export class FabricClient {
|
||||
joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
|
||||
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
|
||||
}
|
||||
|
||||
leaveChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
|
||||
return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken);
|
||||
}
|
||||
|
||||
// Register the OpenClaw slash-command catalog with this guild (idempotent
|
||||
// full replace). The frontend GETs it for `/` autocomplete; execution
|
||||
// still flows as a normal /<cmd> message into OpenClaw's command system.
|
||||
syncCommands(
|
||||
guildEndpoint: string,
|
||||
guildToken: string,
|
||||
commands: unknown[],
|
||||
syncKey: string,
|
||||
): Promise<unknown> {
|
||||
// Guild C-2: the shared key is sourced from the channel config
|
||||
// (channels.fabric.commandsSyncKey) and must equal the guild's
|
||||
// FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY for the catalog write.
|
||||
return this.req(
|
||||
'PUT',
|
||||
`${guildEndpoint}/api/commands`,
|
||||
guildToken,
|
||||
{ commands },
|
||||
syncKey ? { 'x-commands-sync-key': syncKey } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// [{ userId, bypass }] — bypass is true only for discuss/work bypass-list
|
||||
channelMembers(
|
||||
guildEndpoint: string,
|
||||
guildToken: string,
|
||||
channelId: string,
|
||||
): Promise<Array<{ userId: string; bypass?: boolean }>> {
|
||||
return this.req(
|
||||
'GET',
|
||||
`${guildEndpoint}/api/channels/${channelId}/members`,
|
||||
guildToken,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- channel canvas (one pinned doc per channel) ----
|
||||
|
||||
private canvasUrl(endpoint: string, channelId: string): string {
|
||||
return `${endpoint}/api/channels/${channelId}/canvas`;
|
||||
}
|
||||
|
||||
// null when the channel has no canvas
|
||||
getCanvas(
|
||||
endpoint: string,
|
||||
token: string,
|
||||
channelId: string,
|
||||
): Promise<FabricCanvas | null> {
|
||||
return this.req('GET', this.canvasUrl(endpoint, channelId), token);
|
||||
}
|
||||
|
||||
// share / replace (caller becomes the sharer)
|
||||
shareCanvas(
|
||||
endpoint: string,
|
||||
token: string,
|
||||
channelId: string,
|
||||
body: CanvasInput,
|
||||
): Promise<FabricCanvas> {
|
||||
return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body);
|
||||
}
|
||||
|
||||
// update in place (original sharer only — else the guild returns 403)
|
||||
updateCanvas(
|
||||
endpoint: string,
|
||||
token: string,
|
||||
channelId: string,
|
||||
body: Partial<CanvasInput>,
|
||||
): Promise<FabricCanvas> {
|
||||
return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body);
|
||||
}
|
||||
|
||||
// remove ("close") the canvas (original sharer only)
|
||||
removeCanvas(endpoint: string, token: string, channelId: string): Promise<unknown> {
|
||||
return this.req('DELETE', this.canvasUrl(endpoint, channelId), token);
|
||||
}
|
||||
|
||||
// ---- channel discovery + message read (used by the agent-facing
|
||||
// fabric-channel-list / fabric-message-history tools) ----
|
||||
|
||||
/**
|
||||
* List channels in a guild visible to the calling user. Backend
|
||||
* filters to public + channels the user is a member of.
|
||||
*/
|
||||
listChannels(
|
||||
guildEndpoint: string,
|
||||
guildToken: string,
|
||||
guildNodeId: string,
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
guildId: string;
|
||||
name: string;
|
||||
xType: string;
|
||||
kind: string;
|
||||
isPublic: boolean;
|
||||
closed: boolean;
|
||||
lastSeq: number;
|
||||
createdAt: string;
|
||||
}>> {
|
||||
return this.req(
|
||||
'GET',
|
||||
`${guildEndpoint}/api/channels?guildId=${encodeURIComponent(guildNodeId)}`,
|
||||
guildToken,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Page through a channel's message history by `seq`.
|
||||
*
|
||||
* Backend defaults: 50 / call, max 200. The `seq` field starts at 1
|
||||
* per channel; pass `seqFrom=channel.lastSeq - N + 1` to get the
|
||||
* tail. Page metadata in the response describes what to ask next.
|
||||
*/
|
||||
listMessages(
|
||||
guildEndpoint: string,
|
||||
guildToken: string,
|
||||
channelId: string,
|
||||
opts: { seqFrom?: number; seqTo?: number; limit?: number } = {},
|
||||
): Promise<{
|
||||
items: Array<{
|
||||
messageId: string;
|
||||
seq: number;
|
||||
content: string;
|
||||
authorUserId: string;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
isDeleted: boolean;
|
||||
}>;
|
||||
page: {
|
||||
seqFrom: number;
|
||||
seqTo: number;
|
||||
limit: number;
|
||||
returned: number;
|
||||
hasMore: boolean;
|
||||
nextExpectedSeq: number;
|
||||
highestCommittedSeq: number;
|
||||
};
|
||||
}> {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.seqFrom !== undefined) qs.set('seq_from', String(opts.seqFrom));
|
||||
if (opts.seqTo !== undefined) qs.set('seq_to', String(opts.seqTo));
|
||||
if (opts.limit !== undefined) qs.set('limit', String(opts.limit));
|
||||
const url = `${guildEndpoint}/api/channels/${channelId}/messages` + (qs.toString() ? `?${qs}` : '');
|
||||
return this.req('GET', url, guildToken);
|
||||
}
|
||||
}
|
||||
|
||||
export type CanvasFormat = 'md' | 'html' | 'text';
|
||||
export type CanvasInput = { title: string; format: CanvasFormat; source: string };
|
||||
export type FabricCanvas = {
|
||||
channelId: string;
|
||||
sharerUserId: string;
|
||||
title: string;
|
||||
format: CanvasFormat;
|
||||
source: string;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
589
src/inbound.ts
589
src/inbound.ts
@@ -1,48 +1,245 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
||||
import type { FabricClient, FabricSession } from './fabric-client.js';
|
||||
import type { IdentityRegistry } from './identity.js';
|
||||
import { resolveCoalesce } from './accounts.js';
|
||||
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
|
||||
|
||||
// OpenClaw plugin runtime — only the channel-turn kernel surface we use.
|
||||
// Typed loosely on purpose: the concrete shapes come from
|
||||
// openclaw/plugin-sdk/core at the host's SDK version.
|
||||
type PluginRuntime = {
|
||||
// COMPAT NOTE (openclaw v2026.5.7): the inbound path mirrors how bundled
|
||||
// channels (nextcloud-talk) drive the kernel:
|
||||
// core = PluginRuntime (from setRuntime)
|
||||
// route = core.channel.routing.resolveAgentRoute(...)
|
||||
// ctx = core.channel.reply.finalizeInboundContext(...) // has SessionKey
|
||||
// dispatch= dispatchInboundReplyWithBase({ cfg, route, ctxPayload, core, deliver })
|
||||
// `core.channel.*` is accessed loosely so unrelated SDK drift won't break us.
|
||||
type Core = {
|
||||
channel: {
|
||||
turn: {
|
||||
run(args: unknown): Promise<unknown>;
|
||||
routing: { resolveAgentRoute: (p: unknown) => { agentId: string; sessionKey: string; accountId?: string } };
|
||||
session: {
|
||||
resolveStorePath: (store: unknown, o: { agentId: string }) => string;
|
||||
recordInboundSession: (p: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
ctx: unknown;
|
||||
createIfMissing?: boolean;
|
||||
onRecordError: (e: unknown) => void;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
reply: { finalizeInboundContext: (p: Record<string, unknown>) => unknown };
|
||||
};
|
||||
log?: { debug?: (m: string, x?: unknown) => void };
|
||||
};
|
||||
|
||||
type Logger = { info: (m: string) => void; warn: (m: string) => void };
|
||||
type Logger = { info: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void };
|
||||
|
||||
type FabricAttachment = { url: string; name?: string; mimeType?: string };
|
||||
type FabricMessage = {
|
||||
messageId: string;
|
||||
seq: number;
|
||||
content: string;
|
||||
authorUserId?: string;
|
||||
createdAt?: string;
|
||||
// per-recipient metadata Fabric attaches at push time (this agent's view)
|
||||
channelId?: string;
|
||||
attachments?: FabricAttachment[];
|
||||
wakeup?: boolean;
|
||||
// x-type of the channel (sent on message.created). 'dm' bypasses the
|
||||
// wakeup gate: any message that isn't the agent's own is delivered.
|
||||
xType?: string;
|
||||
};
|
||||
|
||||
// One live Fabric connection per agent identity (Phase 1 = B1). Lives in the
|
||||
// channel-plugin runtime (no separate sidecar). Firehose (B2) would replace
|
||||
// this class behind the same dispatch() call.
|
||||
export class FabricInbound {
|
||||
private sockets: Socket[] = [];
|
||||
private timers: NodeJS.Timeout[] = [];
|
||||
private seen = new Set<string>();
|
||||
// Timers that periodically re-sync channel membership per (agent, guild).
|
||||
// Without this, the agent's socket.io subscriptions are a snapshot taken
|
||||
// at connect time — any channel the agent joins later (e.g. a fresh DM
|
||||
// created by another user) is unreachable until the gateway restarts.
|
||||
private channelSyncTimers: NodeJS.Timeout[] = [];
|
||||
// Resync cadence. Backend doesn't push a `channel.joined` event, so we
|
||||
// poll. 60s keeps the lag bounded without hammering the backend.
|
||||
private static readonly CHANNEL_SYNC_INTERVAL_MS = 60_000;
|
||||
// Guild access tokens are short-lived (~15 min). The socket survives via
|
||||
// socket.io reconnect, but the token captured at connect time goes stale,
|
||||
// so HTTP calls (attachment download, posting the reply) start 401ing.
|
||||
// Re-login per agent on a short TTL to keep a fresh token.
|
||||
private tokenCache = new Map<string, { session: FabricSession; at: number }>();
|
||||
private static readonly TOKEN_TTL_MS = 8 * 60 * 1000;
|
||||
|
||||
// Per-channel serial work queue. Every inbound socket message for a
|
||||
// channel awaits the previous task for that same channel, so model
|
||||
// turns never interleave. Map key = channelId; value is the tail of
|
||||
// the chain (an in-flight promise the next task awaits).
|
||||
//
|
||||
// Why per-channel and not per-agent: a single agent may sit in
|
||||
// several triage / general channels; we want each channel to flow at
|
||||
// its own speed but the SAME channel's traffic to be strictly serial.
|
||||
// For dm and discuss the queue also serialises but those traditionally
|
||||
// had at-most-one-in-flight anyway via the turn engine.
|
||||
private channelChains = new Map<string, Promise<void>>();
|
||||
|
||||
// Agent.status snapshot cache (5s TTL) — keeps the HF /calendar/
|
||||
// agent/status round-trip off the hot path for back-to-back triage
|
||||
// messages. Short TTL because status flips are rare-but-meaningful.
|
||||
private agentStatusCache = new Map<string, { onCall: boolean; at: number }>();
|
||||
private static readonly AGENT_STATUS_TTL_MS = 5_000;
|
||||
|
||||
// Triage messages that arrived while the on-duty agent wasn't on_call
|
||||
// — sit here until either (a) the agent becomes on_call and the next
|
||||
// triage arrival drains them, or (b) the gateway restarts (lost; ok
|
||||
// because the underlying Fabric messages are persisted and re-fetched
|
||||
// on agent reconnect's history sweep).
|
||||
private pendingTriageGated: Array<{
|
||||
agentId: string;
|
||||
g: { nodeId: string; endpoint: string };
|
||||
channelId: string;
|
||||
m: FabricMessage;
|
||||
session: FabricSession;
|
||||
}> = [];
|
||||
|
||||
// Schedule `task` to run after every previous task on the same
|
||||
// channel has completed. Returns the promise so callers can await
|
||||
// their own result if they need to; the chain itself is fire-and-
|
||||
// forget from the socket.on handler.
|
||||
private enqueueChannelTask(channelId: string, task: () => Promise<void>): Promise<void> {
|
||||
const prev = this.channelChains.get(channelId) ?? Promise.resolve();
|
||||
const next = prev.then(task).catch((err) => {
|
||||
this.log.warn(`fabric: per-channel task failed channel=${channelId}: ${String(err)}`);
|
||||
});
|
||||
this.channelChains.set(channelId, next);
|
||||
// Best-effort cleanup so the Map doesn't grow without bound for
|
||||
// long-running gateways: drop the entry when the chain settles, but
|
||||
// only if it's still the latest reference (newer enqueue may have
|
||||
// overwritten it in the meantime).
|
||||
void next.finally(() => {
|
||||
if (this.channelChains.get(channelId) === next) {
|
||||
this.channelChains.delete(channelId);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
// Hit HF backend to check whether `agentId` is currently on_call.
|
||||
// Cached for 5s. Failures (network, 404, etc.) are treated as "not
|
||||
// on_call" — triage stays gated rather than risking a confused wake.
|
||||
private async checkAgentOnCall(agentId: string): Promise<boolean> {
|
||||
const cached = this.agentStatusCache.get(agentId);
|
||||
if (cached && Date.now() - cached.at < FabricInbound.AGENT_STATUS_TTL_MS) {
|
||||
return cached.onCall;
|
||||
}
|
||||
const base = (process.env.HF_API_BASE_URL ?? '').trim() || 'https://monitor.hangman-lab.top';
|
||||
// CLAW_IDENTIFIER resolution priority:
|
||||
// 1. HF_CLAW_IDENTIFIER env (operator override)
|
||||
// 2. openclaw config `plugins.harbor-forge.identifier` (what the HF
|
||||
// plugin itself uses — keeps the two in sync without an extra
|
||||
// env per service unit)
|
||||
// 3. os.hostname() last-resort fallback (often wrong: e.g. sim
|
||||
// container hostname is `server.t2` but HF agent row has
|
||||
// `claw_identifier=sim-t2`; matching is mandatory for the HF
|
||||
// backend's _require_agent() check)
|
||||
let claw = (process.env.HF_CLAW_IDENTIFIER ?? '').trim();
|
||||
if (!claw) {
|
||||
try {
|
||||
// openclaw config shape (verified in sim):
|
||||
// { plugins: { entries: { 'harbor-forge': { config: { identifier } } } } }
|
||||
const cfg = this.cfg as {
|
||||
plugins?: { entries?: Record<string, { config?: { identifier?: string } }> };
|
||||
};
|
||||
const fromCfg = cfg?.plugins?.entries?.['harbor-forge']?.config?.identifier;
|
||||
if (fromCfg && typeof fromCfg === 'string' && fromCfg.trim()) {
|
||||
claw = fromCfg.trim();
|
||||
}
|
||||
} catch {
|
||||
/* fall through to hostname */
|
||||
}
|
||||
}
|
||||
if (!claw) {
|
||||
claw = (await import('os')).hostname();
|
||||
}
|
||||
let onCall = false;
|
||||
try {
|
||||
const url = `${base.replace(/\/$/, '')}/calendar/agent/status?agent_id=${encodeURIComponent(agentId)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'X-Agent-ID': agentId, 'X-Claw-Identifier': claw },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { status?: string };
|
||||
onCall = (data.status ?? '').toLowerCase() === 'on_call';
|
||||
}
|
||||
} catch (err) {
|
||||
this.log.warn(`fabric: HF status check failed agent=${agentId}: ${String(err)}`);
|
||||
}
|
||||
this.agentStatusCache.set(agentId, { onCall, at: Date.now() });
|
||||
return onCall;
|
||||
}
|
||||
|
||||
// FIFO drain of all triage-gated messages for `agentId` (called when
|
||||
// we just learned they're on_call). Each drained message is dispatched
|
||||
// through its own channel chain so per-channel serial order is kept.
|
||||
private async drainGatedFor(agentId: string): Promise<void> {
|
||||
const keep: typeof this.pendingTriageGated = [];
|
||||
const drain: typeof this.pendingTriageGated = [];
|
||||
for (const item of this.pendingTriageGated) {
|
||||
if (item.agentId === agentId) drain.push(item);
|
||||
else keep.push(item);
|
||||
}
|
||||
if (drain.length === 0) return;
|
||||
this.pendingTriageGated = keep;
|
||||
for (const item of drain) {
|
||||
this.log.info(
|
||||
`fabric: triage drain agent=${item.agentId} channel=${item.channelId} msg=${item.m.messageId}`,
|
||||
);
|
||||
// Re-enqueue via the per-channel chain so ordering is preserved.
|
||||
this.enqueueChannelTask(item.channelId, async () => {
|
||||
await this.dispatch(item.agentId, item.g, item.channelId, item.m, item.session);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return a fresh guild access token for the agent, re-authenticating with
|
||||
// the agent's Fabric API key when the cached session is stale. Falls back
|
||||
// to the connect-time session token if re-login fails.
|
||||
private async freshGuildToken(
|
||||
agentId: string,
|
||||
guildNodeId: string,
|
||||
fallback: FabricSession,
|
||||
): Promise<string | undefined> {
|
||||
const pick = (s: FabricSession) =>
|
||||
s.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
||||
const now = Date.now();
|
||||
const cached = this.tokenCache.get(agentId);
|
||||
if (cached && now - cached.at < FabricInbound.TOKEN_TTL_MS) {
|
||||
return pick(cached.session) ?? pick(fallback);
|
||||
}
|
||||
const apiKey = this.identity.findByAgentId(agentId)?.fabricApiKey;
|
||||
if (apiKey) {
|
||||
try {
|
||||
const s = await this.client.agentLogin(apiKey);
|
||||
this.tokenCache.set(agentId, { session: s, at: now });
|
||||
return pick(s) ?? pick(fallback);
|
||||
} catch (err) {
|
||||
this.log.warn(`fabric: token refresh failed agent=${agentId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return pick(fallback);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly runtime: PluginRuntime,
|
||||
private readonly core: unknown, // PluginRuntime
|
||||
private readonly cfg: unknown, // OpenClawConfig
|
||||
private readonly client: FabricClient,
|
||||
private readonly identity: IdentityRegistry,
|
||||
private readonly log: Logger,
|
||||
private readonly accounts: Array<{ agentId: string; fabricApiKey: string }> = [],
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
for (const entry of this.identity.list()) {
|
||||
const entries =
|
||||
this.accounts.length > 0
|
||||
? this.accounts.map((a) => ({ agentId: a.agentId, fabricApiKey: a.fabricApiKey }))
|
||||
: this.identity.list();
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const session = await this.client.agentLogin(entry.fabricApiKey);
|
||||
this.identity.upsert({
|
||||
@@ -60,10 +257,10 @@ export class FabricInbound {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
for (const t of this.channelSyncTimers) clearInterval(t);
|
||||
this.channelSyncTimers = [];
|
||||
for (const s of this.sockets) s.disconnect();
|
||||
this.sockets = [];
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
private async connectAgent(agentId: string, session: FabricSession): Promise<void> {
|
||||
@@ -71,101 +268,335 @@ export class FabricInbound {
|
||||
for (const g of session.guilds) {
|
||||
const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
|
||||
if (!tok) continue;
|
||||
|
||||
const socket = io(`${g.endpoint}/realtime`, {
|
||||
transports: ['websocket'],
|
||||
auth: { token: tok },
|
||||
autoConnect: false,
|
||||
});
|
||||
|
||||
const joinAll = async () => {
|
||||
// Tracked socket.io rooms for this (agent, guild). The initial fetch
|
||||
// on `connect` seeds it; the periodic resync diffs against it so we
|
||||
// only emit `join_channel` for genuinely new channels (and
|
||||
// `leave_channel` for ones the agent is no longer in).
|
||||
const joined = new Set<string>();
|
||||
const syncChannels = async (kind: 'initial' | 'resync') => {
|
||||
let freshTok: string | undefined;
|
||||
try {
|
||||
const res = await fetch(`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`, {
|
||||
headers: { authorization: `Bearer ${tok}` },
|
||||
});
|
||||
const channels = res.ok ? ((await res.json()) as Array<{ id: string }>) : [];
|
||||
for (const c of channels) socket.emit('join_channel', { channelId: c.id });
|
||||
freshTok = await this.freshGuildToken(agentId, g.nodeId, session);
|
||||
} catch {
|
||||
/* best effort */
|
||||
freshTok = tok;
|
||||
}
|
||||
const authTok = freshTok ?? tok;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`,
|
||||
{ headers: { authorization: `Bearer ${authTok}` } },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const channels = (await res.json()) as Array<{ id: string }>;
|
||||
const current = new Set(channels.map((c) => c.id));
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const id of current) {
|
||||
if (!joined.has(id)) {
|
||||
socket.emit('join_channel', { channelId: id });
|
||||
joined.add(id);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
for (const id of [...joined]) {
|
||||
if (!current.has(id)) {
|
||||
socket.emit('leave_channel', { channelId: id });
|
||||
joined.delete(id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (kind === 'initial') {
|
||||
this.log.info(
|
||||
`fabric: agent ${agentId} joined ${current.size} channel(s) on ${g.nodeId}`,
|
||||
);
|
||||
} else if (added > 0 || removed > 0) {
|
||||
this.log.info(
|
||||
`fabric: agent ${agentId} channel resync on ${g.nodeId}: +${added} -${removed} (now ${joined.size})`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* best effort — next tick will retry */
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('connect', () => void joinAll());
|
||||
socket.on('message.created', (m: FabricMessage & { channelId?: string }) => {
|
||||
socket.on('connect', () => {
|
||||
// On every (re)connect the server forgets prior subscriptions, so
|
||||
// reset our local view and seed from a fresh fetch.
|
||||
joined.clear();
|
||||
void syncChannels('initial');
|
||||
});
|
||||
// Push-based membership events from the backend (companion to
|
||||
// Fabric.Backend.Guild's RealtimeGateway.emitToUser). When the
|
||||
// server tells us this user was added to / removed from a
|
||||
// channel, we sub/unsub the socket.io room immediately — no
|
||||
// 60s wait for the polling resync. Polling remains as a safety
|
||||
// net for missed events.
|
||||
socket.on('channel.joined', (evt: { channelId?: string }) => {
|
||||
const id = evt?.channelId;
|
||||
if (!id || joined.has(id)) return;
|
||||
socket.emit('join_channel', { channelId: id });
|
||||
joined.add(id);
|
||||
this.log.info(`fabric: agent ${agentId} channel.joined push on ${g.nodeId}: ${id} (now ${joined.size})`);
|
||||
});
|
||||
socket.on('channel.left', (evt: { channelId?: string }) => {
|
||||
const id = evt?.channelId;
|
||||
if (!id || !joined.has(id)) return;
|
||||
socket.emit('leave_channel', { channelId: id });
|
||||
joined.delete(id);
|
||||
this.log.info(`fabric: agent ${agentId} channel.left push on ${g.nodeId}: ${id} (now ${joined.size})`);
|
||||
});
|
||||
const syncTimer = setInterval(
|
||||
() => void syncChannels('resync'),
|
||||
FabricInbound.CHANNEL_SYNC_INTERVAL_MS,
|
||||
);
|
||||
this.channelSyncTimers.push(syncTimer);
|
||||
socket.on('message.created', (m: FabricMessage) => {
|
||||
const channelId = m.channelId ?? '';
|
||||
if (!channelId) return;
|
||||
// self-echo guard + dedupe
|
||||
if (m.authorUserId && m.authorUserId === selfUserId) return;
|
||||
const key = `${agentId}:${m.messageId}`;
|
||||
if (this.seen.has(key)) return;
|
||||
this.seen.add(key);
|
||||
if (this.seen.size > 5000) this.seen.clear();
|
||||
void this.dispatch(agentId, g, channelId, m);
|
||||
// Per-channel serial queue. Prevents concurrent model turns for
|
||||
// the same channel — important for triage where a second wake
|
||||
// arriving mid-reply would interleave with the in-flight one.
|
||||
this.enqueueChannelTask(channelId, async () => {
|
||||
// Triage on_call gate: if the on-duty agent isn't currently
|
||||
// on_call per HF, don't dispatch yet — just sit on the
|
||||
// per-channel queue. Subsequent triage messages will recheck;
|
||||
// when the agent becomes on_call, the next arrival drains.
|
||||
//
|
||||
// Also handles: triage + wake=true must verify status before
|
||||
// committing to a model turn. Non-triage and triage observer
|
||||
// (wake=false) skip the gate.
|
||||
if (m.xType === 'triage' && m.wakeup === true) {
|
||||
const onCall = await this.checkAgentOnCall(agentId);
|
||||
if (!onCall) {
|
||||
this.log.info(
|
||||
`fabric: triage wake gated (agent=${agentId} not on_call) — re-queue msg=${m.messageId}`,
|
||||
);
|
||||
this.pendingTriageGated.push({ agentId, g, channelId, m, session });
|
||||
return;
|
||||
}
|
||||
// Drain any previously-gated messages (FIFO) before this one,
|
||||
// now that we know the agent is on_call.
|
||||
await this.drainGatedFor(agentId);
|
||||
}
|
||||
await this.dispatch(agentId, g, channelId, m, session);
|
||||
});
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
this.sockets.push(socket);
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the inbound Fabric message to OpenClaw's channel-turn kernel.
|
||||
// wakeup === true -> dispatch (agent runs, may reply)
|
||||
// wakeup !== true -> drop but keep as group history/context
|
||||
// Download a message's attachments to a temp dir using the agent's guild
|
||||
// token; returns local paths/types/urls for the inbound media context.
|
||||
private async fetchAttachments(
|
||||
agentId: string,
|
||||
endpoint: string,
|
||||
token: string | undefined,
|
||||
m: FabricMessage,
|
||||
): Promise<{ paths: string[]; types: string[]; urls: string[] }> {
|
||||
const out = { paths: [] as string[], types: [] as string[], urls: [] as string[] };
|
||||
const list = m.attachments ?? [];
|
||||
if (!list.length || !token) return out;
|
||||
const dir = join(tmpdir(), `fabric-media-${agentId}-${m.messageId}`.replace(/[^\w.-]/g, '_'));
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
let i = 0;
|
||||
for (const a of list) {
|
||||
try {
|
||||
const abs = a.url.startsWith('http') ? a.url : `${endpoint}${a.url}`;
|
||||
const res = await fetch(abs, { headers: { authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) {
|
||||
this.log.warn(`fabric: attachment fetch ${res.status} ${abs}`);
|
||||
continue;
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const safe = (a.name ?? `file-${i}`).replace(/[^\w.-]/g, '_').slice(0, 120) || `file-${i}`;
|
||||
const p = join(dir, `${i}-${safe}`);
|
||||
await fs.writeFile(p, buf);
|
||||
out.paths.push(p);
|
||||
out.types.push(
|
||||
a.mimeType ||
|
||||
res.headers.get('content-type')?.split(';')[0] ||
|
||||
'application/octet-stream',
|
||||
);
|
||||
out.urls.push(abs);
|
||||
i++;
|
||||
} catch (err) {
|
||||
this.log.warn(`fabric: attachment fetch failed agent=${agentId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
if (out.paths.length)
|
||||
this.log.info(`fabric: fetched ${out.paths.length} attachment(s) agent=${agentId}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
private async dispatch(
|
||||
agentId: string,
|
||||
guild: { nodeId: string; endpoint: string },
|
||||
channelId: string,
|
||||
m: FabricMessage,
|
||||
session: FabricSession,
|
||||
): Promise<void> {
|
||||
const admit = m.wakeup === true;
|
||||
const core = this.core as Core & Record<string, unknown>;
|
||||
const cfg = this.cfg as { session?: { store?: unknown } };
|
||||
try {
|
||||
await this.runtime.channel.turn.run({
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: this.cfg,
|
||||
channel: 'fabric',
|
||||
accountId: agentId,
|
||||
raw: m,
|
||||
adapter: {
|
||||
ingest: (raw: FabricMessage) => ({
|
||||
id: raw.messageId,
|
||||
timestamp: raw.createdAt ? Date.parse(raw.createdAt) : Date.now(),
|
||||
rawText: raw.content,
|
||||
textForAgent: raw.content,
|
||||
}),
|
||||
classify: () => ({ kind: 'message', canStartAgentTurn: admit }),
|
||||
preflight: () =>
|
||||
admit ? {} : { admission: { kind: 'drop', reason: 'no-wakeup', recordHistory: true } },
|
||||
resolveTurn: (input: { id: string }) => ({
|
||||
route: {
|
||||
agentId,
|
||||
routeSessionKey: `agent:${agentId}:fabric:channel:${channelId}`,
|
||||
createIfMissing: true,
|
||||
},
|
||||
conversation: { kind: 'channel', id: channelId, label: `fabric:${guild.nodeId}` },
|
||||
reply: { to: channelId, nativeChannelId: channelId },
|
||||
message: {
|
||||
body: m.content,
|
||||
rawBody: m.content,
|
||||
bodyForAgent: m.content,
|
||||
envelopeFrom: m.authorUserId ?? 'fabric',
|
||||
},
|
||||
delivery: {
|
||||
deliver: async (payload: { text?: string }) => {
|
||||
const text = typeof payload?.text === 'string' ? payload.text : '';
|
||||
if (!text.trim()) return { visibleReplySent: false };
|
||||
const entry = this.identity.findByAgentId(agentId);
|
||||
const session = entry ? await this.client.agentLogin(entry.fabricApiKey) : null;
|
||||
const gt = session?.guildAccessTokens.find((t) => t.guildNodeId === guild.nodeId)?.token;
|
||||
if (!session || !gt) return { visibleReplySent: false };
|
||||
await this.client.postMessage(guild.endpoint, gt, channelId, text, session.user.id);
|
||||
return { visibleReplySent: true };
|
||||
},
|
||||
},
|
||||
meta: { admission: admit ? { kind: 'dispatch' } : { kind: 'drop', recordHistory: true } },
|
||||
}),
|
||||
},
|
||||
log: (e: { stage?: string }) => this.runtime.log?.debug?.(`fabric.turn.${e?.stage}`),
|
||||
peer: { kind: 'group', id: channelId },
|
||||
});
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
|
||||
const baseCtx: Record<string, unknown> = {
|
||||
Body: m.content,
|
||||
BodyForAgent: m.content,
|
||||
RawBody: m.content,
|
||||
CommandBody: m.content,
|
||||
From: `fabric:channel:${channelId}`,
|
||||
To: `fabric:${channelId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId ?? agentId,
|
||||
ChatType: 'group',
|
||||
ConversationLabel: `fabric:${guild.nodeId}`,
|
||||
SenderId: m.authorUserId ?? 'fabric',
|
||||
Provider: 'fabric',
|
||||
Surface: 'fabric',
|
||||
MessageSid: m.messageId,
|
||||
Timestamp: m.createdAt ? Date.parse(m.createdAt) : Date.now(),
|
||||
OriginatingChannel: 'fabric',
|
||||
OriginatingTo: `fabric:${channelId}`,
|
||||
};
|
||||
|
||||
// Non-wakeup: Fabric has already decided this agent is NOT the speaker
|
||||
// this round. Do NOT run the model and do NOT send anything back — the
|
||||
// discuss/work turn engine expects silence from non-woken agents (only
|
||||
// the woken speaker emits a normal message or /no-reply). We still
|
||||
// record the message into the agent's session so it has the full
|
||||
// channel conversation as context whenever it IS later woken.
|
||||
//
|
||||
// Exception: dm channels are 1:1 — there is no turn/wakeup gating;
|
||||
// any message that isn't the agent's own (already filtered above) is
|
||||
// always delivered to the model.
|
||||
if (m.xType !== 'dm' && m.wakeup !== true) {
|
||||
// Triage exception: non-wake messages (admin observer) MUST NOT
|
||||
// enter the agent's session at all. The next time the agent
|
||||
// wakes for a triage message, their context should contain only
|
||||
// their own past wakeups + their own outgoing messages — never
|
||||
// the observer-only chatter from other agents. For non-triage
|
||||
// channels keep the legacy "record-as-history" so a later wake
|
||||
// sees the full channel conversation.
|
||||
if (m.xType === 'triage') {
|
||||
this.log.info(
|
||||
`fabric: triage observer skip agent=${agentId} channel=${channelId} msg=${m.messageId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext(baseCtx);
|
||||
await core.channel.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
createIfMissing: true,
|
||||
onRecordError: (err: unknown) =>
|
||||
this.log.warn(`fabric: history record failed agent=${agentId}: ${String(err)}`),
|
||||
});
|
||||
this.log.info(
|
||||
`fabric: recorded (no wakeup, history only) agent=${agentId} channel=${channelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.info(`fabric: dispatch agent=${agentId} channel=${channelId}`);
|
||||
const gt = await this.freshGuildToken(agentId, guild.nodeId, session);
|
||||
|
||||
// Fetch any uploaded files for the agent: download to a temp dir and
|
||||
// hand openclaw local MediaPaths (+types) so the model receives them.
|
||||
const media = await this.fetchAttachments(agentId, guild.endpoint, gt, m);
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
...baseCtx,
|
||||
// Provide ONLY local paths. The guild file URL is on a private host
|
||||
// (e.g. localhost); openclaw's SSRF guard blocks re-fetching it, so
|
||||
// passing MediaUrls is both redundant (we already downloaded the
|
||||
// bytes) and noisy. Local MediaPaths is the reliable delivery.
|
||||
...(media.paths.length
|
||||
? {
|
||||
MediaPaths: media.paths,
|
||||
MediaTypes: media.types,
|
||||
MediaPath: media.paths[0],
|
||||
MediaType: media.types[0],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
await dispatchInboundReplyWithBase({
|
||||
cfg: this.cfg as never,
|
||||
channel: 'fabric',
|
||||
accountId: agentId,
|
||||
route,
|
||||
storePath,
|
||||
ctxPayload: ctxPayload as never,
|
||||
core: this.core as never,
|
||||
deliver: async (payload: { text?: string }) => {
|
||||
const text = (payload?.text ?? '').trim();
|
||||
this.log.info(`fabric: deliver agent=${agentId} channel=${channelId} len=${text.length}`);
|
||||
if (!text || !gt) return;
|
||||
// Buffer segments; the merged message is posted right after
|
||||
// dispatch returns (the deterministic turn boundary, see the
|
||||
// finally below). Disable per channel: channels.fabric.coalesce.
|
||||
await enqueueDelivery({
|
||||
channelId,
|
||||
text,
|
||||
coalesce: resolveCoalesce(this.cfg as never),
|
||||
post: (t) =>
|
||||
this.client.postMessage(guild.endpoint, gt, channelId, t, session.user.id) as Promise<void>,
|
||||
log: (m) => this.log.info(m),
|
||||
});
|
||||
},
|
||||
onRecordError: (err: unknown) =>
|
||||
this.log.warn(`fabric: session record failed agent=${agentId}: ${String(err)}`),
|
||||
onDispatchError: (err: unknown, info: { kind: string }) =>
|
||||
this.log.warn(`fabric: ${info.kind} dispatch failed agent=${agentId}: ${String(err)}`),
|
||||
// - disableBlockStreaming: Fabric has no length limit, deliver the
|
||||
// whole reply as ONE message.
|
||||
// - sourceReplyDeliveryMode 'automatic': OpenClaw defaults group
|
||||
// chats to "message_tool_only", which SUPPRESSES auto-delivery of
|
||||
// the agent's text reply (it expects the agent to call a message
|
||||
// tool). Fabric already gates *when* an agent speaks via the
|
||||
// per-recipient wakeup flag, so once a turn is dispatched the
|
||||
// reply must always flow back through `deliver`. Forcing
|
||||
// 'automatic' overrides the group default so the reply is
|
||||
// delivered. (source-reply-delivery-mode: a truthy `requested`
|
||||
// wins unless it's message_tool_only with no tool available.)
|
||||
replyOptions: {
|
||||
disableBlockStreaming: true,
|
||||
sourceReplyDeliveryMode: 'automatic',
|
||||
} as never,
|
||||
});
|
||||
this.log.info(`fabric: dispatch returned agent=${agentId} channel=${channelId}`);
|
||||
} catch (err) {
|
||||
this.log.warn(`fabric: turn.run failed agent=${agentId} channel=${channelId}: ${String(err)}`);
|
||||
this.log.warn(`fabric: dispatch failed agent=${agentId} channel=${channelId}: ${String(err)}`);
|
||||
} finally {
|
||||
// Deterministic per-turn boundary: dispatchInboundReplyWithBase only
|
||||
// resolves AFTER every deliver() call of this turn has run, so the
|
||||
// buffer now holds all segments — flush them as ONE Fabric message.
|
||||
// No hooks, no timers, no idle guessing.
|
||||
await flushFabricForChannel(channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
336
src/tools.ts
336
src/tools.ts
@@ -25,7 +25,12 @@ export function registerFabricTools(
|
||||
// Resolve the calling agent's Fabric session + a guild's token/endpoint.
|
||||
const ctxGuild = async (agentId: string, guildNodeId: string) => {
|
||||
const entry = identity.findByAgentId(agentId);
|
||||
if (!entry) throw new Error(`agent ${agentId} not registered (call fabric-register)`);
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
`agent ${agentId} not registered — run: AGENT_ID=${agentId} ` +
|
||||
`~/.openclaw/bin/fabric-register --api-key <fak_…> (or set ` +
|
||||
`channels.fabric.accounts.${agentId}); then restart the gateway`,
|
||||
);
|
||||
const session = await client.agentLogin(entry.fabricApiKey);
|
||||
const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
|
||||
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
||||
@@ -33,37 +38,16 @@ export function registerFabricTools(
|
||||
return { session, guild, token };
|
||||
};
|
||||
|
||||
// fabric-register: bind this agent to a Fabric API key.
|
||||
api.registerTool((ctx: Ctx) => ({
|
||||
name: 'fabric-register',
|
||||
description: "Register this agent's Fabric API key (minted via Center CLI `user apikey`).",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['fabricApiKey'],
|
||||
properties: {
|
||||
fabricApiKey: { type: 'string', description: 'Fabric Center API key (fak_…)' },
|
||||
},
|
||||
},
|
||||
handler: async (params: { fabricApiKey: string }) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||
const session = await client.agentLogin(params.fabricApiKey);
|
||||
identity.upsert({
|
||||
agentId,
|
||||
fabricApiKey: params.fabricApiKey,
|
||||
fabricUserId: session.user.id,
|
||||
displayName: session.user.name,
|
||||
});
|
||||
return { ok: true, user: session.user };
|
||||
},
|
||||
}));
|
||||
// NOTE: binding an agent's Fabric API key is intentionally NOT a tool.
|
||||
// It's a one-time step done out-of-band via the installed script
|
||||
// ~/.openclaw/bin/fabric-register --api-key <fak_…> (AGENT_ID or --agent-id)
|
||||
// or via static config (channels.fabric.accounts.<agentId>).
|
||||
|
||||
const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') =>
|
||||
api.registerTool((ctx: Ctx) => ({
|
||||
name: `create-${kind}-channel`,
|
||||
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`,
|
||||
inputSchema: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId', 'name'],
|
||||
@@ -76,7 +60,7 @@ export function registerFabricTools(
|
||||
listeners: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
handler: async (p: {
|
||||
execute: async (p: {
|
||||
guildNodeId: string;
|
||||
name: string;
|
||||
isPublic?: boolean;
|
||||
@@ -106,7 +90,7 @@ export function registerFabricTools(
|
||||
api.registerTool((ctx: Ctx) => ({
|
||||
name: 'discussion-complete',
|
||||
description: 'Conclude a discussion: post a summary then close the channel.',
|
||||
inputSchema: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId', 'channelId', 'summary'],
|
||||
@@ -117,7 +101,7 @@ export function registerFabricTools(
|
||||
callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' },
|
||||
},
|
||||
},
|
||||
handler: async (p: {
|
||||
execute: async (p: {
|
||||
guildNodeId: string;
|
||||
channelId: string;
|
||||
summary: string;
|
||||
@@ -136,4 +120,296 @@ export function registerFabricTools(
|
||||
return { ok: true, closed: true };
|
||||
},
|
||||
}));
|
||||
|
||||
// fabric-canvas: share / update / read / close the channel's single
|
||||
// pinned canvas document (one tool, four actions). update/close are
|
||||
// sharer-only server-side (the guild returns 403 otherwise).
|
||||
api.registerTool((ctx: Ctx) => ({
|
||||
name: 'fabric-canvas',
|
||||
description:
|
||||
"Manage a channel's pinned canvas document. action: " +
|
||||
"read (current canvas or null) | share (create/replace; you become " +
|
||||
'the sharer) | update (edit in place; sharer only) | close (remove; ' +
|
||||
'sharer only).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action', 'guildNodeId', 'channelId'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['read', 'share', 'update', 'close'] },
|
||||
guildNodeId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
title: { type: 'string', description: 'share: required; update: optional' },
|
||||
format: {
|
||||
type: 'string',
|
||||
enum: ['md', 'html', 'text'],
|
||||
description: 'share: required; update: optional',
|
||||
},
|
||||
source: {
|
||||
type: 'string',
|
||||
description: 'document body. share: required; update: optional',
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: async (p: {
|
||||
action: 'read' | 'share' | 'update' | 'close';
|
||||
guildNodeId: string;
|
||||
channelId: string;
|
||||
title?: string;
|
||||
format?: 'md' | 'html' | 'text';
|
||||
source?: string;
|
||||
}) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const ep = guild.endpoint;
|
||||
switch (p.action) {
|
||||
case 'read': {
|
||||
const canvas = await client.getCanvas(ep, token, p.channelId);
|
||||
return { ok: true, canvas };
|
||||
}
|
||||
case 'share': {
|
||||
if (!p.title || !p.format || p.source === undefined) {
|
||||
return { ok: false, error: 'share requires title, format, and source' };
|
||||
}
|
||||
const canvas = await client.shareCanvas(ep, token, p.channelId, {
|
||||
title: p.title,
|
||||
format: p.format,
|
||||
source: p.source,
|
||||
});
|
||||
return { ok: true, canvas };
|
||||
}
|
||||
case 'update': {
|
||||
const body: Partial<{ title: string; format: 'md' | 'html' | 'text'; source: string }> = {};
|
||||
if (p.title !== undefined) body.title = p.title;
|
||||
if (p.format !== undefined) body.format = p.format;
|
||||
if (p.source !== undefined) body.source = p.source;
|
||||
if (Object.keys(body).length === 0) {
|
||||
return { ok: false, error: 'update needs at least one of title/format/source' };
|
||||
}
|
||||
const canvas = await client.updateCanvas(ep, token, p.channelId, body);
|
||||
return { ok: true, canvas };
|
||||
}
|
||||
case 'close': {
|
||||
await client.removeCanvas(ep, token, p.channelId);
|
||||
return { ok: true, removed: true };
|
||||
}
|
||||
default:
|
||||
return { ok: false, error: `unknown action ${String(p.action)}` };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// fabric-channel: channel membership (one tool, three actions).
|
||||
api.registerTool((ctx: Ctx) => ({
|
||||
name: 'fabric-channel',
|
||||
description:
|
||||
'Channel membership. action: members (list channel member userIds) | ' +
|
||||
'join (this agent joins the channel) | leave (this agent leaves).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['action', 'guildNodeId', 'channelId'],
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['members', 'join', 'leave'] },
|
||||
guildNodeId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
execute: async (p: {
|
||||
action: 'members' | 'join' | 'leave';
|
||||
guildNodeId: string;
|
||||
channelId: string;
|
||||
}) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const ep = guild.endpoint;
|
||||
switch (p.action) {
|
||||
case 'members': {
|
||||
const members = await client.channelMembers(ep, token, p.channelId);
|
||||
return { ok: true, members };
|
||||
}
|
||||
case 'join': {
|
||||
await client.joinChannel(ep, token, p.channelId);
|
||||
return { ok: true, joined: true };
|
||||
}
|
||||
case 'leave': {
|
||||
await client.leaveChannel(ep, token, p.channelId);
|
||||
return { ok: true, left: true };
|
||||
}
|
||||
default:
|
||||
return { ok: false, error: `unknown action ${String(p.action)}` };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// fabric-send-message: post a message into a specific channel.
|
||||
//
|
||||
// Unlike a normal channel reply (which goes back to whatever channel
|
||||
// woke the agent), this lets the agent proactively initiate text into
|
||||
// any channel they are a member of — e.g. ARD broadcasting daily
|
||||
// workload to #agents-room, or triage agent following up on an
|
||||
// already-routed task by commenting in #updates.
|
||||
// -----------------------------------------------------------------
|
||||
api.registerTool((ctx: Ctx) => ({
|
||||
name: 'fabric-send-message',
|
||||
description:
|
||||
'Send a text message into a specific Fabric channel. Author is the calling agent. ' +
|
||||
'Requires guildNodeId + channelId + content. Returns {ok, messageId, seq}.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId', 'channelId', 'content'],
|
||||
properties: {
|
||||
guildNodeId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
content: { type: 'string', description: 'Message body (markdown supported by the renderer).' },
|
||||
},
|
||||
},
|
||||
execute: async (p: { guildNodeId: string; channelId: string; content: string }) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||
const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const res = (await client.postMessage(
|
||||
guild.endpoint,
|
||||
token,
|
||||
p.channelId,
|
||||
p.content,
|
||||
session.user.id,
|
||||
)) as { messageId?: string; seq?: number };
|
||||
return { ok: true, messageId: res.messageId, seq: res.seq };
|
||||
},
|
||||
}));
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// fabric-channel-list: enumerate channels the calling agent can see
|
||||
// in a given guild. Backend filters to public channels + channels the
|
||||
// agent is a member of. Returns id / name / xType per channel so the
|
||||
// agent can pick a channelId for fabric-send-message etc.
|
||||
// -----------------------------------------------------------------
|
||||
api.registerTool((ctx: Ctx) => ({
|
||||
name: 'fabric-channel-list',
|
||||
description:
|
||||
'List channels visible to the calling agent in a guild. Optional ' +
|
||||
'nameFilter does a case-insensitive substring match client-side. ' +
|
||||
'Use this to find a channelId before fabric-send-message / fabric-message-history.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId'],
|
||||
properties: {
|
||||
guildNodeId: { type: 'string' },
|
||||
nameFilter: { type: 'string', description: 'optional substring match on channel name (case-insensitive)' },
|
||||
xType: {
|
||||
type: 'string',
|
||||
enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'],
|
||||
description: 'optional filter by x_type',
|
||||
},
|
||||
includeClosed: { type: 'boolean', description: 'default false — closed channels filtered out' },
|
||||
},
|
||||
},
|
||||
execute: async (p: {
|
||||
guildNodeId: string;
|
||||
nameFilter?: string;
|
||||
xType?: string;
|
||||
includeClosed?: boolean;
|
||||
}) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const all = await client.listChannels(guild.endpoint, token, p.guildNodeId);
|
||||
const needle = (p.nameFilter ?? '').toLowerCase();
|
||||
const filtered = all.filter((c) => {
|
||||
if (!p.includeClosed && c.closed) return false;
|
||||
if (p.xType && c.xType !== p.xType) return false;
|
||||
if (needle && !c.name.toLowerCase().includes(needle)) return false;
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
count: filtered.length,
|
||||
channels: filtered.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
xType: c.xType,
|
||||
isPublic: c.isPublic,
|
||||
closed: c.closed,
|
||||
lastSeq: c.lastSeq,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// fabric-message-history: read a channel's recent message history by
|
||||
// `seq`. Tail-by-default: when `seqFrom`/`seqTo` are omitted, returns
|
||||
// the last `limit` messages (limit defaults to 20, max 200).
|
||||
//
|
||||
// Use cases: catch-up on a channel that was muted while the agent was
|
||||
// gated; verify a previous message went through; lookup recent
|
||||
// duplicates before opening a new task in triage.
|
||||
// -----------------------------------------------------------------
|
||||
api.registerTool((ctx: Ctx) => ({
|
||||
name: 'fabric-message-history',
|
||||
description:
|
||||
"Read a channel's recent message history. Omit seqFrom/seqTo to " +
|
||||
'tail (last `limit` messages, default 20, max 200). Backend ' +
|
||||
'requires the calling agent to be a channel participant.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['guildNodeId', 'channelId'],
|
||||
properties: {
|
||||
guildNodeId: { type: 'string' },
|
||||
channelId: { type: 'string' },
|
||||
seqFrom: { type: 'integer', minimum: 1, description: 'inclusive lower bound; default = tail' },
|
||||
seqTo: { type: 'integer', minimum: 1, description: 'inclusive upper bound; default = channel head' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 200, description: 'default 20' },
|
||||
},
|
||||
},
|
||||
execute: async (p: {
|
||||
guildNodeId: string;
|
||||
channelId: string;
|
||||
seqFrom?: number;
|
||||
seqTo?: number;
|
||||
limit?: number;
|
||||
}) => {
|
||||
const agentId = ctx.agentId;
|
||||
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||
const limit = p.limit ?? 20;
|
||||
|
||||
// Tail mode: discover channel head via channel listing, then ask
|
||||
// for [head-limit+1, head]. Avoids needing the agent to know seq.
|
||||
let seqFrom = p.seqFrom;
|
||||
let seqTo = p.seqTo;
|
||||
if (seqFrom === undefined && seqTo === undefined) {
|
||||
const channels = await client.listChannels(guild.endpoint, token, p.guildNodeId);
|
||||
const ch = channels.find((c) => c.id === p.channelId);
|
||||
const head = ch?.lastSeq ?? 0;
|
||||
seqFrom = Math.max(1, head - limit + 1);
|
||||
seqTo = head;
|
||||
}
|
||||
|
||||
const res = await client.listMessages(guild.endpoint, token, p.channelId, {
|
||||
seqFrom,
|
||||
seqTo,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
page: res.page,
|
||||
messages: res.items.map((m) => ({
|
||||
messageId: m.messageId,
|
||||
seq: m.seq,
|
||||
authorUserId: m.authorUserId,
|
||||
content: m.content,
|
||||
createdAt: m.createdAt,
|
||||
isDeleted: m.isDeleted,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user