Compare commits
45 Commits
42228e0a23
...
fix/routin
| Author | SHA1 | Date | |
|---|---|---|---|
| ea713064e1 | |||
| 180b717eda | |||
| 152b465e64 | |||
| 7f96fffca9 | |||
| b659dadb9e | |||
| 20e55849eb | |||
| d47d3467df | |||
| 7dc70522d1 | |||
| 2acb084ee4 | |||
| 9419d270e5 | |||
| 79b29db26c | |||
| a87de27cff | |||
| dabaa6e1f2 | |||
| b8e0e424fa | |||
| 81a10f2a1f | |||
| c5429129d9 | |||
| c330571bcb | |||
| 8963f3ca00 | |||
| 0e36457d8f | |||
| 5ff464a055 | |||
| 6fe06f55dd | |||
| a15dc880af | |||
| 5dcbd99c28 | |||
| cd36d1b9e2 | |||
| 9c910f082b | |||
| c5fd091f5a | |||
| c5a33c33ec | |||
| 28f5083679 | |||
| a060ff98a2 | |||
| b9a5456d57 | |||
| d1d5ad10ca | |||
| 92945b777d | |||
| 8774cfd7cc | |||
| ab126825ef | |||
| bb63a57384 | |||
| fc6edaabfd | |||
| c03562046d | |||
| fac6debfa5 | |||
| aaabb0ddb0 | |||
| 26c12533fb | |||
| 9d0fa1d5c8 | |||
| 892db9f9be | |||
| fc7efd0227 | |||
| d79a04b8a3 | |||
| cc655ffcc3 |
132
README.md
132
README.md
@@ -1,65 +1,139 @@
|
|||||||
# Fabric.OpenclawPlugin
|
# 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
|
## Model
|
||||||
|
|
||||||
- `kind: "channel"` plugin (like the bundled discord channel). OpenClaw **core
|
- `kind: "channel"` plugin (like the bundled discord channel). OpenClaw core
|
||||||
owns dispatch** (inbound → agent run) and the reply pipeline via the channel
|
owns dispatch and the reply pipeline via the channel-turn kernel
|
||||||
turn kernel `runtime.channel.turn.run(...)`.
|
(`resolveAgentRoute` + `finalizeInboundContext` +
|
||||||
- Fabric already owns turn/shuffle/mention/`/no-reply` server-side, so this
|
`dispatchInboundReplyWithBase`). Fabric already owns turn/shuffle/mention/
|
||||||
plugin is thin. Fabric's per-recipient **`wakeup`** maps to channel-turn
|
`/no-reply` server-side, so this plugin is thin.
|
||||||
**admission**:
|
- Fabric's per-recipient **`wakeup`** maps to admission:
|
||||||
- `wakeup === true` → `dispatch` (agent runs, may reply)
|
- `wakeup === true` → **dispatch** (the agent runs and may reply).
|
||||||
- otherwise → `{ kind: "drop", recordHistory: true }` (kept as context)
|
- `wakeup !== true` → **record only**: the message is written to the
|
||||||
- **No sidecar, no fake no-reply model, no `before_model_resolve` gating.**
|
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
|
## Auth
|
||||||
|
|
||||||
Each agent has a Fabric Center **API key** (mint via Center CLI:
|
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
|
`node dist/cli.js user apikey --email <agent-email>`). The key is exchanged
|
||||||
a user session (`POST /auth/agent/login`) used to receive (socket) and post
|
for a user session (`POST /auth/agent/login`).
|
||||||
replies. Bind a key to an agent via the `fabric-register` tool, or pre-populate
|
|
||||||
the identity file.
|
### 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
|
## Config
|
||||||
|
|
||||||
- `channels.fabric.centerApiBase` — e.g. `http://localhost:7001/api`
|
- `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 }`
|
- `channels.fabric.accounts.<agentId>` = `{ fabricApiKey, enabled }`
|
||||||
(agent = account; the account id is the openclaw agentId)
|
(**agent = account**; the account id is the OpenClaw agentId)
|
||||||
- plugin `identityFilePath` — default `~/.openclaw/fabric-identity.json`
|
- plugin `identityFilePath` — default `~/.openclaw/fabric-identity.json`
|
||||||
(`{ entries: [{ agentId, fabricApiKey }] }`)
|
|
||||||
|
|
||||||
### Required: route binding (account → agent)
|
### Required: route binding (account → agent)
|
||||||
|
|
||||||
openclaw routes a channel turn to an agent via `cfg.bindings`. Without a
|
OpenClaw routes a channel turn via `cfg.bindings`; without a Fabric binding
|
||||||
Fabric binding it falls back to the default agent. Add one per account:
|
it falls back to the default agent. One per account:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "agentId": "<agent>", "match": { "channel": "fabric", "accountId": "<account>" } }
|
{ "agentId": "<agent>", "match": { "channel": "fabric", "accountId": "<account>" } }
|
||||||
```
|
```
|
||||||
|
|
||||||
e.g. `openclaw config set bindings '[…, {"agentId":"echo","match":{"channel":"fabric","accountId":"echo"}}]' --json`,
|
Then `openclaw gateway restart`.
|
||||||
then `openclaw gateway restart`.
|
|
||||||
|
|
||||||
## Tools
|
## 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-chat-channel` (general) / `create-work-channel` (work) /
|
||||||
`create-report-channel` (report) / `create-discussion-channel` (discuss)
|
`create-report-channel` (report) / `create-discussion-channel` (discuss)
|
||||||
- `discussion-complete` — post a summary then close the channel
|
- `discussion-complete` — post a summary, then close the channel
|
||||||
(Fabric `POST /channels/:id/close`; closed → history readable, posts → 409)
|
(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
|
On `gateway_start` the plugin syncs OpenClaw's native-command catalog to
|
||||||
a later drop-in behind the same `dispatch()` seam.
|
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
|
```bash
|
||||||
npm install && npm run build
|
npm install && npm run build
|
||||||
|
node install.mjs # build + copy to ~/.openclaw/plugins/fabric + configure
|
||||||
```
|
```
|
||||||
|
|
||||||
> The plugin compiles against the host's OpenClaw SDK
|
`install.mjs` mirrors the PaddedCell-style installer (also `--uninstall`).
|
||||||
> (`openclaw/plugin-sdk/*`); build inside the OpenClaw plugin environment.
|
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)));
|
||||||
64
dist/fabric/index.js
vendored
64
dist/fabric/index.js
vendored
@@ -5,15 +5,24 @@
|
|||||||
// the OpenClawPluginApi for runtime startup (transport + tools).
|
// the OpenClawPluginApi for runtime startup (transport + tools).
|
||||||
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
|
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
|
||||||
import { fabricChannelPlugin } from './src/channel.js';
|
import { fabricChannelPlugin } from './src/channel.js';
|
||||||
|
import { flushAllFabric } from './src/coalesce.js';
|
||||||
|
import { getChannelType, flushChannelMeta } from './src/channel-meta.js';
|
||||||
import { FabricInbound } from './src/inbound.js';
|
import { FabricInbound } from './src/inbound.js';
|
||||||
import { listEnabledFabricAccounts } from './src/accounts.js';
|
import { listEnabledFabricAccounts } from './src/accounts.js';
|
||||||
import { registerFabricTools } from './src/tools.js';
|
import { registerFabricTools } from './src/tools.js';
|
||||||
import { FabricClient } from './src/fabric-client.js';
|
import { FabricClient } from './src/fabric-client.js';
|
||||||
import { IdentityRegistry } from './src/identity.js';
|
import { IdentityRegistry } from './src/identity.js';
|
||||||
|
import { syncFabricCommands } from './src/command-sync.js';
|
||||||
|
import { PresenceSync } from './src/presence-sync.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
let runtimeRef = null;
|
let runtimeRef = null;
|
||||||
let inbound = null;
|
let inbound = null;
|
||||||
|
let presence = null;
|
||||||
|
// Periodic re-harvest of presence accounts so newly-connected agents
|
||||||
|
// (registered through tool-based identity flow AFTER initial start)
|
||||||
|
// get picked up. Cleared on gateway_stop.
|
||||||
|
let presenceRefreshTimer = null;
|
||||||
export { fabricChannelPlugin } from './src/channel.js';
|
export { fabricChannelPlugin } from './src/channel.js';
|
||||||
export default defineChannelPluginEntry({
|
export default defineChannelPluginEntry({
|
||||||
id: 'fabric',
|
id: 'fabric',
|
||||||
@@ -35,6 +44,29 @@ export default defineChannelPluginEntry({
|
|||||||
const client = new FabricClient(centerApiBase);
|
const client = new FabricClient(centerApiBase);
|
||||||
const identity = new IdentityRegistry(idFile);
|
const identity = new IdentityRegistry(idFile);
|
||||||
registerFabricTools({ registerTool: (d) => api.registerTool(d), logger: api.logger }, client, identity);
|
registerFabricTools({ registerTool: (d) => api.registerTool(d), logger: api.logger }, client, identity);
|
||||||
|
// Cross-plugin API: globalThis.__fabric
|
||||||
|
// Consumed by ClawPrompts' fabric-chat-injector to narrow its prompt
|
||||||
|
// injection to DM-typed channels only. The channel-meta cache is
|
||||||
|
// populated lazily from inbound (message.created carries xType) and
|
||||||
|
// persisted to ~/.openclaw/fabric-channel-meta.json — so even the
|
||||||
|
// very first DM after a fresh gateway start hits cache from the
|
||||||
|
// previous run rather than firing the injector on the wrong type.
|
||||||
|
//
|
||||||
|
// null return = channel never seen (cache cold). Callers MUST NOT
|
||||||
|
// fall back to "assume DM" — fail closed on unknown.
|
||||||
|
{
|
||||||
|
const _G = globalThis;
|
||||||
|
_G['__fabric'] = { getChannelType };
|
||||||
|
// Flush channel-meta cache when the gateway shuts down so
|
||||||
|
// recently-recorded xType entries don't get lost.
|
||||||
|
api.on('gateway_stop', () => {
|
||||||
|
try {
|
||||||
|
flushChannelMeta();
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
api.logger.info('fabric: __fabric cross-plugin API installed (getChannelType)');
|
||||||
|
}
|
||||||
api.on('gateway_start', () => {
|
api.on('gateway_start', () => {
|
||||||
const _G = globalThis;
|
const _G = globalThis;
|
||||||
if (_G._fabricInboundStarted)
|
if (_G._fabricInboundStarted)
|
||||||
@@ -55,10 +87,40 @@ export default defineChannelPluginEntry({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inbound = new FabricInbound(runtimeRef, api.config, client, identity, api.logger, accounts);
|
inbound = new FabricInbound(runtimeRef, api.config, client, identity, api.logger, accounts);
|
||||||
void inbound.start();
|
// start() resolves once all accounts have attempted login; per-
|
||||||
|
// agent failures are logged but don't reject. Once it resolves we
|
||||||
|
// can harvest the presence accounts (those that DID log in have
|
||||||
|
// their fabricUserId + first guild endpoint populated).
|
||||||
|
void inbound.start().then(() => {
|
||||||
|
if (!inbound)
|
||||||
|
return;
|
||||||
|
presence = new PresenceSync(api.logger, client);
|
||||||
|
presence.setAccounts(inbound.getPresenceAccounts());
|
||||||
|
presence.start();
|
||||||
|
api.logger.info(`fabric: presence-sync started for ${inbound.getPresenceAccounts().length} account(s)`);
|
||||||
|
// Re-harvest every 5 min: catches agents added via tool-based
|
||||||
|
// identity provisioning after gateway_start (recruitment flow).
|
||||||
|
// setAccounts is idempotent — duplicates collapse on agentId.
|
||||||
|
presenceRefreshTimer = setInterval(() => {
|
||||||
|
if (inbound && presence)
|
||||||
|
presence.setAccounts(inbound.getPresenceAccounts());
|
||||||
|
}, 5 * 60_000);
|
||||||
|
});
|
||||||
api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`);
|
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', () => {
|
api.on('gateway_stop', () => {
|
||||||
|
void flushAllFabric();
|
||||||
|
if (presenceRefreshTimer) {
|
||||||
|
clearInterval(presenceRefreshTimer);
|
||||||
|
presenceRefreshTimer = null;
|
||||||
|
}
|
||||||
|
presence?.stop();
|
||||||
|
presence = null;
|
||||||
inbound?.stop();
|
inbound?.stop();
|
||||||
inbound = null;
|
inbound = null;
|
||||||
});
|
});
|
||||||
|
|||||||
11
dist/fabric/src/accounts.js
vendored
11
dist/fabric/src/accounts.js
vendored
@@ -7,6 +7,17 @@ const DEFAULT_CENTER = 'http://localhost:7001/api';
|
|||||||
function section(cfg) {
|
function section(cfg) {
|
||||||
return cfg.channels?.fabric ?? {};
|
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) {
|
export function listFabricAccountIds(cfg) {
|
||||||
const accts = section(cfg).accounts ?? {};
|
const accts = section(cfg).accounts ?? {};
|
||||||
const ids = Object.keys(accts);
|
const ids = Object.keys(accts);
|
||||||
|
|||||||
105
dist/fabric/src/channel-meta.js
vendored
Normal file
105
dist/fabric/src/channel-meta.js
vendored
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Channel-meta cache. Records (channelId → xType) for every fabric
|
||||||
|
* channel the gateway has seen at least one inbound message in.
|
||||||
|
*
|
||||||
|
* Populated lazily from inbound (`recordChannelType` is called for
|
||||||
|
* every `message.created` event with non-empty `xType`). Persisted to
|
||||||
|
* `~/.openclaw/fabric-channel-meta.json` so the cache survives
|
||||||
|
* gateway restarts (so the very first DM after restart still gets the
|
||||||
|
* right xType without waiting for a fresh inbound).
|
||||||
|
*
|
||||||
|
* Exposed cross-plugin via `globalThis.__fabric.getChannelType`. Used
|
||||||
|
* by ClawPrompts' fabric-chat-injector to narrow its prompt injection
|
||||||
|
* to xType==='dm' only.
|
||||||
|
*
|
||||||
|
* Failure mode: lookup misses (channel never seen / inbound dropped
|
||||||
|
* xType) return null. Callers MUST treat null as "unknown" — DO NOT
|
||||||
|
* fall back to "assume DM" or you re-introduce the false-positive on
|
||||||
|
* group channels.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
const CACHE_FILE = join(homedir(), '.openclaw', 'fabric-channel-meta.json');
|
||||||
|
let memory = new Map();
|
||||||
|
let loaded = false;
|
||||||
|
let dirty = false;
|
||||||
|
let flushTimer = null;
|
||||||
|
function load() {
|
||||||
|
if (loaded)
|
||||||
|
return;
|
||||||
|
loaded = true;
|
||||||
|
try {
|
||||||
|
if (!existsSync(CACHE_FILE))
|
||||||
|
return;
|
||||||
|
const raw = readFileSync(CACHE_FILE, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
for (const [k, v] of Object.entries(parsed.channels ?? {})) {
|
||||||
|
if (typeof k === 'string' && typeof v === 'string')
|
||||||
|
memory.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// ignore — start with empty cache on corruption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function scheduleFlush() {
|
||||||
|
if (flushTimer)
|
||||||
|
return;
|
||||||
|
// Debounce writes — many inbound messages may arrive in a burst.
|
||||||
|
// 250ms coalesces them; on gateway_stop the channel plugin can force
|
||||||
|
// a synchronous flush via flushChannelMeta().
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null;
|
||||||
|
if (!dirty)
|
||||||
|
return;
|
||||||
|
dirty = false;
|
||||||
|
flushSync();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
function flushSync() {
|
||||||
|
try {
|
||||||
|
const dir = dirname(CACHE_FILE);
|
||||||
|
if (!existsSync(dir))
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const out = { channels: Object.fromEntries(memory) };
|
||||||
|
const tmp = CACHE_FILE + '.tmp';
|
||||||
|
writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
||||||
|
renameSync(tmp, CACHE_FILE);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// swallow — cache is an optimization; loss-on-write is recoverable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Called by inbound on every message.created. xType empty → no-op. */
|
||||||
|
export function recordChannelType(channelId, xType) {
|
||||||
|
if (!channelId || !xType)
|
||||||
|
return;
|
||||||
|
load();
|
||||||
|
const existing = memory.get(channelId);
|
||||||
|
if (existing === xType)
|
||||||
|
return;
|
||||||
|
memory.set(channelId, xType);
|
||||||
|
dirty = true;
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
/** Cross-plugin lookup. null when channel never seen / unknown. */
|
||||||
|
export function getChannelType(channelId) {
|
||||||
|
if (!channelId)
|
||||||
|
return null;
|
||||||
|
load();
|
||||||
|
return memory.get(channelId) ?? null;
|
||||||
|
}
|
||||||
|
/** Force-flush — called on plugin shutdown to make sure recently
|
||||||
|
* recorded entries hit disk before the gateway dies. */
|
||||||
|
export function flushChannelMeta() {
|
||||||
|
if (flushTimer) {
|
||||||
|
clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
}
|
||||||
|
if (dirty) {
|
||||||
|
dirty = false;
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const CHANNEL_META_PATH = CACHE_FILE;
|
||||||
36
dist/fabric/src/channel.js
vendored
36
dist/fabric/src/channel.js
vendored
@@ -11,6 +11,15 @@
|
|||||||
import { createChatChannelPlugin, createChannelPluginBase, buildChannelOutboundSessionRoute, } from 'openclaw/plugin-sdk/core';
|
import { createChatChannelPlugin, createChannelPluginBase, buildChannelOutboundSessionRoute, } from 'openclaw/plugin-sdk/core';
|
||||||
import { FabricClient } from './fabric-client.js';
|
import { FabricClient } from './fabric-client.js';
|
||||||
import { listFabricAccountIds, resolveFabricAccount, resolveDefaultFabricAccountId, } from './accounts.js';
|
import { listFabricAccountIds, resolveFabricAccount, resolveDefaultFabricAccountId, } from './accounts.js';
|
||||||
|
import { getChannelType } from './channel-meta.js';
|
||||||
|
export function fabricPeerRoutingForXType(xType) {
|
||||||
|
if (xType === 'dm')
|
||||||
|
return { peerKind: 'direct', chatType: 'direct' };
|
||||||
|
return { peerKind: 'group', chatType: 'group' };
|
||||||
|
}
|
||||||
|
export function fabricPeerRoutingForChannel(channelId) {
|
||||||
|
return fabricPeerRoutingForXType(getChannelType(channelId));
|
||||||
|
}
|
||||||
// ---- target grammar: fabric:<channelId> ----
|
// ---- target grammar: fabric:<channelId> ----
|
||||||
export function stripFabricTargetPrefix(raw) {
|
export function stripFabricTargetPrefix(raw) {
|
||||||
let s = (raw ?? '').trim();
|
let s = (raw ?? '').trim();
|
||||||
@@ -38,13 +47,18 @@ export function resolveFabricOutboundSessionRoute(params) {
|
|||||||
const id = stripFabricTargetPrefix(params.target);
|
const id = stripFabricTargetPrefix(params.target);
|
||||||
if (!id)
|
if (!id)
|
||||||
return null;
|
return null;
|
||||||
|
// Consult the channel-meta cache populated by inbound — DM channels
|
||||||
|
// need peer.kind='direct' so the outbound session key matches the
|
||||||
|
// inbound one. Cache miss falls back to 'group' (the pre-fix default,
|
||||||
|
// no regression on cold cache).
|
||||||
|
const { peerKind, chatType } = fabricPeerRoutingForChannel(id);
|
||||||
return buildChannelOutboundSessionRoute({
|
return buildChannelOutboundSessionRoute({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
channel: 'fabric',
|
channel: 'fabric',
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
peer: { kind: 'group', id },
|
peer: { kind: peerKind, id },
|
||||||
chatType: 'group',
|
chatType,
|
||||||
from: `fabric:channel:${id}`,
|
from: `fabric:channel:${id}`,
|
||||||
to: `fabric:${id}`,
|
to: `fabric:${id}`,
|
||||||
});
|
});
|
||||||
@@ -103,6 +117,19 @@ export const fabricChannelPlugin = createChatChannelPlugin({
|
|||||||
resolveAccount: (cfg, accountId) => resolveFabricAccount(cfg, accountId),
|
resolveAccount: (cfg, accountId) => resolveFabricAccount(cfg, accountId),
|
||||||
defaultAccountId: (cfg) => resolveDefaultFabricAccountId(cfg),
|
defaultAccountId: (cfg) => resolveDefaultFabricAccountId(cfg),
|
||||||
isConfigured: (account) => Boolean(account.fabricApiKey),
|
isConfigured: (account) => Boolean(account.fabricApiKey),
|
||||||
|
// openclaw's channelManager.getRuntimeSnapshot() — called every minute
|
||||||
|
// by the channel-health-monitor — defaults `configured: true` when the
|
||||||
|
// plugin doesn't expose describeAccount (see applyDescribedAccountFields
|
||||||
|
// in server-channels). Without this, fabric's synthetic 'default'
|
||||||
|
// account (returned by listFabricAccountIds when channels.fabric.accounts
|
||||||
|
// is empty — the prod shape) gets snapshot {enabled:true, configured:true,
|
||||||
|
// running:false} → isManagedAccount=true → not-running → restart loop
|
||||||
|
// every ~10 min, logging `[fabric:default] health-monitor: restarting`.
|
||||||
|
// Mirror isConfigured here so the snapshot truthfully reports false for
|
||||||
|
// any account without a fabricApiKey.
|
||||||
|
describeAccount: (account) => ({
|
||||||
|
configured: Boolean(account.fabricApiKey),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
// Minimal setup adapter: Fabric is configured directly under
|
// Minimal setup adapter: Fabric is configured directly under
|
||||||
// channels.fabric.* (no interactive wizard). applyAccountConfig is the
|
// channels.fabric.* (no interactive wizard). applyAccountConfig is the
|
||||||
@@ -137,7 +164,10 @@ export const fabricChannelPlugin = createChatChannelPlugin({
|
|||||||
attachedResults: {
|
attachedResults: {
|
||||||
channel: 'fabric',
|
channel: 'fabric',
|
||||||
sendText: async (ctx) => {
|
sendText: async (ctx) => {
|
||||||
// openclaw passes config under cfg or config depending on path
|
// 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 ?? {});
|
const cfg = (ctx.cfg ?? ctx.config ?? {});
|
||||||
try {
|
try {
|
||||||
const r = await sendToFabric(cfg, ctx.accountId ?? null, ctx.to, ctx.text);
|
const r = await sendToFabric(cfg, ctx.accountId ?? null, ctx.to, ctx.text);
|
||||||
|
|||||||
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
dist/fabric/src/fabric-client.js
vendored
87
dist/fabric/src/fabric-client.js
vendored
@@ -21,6 +21,25 @@ export class FabricClient {
|
|||||||
}
|
}
|
||||||
return (await res.json());
|
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).
|
// Exchange an agent API key for a Fabric user session (+ guild tokens).
|
||||||
agentLogin(apiKey) {
|
agentLogin(apiKey) {
|
||||||
return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey });
|
return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey });
|
||||||
@@ -44,10 +63,78 @@ export class FabricClient {
|
|||||||
createChannel(guildEndpoint, guildToken, body) {
|
createChannel(guildEndpoint, guildToken, body) {
|
||||||
return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
|
return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
|
||||||
}
|
}
|
||||||
|
// PATCH /api/channels/:id — backend currently only patches `purpose`.
|
||||||
|
// Caller must be a member of the channel (or any user if public).
|
||||||
|
setChannelPurpose(guildEndpoint, guildToken, channelId, purpose) {
|
||||||
|
return this.req('PATCH', `${guildEndpoint}/api/channels/${channelId}`, guildToken, { purpose });
|
||||||
|
}
|
||||||
closeChannel(guildEndpoint, guildToken, channelId) {
|
closeChannel(guildEndpoint, guildToken, channelId) {
|
||||||
return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
|
return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
|
||||||
}
|
}
|
||||||
joinChannel(guildEndpoint, guildToken, channelId) {
|
joinChannel(guildEndpoint, guildToken, channelId) {
|
||||||
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
|
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);
|
||||||
|
}
|
||||||
|
// ---- 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, guildToken, guildNodeId) {
|
||||||
|
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, guildToken, channelId, opts = {}) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
446
dist/fabric/src/inbound.js
vendored
446
dist/fabric/src/inbound.js
vendored
@@ -3,6 +3,10 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
||||||
|
import { resolveCoalesce } from './accounts.js';
|
||||||
|
import { fabricPeerRoutingForXType } from './channel.js';
|
||||||
|
import { recordChannelType } from './channel-meta.js';
|
||||||
|
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
|
||||||
export class FabricInbound {
|
export class FabricInbound {
|
||||||
core;
|
core;
|
||||||
cfg;
|
cfg;
|
||||||
@@ -12,6 +16,162 @@ export class FabricInbound {
|
|||||||
accounts;
|
accounts;
|
||||||
sockets = [];
|
sockets = [];
|
||||||
seen = new Set();
|
seen = new Set();
|
||||||
|
// 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;
|
||||||
|
// 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.
|
||||||
|
channelChains = new Map();
|
||||||
|
// 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.
|
||||||
|
agentStatusCache = new Map();
|
||||||
|
static 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).
|
||||||
|
pendingTriageGated = [];
|
||||||
|
// 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.
|
||||||
|
enqueueChannelTask(channelId, task) {
|
||||||
|
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.
|
||||||
|
async checkAgentOnCall(agentId) {
|
||||||
|
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;
|
||||||
|
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());
|
||||||
|
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.
|
||||||
|
async drainGatedFor(agentId) {
|
||||||
|
const keep = [];
|
||||||
|
const drain = [];
|
||||||
|
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.
|
||||||
|
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
|
constructor(core, // PluginRuntime
|
||||||
cfg, // OpenClawConfig
|
cfg, // OpenClawConfig
|
||||||
client, identity, log, accounts = []) {
|
client, identity, log, accounts = []) {
|
||||||
@@ -44,38 +204,173 @@ export class FabricInbound {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
stop() {
|
stop() {
|
||||||
|
for (const t of this.channelSyncTimers)
|
||||||
|
clearInterval(t);
|
||||||
|
this.channelSyncTimers = [];
|
||||||
for (const s of this.sockets)
|
for (const s of this.sockets)
|
||||||
s.disconnect();
|
s.disconnect();
|
||||||
this.sockets = [];
|
this.sockets = [];
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Per-account metadata harvested during `start()` — used by
|
||||||
|
* PresenceSync to know where to push each agent's HF status.
|
||||||
|
*
|
||||||
|
* `fabricUserId` is filled from `session.user.id` after agent-login.
|
||||||
|
* `guildBaseUrl` is the FIRST guild the agent is connected to (multi-
|
||||||
|
* guild presence push is a future concern; for sim/prod-v1 each agent
|
||||||
|
* is in one guild).
|
||||||
|
*
|
||||||
|
* Returns ONLY agents that successfully connected — failed-login
|
||||||
|
* agents have no fabricUserId yet and are excluded.
|
||||||
|
*/
|
||||||
|
getPresenceAccounts() {
|
||||||
|
const out = [];
|
||||||
|
for (const entry of this.identity.list()) {
|
||||||
|
if (!entry.fabricUserId)
|
||||||
|
continue;
|
||||||
|
const presenceGuild = this.firstGuildByAgent.get(entry.agentId);
|
||||||
|
if (!presenceGuild)
|
||||||
|
continue;
|
||||||
|
out.push({
|
||||||
|
agentId: entry.agentId,
|
||||||
|
fabricUserId: entry.fabricUserId,
|
||||||
|
guildBaseUrl: presenceGuild.endpoint,
|
||||||
|
guildNodeId: presenceGuild.nodeId,
|
||||||
|
fabricApiKey: entry.fabricApiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Filled by connectAgent for each (agent, guild). Tracks ONLY the first
|
||||||
|
// guild per agent (used as the presence-push target). Stores both
|
||||||
|
// endpoint and nodeId — presence-sync needs both: endpoint to build
|
||||||
|
// the URL, nodeId to pick the matching guildAccessToken from a fresh
|
||||||
|
// agent-login response.
|
||||||
|
firstGuildByAgent = new Map();
|
||||||
async connectAgent(agentId, session) {
|
async connectAgent(agentId, session) {
|
||||||
const selfUserId = session.user.id;
|
const selfUserId = session.user.id;
|
||||||
|
// First-guild capture for presence-sync push target. session.guilds is
|
||||||
|
// already in priority order from Center; we take the first one with a
|
||||||
|
// valid endpoint and stop. Multi-guild presence is a future concern.
|
||||||
|
if (!this.firstGuildByAgent.has(agentId)) {
|
||||||
|
const firstGuild = session.guilds.find((g) => typeof g.endpoint === 'string' && g.endpoint.length > 0);
|
||||||
|
if (firstGuild)
|
||||||
|
this.firstGuildByAgent.set(agentId, { endpoint: firstGuild.endpoint, nodeId: firstGuild.nodeId });
|
||||||
|
}
|
||||||
for (const g of session.guilds) {
|
for (const g of session.guilds) {
|
||||||
const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
|
const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
|
||||||
if (!tok)
|
if (!tok)
|
||||||
continue;
|
continue;
|
||||||
|
// Use the *callback* form of `auth` so socket.io re-evaluates the JWT
|
||||||
|
// on every (re)connect. The single-shot `auth: { token: tok }` shape
|
||||||
|
// captured the token in closure: after socket.io's silent auto-reconnect
|
||||||
|
// the backend got the same JWT that expired ~15 min into the session
|
||||||
|
// (guildAccessToken TTL = 900s) and silently rejected the handshake at
|
||||||
|
// the application layer. The client's `connect` event still fired (TCP
|
||||||
|
// succeeded), so the plugin happily ran the channel-resync, emitted
|
||||||
|
// `join_channel` into the void, and logged "joined N channel(s)" while
|
||||||
|
// the backend was actually broadcasting message.created to a room with
|
||||||
|
// zero subscribers. End user symptom: DMs to agents silently dropped.
|
||||||
const socket = io(`${g.endpoint}/realtime`, {
|
const socket = io(`${g.endpoint}/realtime`, {
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
auth: { token: tok },
|
auth: (cb) => {
|
||||||
|
// Best-effort fresh token; on transient failure fall back to the
|
||||||
|
// last known good one. tokenCache also keeps HTTP calls (attachment
|
||||||
|
// download / reply post) from 401'ing in the same window.
|
||||||
|
this.freshGuildToken(agentId, g.nodeId, session)
|
||||||
|
.then((fresh) => cb({ token: fresh ?? tok }))
|
||||||
|
.catch(() => cb({ token: tok }));
|
||||||
|
},
|
||||||
autoConnect: false,
|
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 {
|
try {
|
||||||
const res = await fetch(`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`, { headers: { authorization: `Bearer ${tok}` } });
|
freshTok = await this.freshGuildToken(agentId, g.nodeId, session);
|
||||||
const channels = res.ok ? (await res.json()) : [];
|
|
||||||
for (const c of channels)
|
|
||||||
socket.emit('join_channel', { channelId: c.id });
|
|
||||||
this.log.info(`fabric: agent ${agentId} joined ${channels.length} channel(s) on ${g.nodeId}`);
|
|
||||||
}
|
}
|
||||||
catch {
|
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) => {
|
socket.on('message.created', (m) => {
|
||||||
const channelId = m.channelId ?? '';
|
const channelId = m.channelId ?? '';
|
||||||
if (!channelId)
|
if (!channelId)
|
||||||
return;
|
return;
|
||||||
|
// Record xType into the channel-meta cache before self-author
|
||||||
|
// / dedup gates — channel type doesn't depend on who sent the
|
||||||
|
// message, and recording it on observer-only triage messages
|
||||||
|
// is still useful (the next consumer asking
|
||||||
|
// __fabric.getChannelType wants the answer regardless of
|
||||||
|
// whether THIS message was delivered to an agent).
|
||||||
|
recordChannelType(channelId, m.xType);
|
||||||
if (m.authorUserId && m.authorUserId === selfUserId)
|
if (m.authorUserId && m.authorUserId === selfUserId)
|
||||||
return;
|
return;
|
||||||
const key = `${agentId}:${m.messageId}`;
|
const key = `${agentId}:${m.messageId}`;
|
||||||
@@ -84,7 +379,31 @@ export class FabricInbound {
|
|||||||
this.seen.add(key);
|
this.seen.add(key);
|
||||||
if (this.seen.size > 5000)
|
if (this.seen.size > 5000)
|
||||||
this.seen.clear();
|
this.seen.clear();
|
||||||
void this.dispatch(agentId, g, channelId, m, session);
|
// 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();
|
socket.connect();
|
||||||
this.sockets.push(socket);
|
this.sockets.push(socket);
|
||||||
@@ -133,29 +452,27 @@ export class FabricInbound {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
async dispatch(agentId, guild, channelId, m, session) {
|
async dispatch(agentId, guild, channelId, m, session) {
|
||||||
// wakeup === false -> drop (Fabric already decided this agent is silent)
|
|
||||||
if (m.wakeup !== true) {
|
|
||||||
this.log.info(`fabric: drop (no wakeup) agent=${agentId} channel=${channelId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.log.info(`fabric: dispatch agent=${agentId} channel=${channelId}`);
|
|
||||||
const core = this.core;
|
const core = this.core;
|
||||||
const cfg = this.cfg;
|
const cfg = this.cfg;
|
||||||
try {
|
try {
|
||||||
|
// Route by xType. DM channels need peer.kind='direct' so openclaw
|
||||||
|
// treats them as 1:1 (sessionKey 'agent:<id>:fabric:direct:<chan>'
|
||||||
|
// and ctx.ChatType='direct') rather than as a multi-party group.
|
||||||
|
// Without this, the agent's user-prompt metadata says
|
||||||
|
// 'is_group_chat: true' on a DM and downstream prompt logic
|
||||||
|
// (commands-handlers `isDirectMessage` checks ChatType==='direct')
|
||||||
|
// misclassifies the turn.
|
||||||
|
const { peerKind, chatType } = fabricPeerRoutingForXType(m.xType);
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg: this.cfg,
|
cfg: this.cfg,
|
||||||
channel: 'fabric',
|
channel: 'fabric',
|
||||||
accountId: agentId,
|
accountId: agentId,
|
||||||
peer: { kind: 'group', id: channelId },
|
peer: { kind: peerKind, id: channelId },
|
||||||
});
|
});
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
const gt = session.guildAccessTokens.find((t) => t.guildNodeId === guild.nodeId)?.token;
|
const baseCtx = {
|
||||||
// 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({
|
|
||||||
Body: m.content,
|
Body: m.content,
|
||||||
BodyForAgent: m.content,
|
BodyForAgent: m.content,
|
||||||
RawBody: m.content,
|
RawBody: m.content,
|
||||||
@@ -164,7 +481,7 @@ export class FabricInbound {
|
|||||||
To: `fabric:${channelId}`,
|
To: `fabric:${channelId}`,
|
||||||
SessionKey: route.sessionKey,
|
SessionKey: route.sessionKey,
|
||||||
AccountId: route.accountId ?? agentId,
|
AccountId: route.accountId ?? agentId,
|
||||||
ChatType: 'group',
|
ChatType: chatType,
|
||||||
ConversationLabel: `fabric:${guild.nodeId}`,
|
ConversationLabel: `fabric:${guild.nodeId}`,
|
||||||
SenderId: m.authorUserId ?? 'fabric',
|
SenderId: m.authorUserId ?? 'fabric',
|
||||||
Provider: 'fabric',
|
Provider: 'fabric',
|
||||||
@@ -173,14 +490,57 @@ export class FabricInbound {
|
|||||||
Timestamp: m.createdAt ? Date.parse(m.createdAt) : Date.now(),
|
Timestamp: m.createdAt ? Date.parse(m.createdAt) : Date.now(),
|
||||||
OriginatingChannel: 'fabric',
|
OriginatingChannel: 'fabric',
|
||||||
OriginatingTo: `fabric:${channelId}`,
|
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) => 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
|
...(media.paths.length
|
||||||
? {
|
? {
|
||||||
MediaPaths: media.paths,
|
MediaPaths: media.paths,
|
||||||
MediaTypes: media.types,
|
MediaTypes: media.types,
|
||||||
MediaUrls: media.urls,
|
|
||||||
MediaPath: media.paths[0],
|
MediaPath: media.paths[0],
|
||||||
MediaType: media.types[0],
|
MediaType: media.types[0],
|
||||||
MediaUrl: media.urls[0],
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
@@ -197,18 +557,46 @@ export class FabricInbound {
|
|||||||
this.log.info(`fabric: deliver agent=${agentId} channel=${channelId} len=${text.length}`);
|
this.log.info(`fabric: deliver agent=${agentId} channel=${channelId} len=${text.length}`);
|
||||||
if (!text || !gt)
|
if (!text || !gt)
|
||||||
return;
|
return;
|
||||||
await this.client.postMessage(guild.endpoint, gt, channelId, text, session.user.id);
|
// Buffer segments; the merged message is posted right after
|
||||||
this.log.info(`fabric: posted reply agent=${agentId} channel=${channelId}`);
|
// 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)}`),
|
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)}`),
|
onDispatchError: (err, info) => this.log.warn(`fabric: ${info.kind} dispatch failed agent=${agentId}: ${String(err)}`),
|
||||||
// Fabric has no length limit: deliver the whole reply as ONE message.
|
// - disableBlockStreaming: Fabric has no length limit, deliver the
|
||||||
replyOptions: { disableBlockStreaming: true },
|
// 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}`);
|
this.log.info(`fabric: dispatch returned agent=${agentId} channel=${channelId}`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
this.log.warn(`fabric: dispatch 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
139
dist/fabric/src/presence-sync.js
vendored
Normal file
139
dist/fabric/src/presence-sync.js
vendored
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// Guild access JWTs expire every 900s. Refresh ~2 min early to stay
|
||||||
|
// safely inside the window even if a tick runs late.
|
||||||
|
const TOKEN_TTL_MS = (15 - 2) * 60 * 1000;
|
||||||
|
export class PresenceSync {
|
||||||
|
logger;
|
||||||
|
client;
|
||||||
|
timer = null;
|
||||||
|
lastStatus = new Map(); // by agentId
|
||||||
|
accounts = new Map();
|
||||||
|
tokenCache = new Map(); // by agentId
|
||||||
|
// Mutex flag: a tick iterates accounts serially with `await` on each
|
||||||
|
// agent-login + PUT round-trip, so a single tick can easily run 20+s
|
||||||
|
// when there are many accounts. setInterval(intervalMs) does NOT wait
|
||||||
|
// for the previous tick to finish — without this guard the next tick
|
||||||
|
// fires on top of a still-running one and two parallel iterations
|
||||||
|
// PUT the same agentId within milliseconds. That tipped the backend's
|
||||||
|
// first-time-insert race (separate fix in Fabric.Backend.Guild) into
|
||||||
|
// 500s on prod. Guarded ticks just skip a beat instead.
|
||||||
|
inflight = false;
|
||||||
|
constructor(logger, client) {
|
||||||
|
this.logger = logger;
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
setAccounts(accounts) {
|
||||||
|
this.accounts.clear();
|
||||||
|
for (const a of accounts)
|
||||||
|
this.accounts.set(a.agentId, a);
|
||||||
|
}
|
||||||
|
start(intervalMs = 30_000) {
|
||||||
|
if (this.timer)
|
||||||
|
return;
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.tick().catch((err) => this.logger.warn(`fabric: presence-sync error: ${String(err)}`));
|
||||||
|
}, intervalMs);
|
||||||
|
// run once immediately so initial state lands fast
|
||||||
|
void this.tick();
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Fetch a fresh guildAccessToken for `acct`, caching it under the
|
||||||
|
* agentId until just before its JWT expiry. Returns null on login
|
||||||
|
* failure or if the session has no matching guild — caller logs +
|
||||||
|
* skips the PUT.
|
||||||
|
*/
|
||||||
|
async ensureGuildToken(acct) {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = this.tokenCache.get(acct.agentId);
|
||||||
|
if (cached && cached.expiresAt > now)
|
||||||
|
return cached.token;
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await this.client.agentLogin(acct.fabricApiKey);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.logger.warn(`fabric: presence-sync agent-login failed for ${acct.agentId}: ${String(err)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry = session.guildAccessTokens.find((g) => g.guildNodeId === acct.guildNodeId);
|
||||||
|
if (!entry?.token) {
|
||||||
|
this.logger.warn(`fabric: presence-sync no guild token for ${acct.agentId} guild=${acct.guildNodeId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.tokenCache.set(acct.agentId, { token: entry.token, expiresAt: now + TOKEN_TTL_MS });
|
||||||
|
return entry.token;
|
||||||
|
}
|
||||||
|
async tick() {
|
||||||
|
// Mutex: see the `inflight` field declaration for the why. Drop
|
||||||
|
// overlapping ticks rather than letting them run concurrently —
|
||||||
|
// status is gated by `lastStatus !== bridge.get`, so skipping a
|
||||||
|
// beat costs nothing the next beat won't catch.
|
||||||
|
if (this.inflight)
|
||||||
|
return;
|
||||||
|
this.inflight = true;
|
||||||
|
try {
|
||||||
|
await this.tickInner();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
this.inflight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async tickInner() {
|
||||||
|
const bridge = globalThis['__hfAgentStatus'];
|
||||||
|
if (!bridge || typeof bridge.get !== 'function')
|
||||||
|
return; // HF plugin not loaded — skip
|
||||||
|
for (const [agentId, acct] of this.accounts) {
|
||||||
|
let status;
|
||||||
|
try {
|
||||||
|
status = await bridge.get(agentId);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!status)
|
||||||
|
continue;
|
||||||
|
if (this.lastStatus.get(agentId) === status)
|
||||||
|
continue; // no change → no PUT
|
||||||
|
const guildToken = await this.ensureGuildToken(acct);
|
||||||
|
if (!guildToken)
|
||||||
|
continue;
|
||||||
|
try {
|
||||||
|
// Endpoint: PUT /api/agents/:userId/presence. ApiKeyGuard (global
|
||||||
|
// APP_GUARD) requires `Authorization: Bearer <guildAccessToken>`
|
||||||
|
// — NOT the agent's raw fabricApiKey. Pre-v1: this loop sent
|
||||||
|
// x-api-key and got 401 "missing bearer token" forever. The /api
|
||||||
|
// prefix is required because the guild backend sets a global
|
||||||
|
// 'api' prefix in main.ts setGlobalPrefix('api').
|
||||||
|
const url = `${acct.guildBaseUrl.replace(/\/$/, '')}/api/agents/${encodeURIComponent(acct.fabricUserId)}/presence`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
authorization: `Bearer ${guildToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status, source: 'hf-plugin' }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
this.lastStatus.set(agentId, status);
|
||||||
|
this.logger.info(`fabric: presence-sync ${agentId} → ${status}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 401 here usually means the cached token went stale unexpectedly
|
||||||
|
// (server-side rotation or clock skew) — drop the cache so the
|
||||||
|
// next tick re-logs-in.
|
||||||
|
if (res.status === 401)
|
||||||
|
this.tokenCache.delete(agentId);
|
||||||
|
this.logger.warn(`fabric: presence-sync PUT ${agentId} failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.logger.warn(`fabric: presence-sync PUT ${agentId} threw: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
411
dist/fabric/src/tools.js
vendored
411
dist/fabric/src/tools.js
vendored
@@ -9,7 +9,9 @@ export function registerFabricTools(api, client, identity) {
|
|||||||
const ctxGuild = async (agentId, guildNodeId) => {
|
const ctxGuild = async (agentId, guildNodeId) => {
|
||||||
const entry = identity.findByAgentId(agentId);
|
const entry = identity.findByAgentId(agentId);
|
||||||
if (!entry)
|
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 session = await client.agentLogin(entry.fabricApiKey);
|
||||||
const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
|
const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
|
||||||
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
||||||
@@ -17,35 +19,15 @@ export function registerFabricTools(api, client, identity) {
|
|||||||
throw new Error(`agent not a member of guild ${guildNodeId}`);
|
throw new Error(`agent not a member of guild ${guildNodeId}`);
|
||||||
return { session, guild, token };
|
return { session, guild, token };
|
||||||
};
|
};
|
||||||
// fabric-register: bind this agent to a Fabric API key.
|
// NOTE: binding an agent's Fabric API key is intentionally NOT a tool.
|
||||||
api.registerTool((ctx) => ({
|
// It's a one-time step done out-of-band via the installed script
|
||||||
name: 'fabric-register',
|
// ~/.openclaw/bin/fabric-register --api-key <fak_…> (AGENT_ID or --agent-id)
|
||||||
description: "Register this agent's Fabric API key (minted via Center CLI `user apikey`).",
|
// or via static config (channels.fabric.accounts.<agentId>).
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: false,
|
|
||||||
required: ['fabricApiKey'],
|
|
||||||
properties: {
|
|
||||||
fabricApiKey: { type: 'string', description: 'Fabric Center API key (fak_…)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute: 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 };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
const makeCreate = (kind) => api.registerTool((ctx) => ({
|
const makeCreate = (kind) => api.registerTool((ctx) => ({
|
||||||
name: `create-${kind}-channel`,
|
name: `create-${kind}-channel`,
|
||||||
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`,
|
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` +
|
||||||
|
'Optionally pass `purpose` to describe what this channel is for — ' +
|
||||||
|
'agents browse channels by purpose via fabric-channel-list.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -57,9 +39,16 @@ export function registerFabricTools(api, client, identity) {
|
|||||||
memberUserIds: { type: 'array', items: { type: 'string' } },
|
memberUserIds: { type: 'array', items: { type: 'string' } },
|
||||||
onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' },
|
onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' },
|
||||||
listeners: { type: 'array', items: { type: 'string' } },
|
listeners: { type: 'array', items: { type: 'string' } },
|
||||||
|
purpose: {
|
||||||
|
type: 'string',
|
||||||
|
description: "Free-form description of what this channel is for. Optional but " +
|
||||||
|
'strongly recommended so other agents can find this channel by ' +
|
||||||
|
'intent (via fabric-channel-list). Can be edited later with ' +
|
||||||
|
'fabric-channel-set-purpose.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
execute: async (p) => {
|
execute: async (_id, p) => {
|
||||||
const agentId = ctx.agentId;
|
const agentId = ctx.agentId;
|
||||||
if (!agentId)
|
if (!agentId)
|
||||||
return { ok: false, error: 'no agent context' };
|
return { ok: false, error: 'no agent context' };
|
||||||
@@ -70,6 +59,7 @@ export function registerFabricTools(api, client, identity) {
|
|||||||
xType: X_BY_KIND[kind],
|
xType: X_BY_KIND[kind],
|
||||||
isPublic: p.isPublic ?? false,
|
isPublic: p.isPublic ?? false,
|
||||||
memberUserIds: p.memberUserIds ?? [],
|
memberUserIds: p.memberUserIds ?? [],
|
||||||
|
...(p.purpose !== undefined ? { purpose: p.purpose } : {}),
|
||||||
});
|
});
|
||||||
return { ok: true, channelId: ch.id };
|
return { ok: true, channelId: ch.id };
|
||||||
},
|
},
|
||||||
@@ -94,7 +84,7 @@ export function registerFabricTools(api, client, identity) {
|
|||||||
callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' },
|
callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
execute: async (p) => {
|
execute: async (_id, p) => {
|
||||||
const agentId = ctx.agentId;
|
const agentId = ctx.agentId;
|
||||||
if (!agentId)
|
if (!agentId)
|
||||||
return { ok: false, error: 'no agent context' };
|
return { ok: false, error: 'no agent context' };
|
||||||
@@ -109,4 +99,365 @@ export function registerFabricTools(api, client, identity) {
|
|||||||
return { ok: true, closed: true };
|
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 (_id, 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 (_id, 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)}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 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) => ({
|
||||||
|
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 (_id, p) => {
|
||||||
|
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));
|
||||||
|
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) => ({
|
||||||
|
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 (_id, p) => {
|
||||||
|
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,
|
||||||
|
purpose: c.purpose ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// fabric-guild-list: enumerate guilds the calling agent belongs to.
|
||||||
|
// Each row carries `purpose` — free-form description of what the
|
||||||
|
// guild is for (admin-set). Use this as the first step when a
|
||||||
|
// workflow says "find the right guild for X" — pick by purpose,
|
||||||
|
// then fabric-channel-list to find the right channel inside it.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
api.registerTool((ctx) => ({
|
||||||
|
name: 'fabric-guild-list',
|
||||||
|
description: 'List guilds the calling agent is a member of. Returns ' +
|
||||||
|
'{nodeId, name, purpose, status} per row. ' +
|
||||||
|
"`purpose` is a free-form description of what each guild is for — " +
|
||||||
|
'pick the guild whose purpose matches your intent. Use this tool ' +
|
||||||
|
'BEFORE fabric-channel-list when a workflow asks you to pick the ' +
|
||||||
|
'right guild by intent (e.g. "find a guild whose purpose mentions ' +
|
||||||
|
'debate broadcasts" → then list its announce-type channels).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
nameFilter: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'optional case-insensitive substring match on guild name',
|
||||||
|
},
|
||||||
|
purposeFilter: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'optional case-insensitive substring match on guild purpose ' +
|
||||||
|
'(e.g. "debate", "announcements")',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (_id, p) => {
|
||||||
|
const agentId = ctx.agentId;
|
||||||
|
if (!agentId)
|
||||||
|
return { ok: false, error: 'no agent context' };
|
||||||
|
const entry = identity.findByAgentId(agentId);
|
||||||
|
if (!entry)
|
||||||
|
return { ok: false, error: `agent ${agentId} not registered` };
|
||||||
|
const session = await client.agentLogin(entry.fabricApiKey);
|
||||||
|
const nameNeedle = (p.nameFilter ?? '').toLowerCase();
|
||||||
|
const purposeNeedle = (p.purposeFilter ?? '').toLowerCase();
|
||||||
|
const guilds = session.guilds.filter((g) => {
|
||||||
|
if (nameNeedle && !g.name.toLowerCase().includes(nameNeedle))
|
||||||
|
return false;
|
||||||
|
if (purposeNeedle) {
|
||||||
|
const purp = (g.purpose ?? '').toLowerCase();
|
||||||
|
if (!purp.includes(purposeNeedle))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
count: guilds.length,
|
||||||
|
guilds: guilds.map((g) => ({
|
||||||
|
nodeId: g.nodeId,
|
||||||
|
name: g.name,
|
||||||
|
status: g.status,
|
||||||
|
purpose: g.purpose ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// fabric-channel-set-purpose: set/update a channel's free-form
|
||||||
|
// purpose description. Caller must be a channel member (or the
|
||||||
|
// channel must be public). Use this to backfill purpose on existing
|
||||||
|
// channels, or to refine it after a channel's role evolves.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
api.registerTool((ctx) => ({
|
||||||
|
name: 'fabric-channel-set-purpose',
|
||||||
|
description: "Set or update a channel's free-form purpose description. " +
|
||||||
|
'Channel membership required (or the channel must be public). ' +
|
||||||
|
'Pass empty string to clear. Use this to make a channel ' +
|
||||||
|
'discoverable to other agents via fabric-channel-list.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['guildNodeId', 'channelId', 'purpose'],
|
||||||
|
properties: {
|
||||||
|
guildNodeId: { type: 'string' },
|
||||||
|
channelId: { type: 'string' },
|
||||||
|
purpose: {
|
||||||
|
type: 'string',
|
||||||
|
description: "What this channel is for. Pass '' (empty string) to clear.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (_id, p) => {
|
||||||
|
const agentId = ctx.agentId;
|
||||||
|
if (!agentId)
|
||||||
|
return { ok: false, error: 'no agent context' };
|
||||||
|
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||||
|
const res = await client.setChannelPurpose(guild.endpoint, token, p.channelId, p.purpose);
|
||||||
|
return { ok: true, channel: res };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 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) => ({
|
||||||
|
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 (_id, p) => {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
83
index.ts
83
index.ts
@@ -6,16 +6,27 @@
|
|||||||
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
|
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
|
||||||
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core';
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core';
|
||||||
import { fabricChannelPlugin } from './src/channel.js';
|
import { fabricChannelPlugin } from './src/channel.js';
|
||||||
|
import { flushAllFabric } from './src/coalesce.js';
|
||||||
|
import { getChannelType, flushChannelMeta } from './src/channel-meta.js';
|
||||||
import { FabricInbound } from './src/inbound.js';
|
import { FabricInbound } from './src/inbound.js';
|
||||||
import { listEnabledFabricAccounts } from './src/accounts.js';
|
import { listEnabledFabricAccounts } from './src/accounts.js';
|
||||||
import { registerFabricTools } from './src/tools.js';
|
import { registerFabricTools } from './src/tools.js';
|
||||||
import { FabricClient } from './src/fabric-client.js';
|
import { FabricClient } from './src/fabric-client.js';
|
||||||
import { IdentityRegistry } from './src/identity.js';
|
import { IdentityRegistry } from './src/identity.js';
|
||||||
|
import { syncFabricCommands } from './src/command-sync.js';
|
||||||
|
import { PresenceSync } from './src/presence-sync.js';
|
||||||
|
import { SubDiscussionStore } from './src/sub-discussion-store.js';
|
||||||
|
import { registerSubDiscussionHook } from './src/sub-discussion-hook.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
|
||||||
let runtimeRef: unknown = null;
|
let runtimeRef: unknown = null;
|
||||||
let inbound: FabricInbound | null = null;
|
let inbound: FabricInbound | null = null;
|
||||||
|
let presence: PresenceSync | null = null;
|
||||||
|
// Periodic re-harvest of presence accounts so newly-connected agents
|
||||||
|
// (registered through tool-based identity flow AFTER initial start)
|
||||||
|
// get picked up. Cleared on gateway_stop.
|
||||||
|
let presenceRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
export { fabricChannelPlugin } from './src/channel.js';
|
export { fabricChannelPlugin } from './src/channel.js';
|
||||||
|
|
||||||
@@ -36,23 +47,63 @@ export default defineChannelPluginEntry({
|
|||||||
config?: unknown;
|
config?: unknown;
|
||||||
pluginConfig?: { identityFilePath?: string };
|
pluginConfig?: { identityFilePath?: string };
|
||||||
logger: { info: (m: string) => void; warn: (m: string) => void };
|
logger: { info: (m: string) => void; warn: (m: string) => void };
|
||||||
on: (ev: string, fn: () => void) => void;
|
on: (ev: string, fn: (...args: unknown[]) => unknown) => void;
|
||||||
registerTool: (d: unknown) => void;
|
registerTool: (d: unknown) => void;
|
||||||
};
|
};
|
||||||
const cfg = (api.config ?? {}) as { channels?: { fabric?: { centerApiBase?: string } } };
|
const cfg = (api.config ?? {}) as {
|
||||||
|
channels?: { fabric?: { centerApiBase?: string; commandsSyncKey?: string } };
|
||||||
|
};
|
||||||
const centerApiBase = cfg.channels?.fabric?.centerApiBase ?? 'http://localhost:7001/api';
|
const centerApiBase = cfg.channels?.fabric?.centerApiBase ?? 'http://localhost:7001/api';
|
||||||
const idFile =
|
const idFile =
|
||||||
api.pluginConfig?.identityFilePath ??
|
api.pluginConfig?.identityFilePath ??
|
||||||
path.join(os.homedir(), '.openclaw', 'fabric-identity.json');
|
path.join(os.homedir(), '.openclaw', 'fabric-identity.json');
|
||||||
|
const subDiscussionFile = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'.openclaw',
|
||||||
|
'fabric-sub-discussion.json',
|
||||||
|
);
|
||||||
|
|
||||||
// tools operate against a default Center; per-account keys come from config
|
// tools operate against a default Center; per-account keys come from config
|
||||||
const client = new FabricClient(centerApiBase);
|
const client = new FabricClient(centerApiBase);
|
||||||
const identity = new IdentityRegistry(idFile);
|
const identity = new IdentityRegistry(idFile);
|
||||||
|
const subDiscussion = new SubDiscussionStore(subDiscussionFile);
|
||||||
registerFabricTools(
|
registerFabricTools(
|
||||||
{ registerTool: (d) => api.registerTool(d), logger: api.logger },
|
{ registerTool: (d) => api.registerTool(d), logger: api.logger },
|
||||||
client,
|
client,
|
||||||
identity,
|
identity,
|
||||||
|
subDiscussion,
|
||||||
|
cfg,
|
||||||
);
|
);
|
||||||
|
// Per-(agent, channel) prompt injection for sub-discussion channels.
|
||||||
|
// Runs as a sibling to PrismFacet's before_prompt_build hook (and
|
||||||
|
// ClawPrompts' fabric-chat-injector); openclaw composes
|
||||||
|
// appendSystemContext from all registered handlers.
|
||||||
|
registerSubDiscussionHook(
|
||||||
|
{ on: api.on, logger: api.logger },
|
||||||
|
subDiscussion,
|
||||||
|
identity,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cross-plugin API: globalThis.__fabric
|
||||||
|
// Consumed by ClawPrompts' fabric-chat-injector to narrow its prompt
|
||||||
|
// injection to DM-typed channels only. The channel-meta cache is
|
||||||
|
// populated lazily from inbound (message.created carries xType) and
|
||||||
|
// persisted to ~/.openclaw/fabric-channel-meta.json — so even the
|
||||||
|
// very first DM after a fresh gateway start hits cache from the
|
||||||
|
// previous run rather than firing the injector on the wrong type.
|
||||||
|
//
|
||||||
|
// null return = channel never seen (cache cold). Callers MUST NOT
|
||||||
|
// fall back to "assume DM" — fail closed on unknown.
|
||||||
|
{
|
||||||
|
const _G = globalThis as Record<string, unknown>;
|
||||||
|
_G['__fabric'] = { getChannelType };
|
||||||
|
// Flush channel-meta cache when the gateway shuts down so
|
||||||
|
// recently-recorded xType entries don't get lost.
|
||||||
|
api.on('gateway_stop', () => {
|
||||||
|
try { flushChannelMeta(); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
api.logger.info('fabric: __fabric cross-plugin API installed (getChannelType)');
|
||||||
|
}
|
||||||
|
|
||||||
api.on('gateway_start', () => {
|
api.on('gateway_start', () => {
|
||||||
const _G = globalThis as Record<string, unknown>;
|
const _G = globalThis as Record<string, unknown>;
|
||||||
@@ -80,11 +131,37 @@ export default defineChannelPluginEntry({
|
|||||||
api.logger,
|
api.logger,
|
||||||
accounts,
|
accounts,
|
||||||
);
|
);
|
||||||
void inbound.start();
|
// start() resolves once all accounts have attempted login; per-
|
||||||
|
// agent failures are logged but don't reject. Once it resolves we
|
||||||
|
// can harvest the presence accounts (those that DID log in have
|
||||||
|
// their fabricUserId + first guild endpoint populated).
|
||||||
|
void inbound.start().then(() => {
|
||||||
|
if (!inbound) return;
|
||||||
|
presence = new PresenceSync(api.logger, client);
|
||||||
|
presence.setAccounts(inbound.getPresenceAccounts());
|
||||||
|
presence.start();
|
||||||
|
api.logger.info(`fabric: presence-sync started for ${inbound.getPresenceAccounts().length} account(s)`);
|
||||||
|
|
||||||
|
// Re-harvest every 5 min: catches agents added via tool-based
|
||||||
|
// identity provisioning after gateway_start (recruitment flow).
|
||||||
|
// setAccounts is idempotent — duplicates collapse on agentId.
|
||||||
|
presenceRefreshTimer = setInterval(() => {
|
||||||
|
if (inbound && presence) presence.setAccounts(inbound.getPresenceAccounts());
|
||||||
|
}, 5 * 60_000);
|
||||||
|
});
|
||||||
api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`);
|
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', () => {
|
api.on('gateway_stop', () => {
|
||||||
|
void flushAllFabric();
|
||||||
|
if (presenceRefreshTimer) { clearInterval(presenceRefreshTimer); presenceRefreshTimer = null; }
|
||||||
|
presence?.stop();
|
||||||
|
presence = null;
|
||||||
inbound?.stop();
|
inbound?.stop();
|
||||||
inbound = null;
|
inbound = null;
|
||||||
});
|
});
|
||||||
|
|||||||
27
install.mjs
27
install.mjs
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
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 { dirname, join, resolve } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
@@ -109,12 +109,30 @@ function build() {
|
|||||||
ok('compiled -> dist/fabric');
|
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) {
|
function clearInstall(base) {
|
||||||
const dest = join(base, 'plugins', PLUGIN_ID);
|
const dest = join(base, 'plugins', PLUGIN_ID);
|
||||||
if (existsSync(dest)) {
|
if (existsSync(dest)) {
|
||||||
rmSync(dest, { recursive: true, force: true });
|
rmSync(dest, { recursive: true, force: true });
|
||||||
ok(`removed ${dest}`);
|
ok(`removed ${dest}`);
|
||||||
}
|
}
|
||||||
|
const bin = binTarget(base);
|
||||||
|
if (existsSync(bin)) {
|
||||||
|
rmSync(bin, { force: true });
|
||||||
|
ok(`removed ${bin}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function cleanupConfig(base) {
|
function cleanupConfig(base) {
|
||||||
const dest = join(base, 'plugins', PLUGIN_ID);
|
const dest = join(base, 'plugins', PLUGIN_ID);
|
||||||
@@ -149,6 +167,7 @@ function install() {
|
|||||||
ok(`plugin files -> ${dest}`);
|
ok(`plugin files -> ${dest}`);
|
||||||
exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose });
|
exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose });
|
||||||
ok('runtime deps installed');
|
ok('runtime deps installed');
|
||||||
|
installBinScript(base);
|
||||||
return { base, dest };
|
return { base, dest };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,8 +219,10 @@ function main() {
|
|||||||
console.log('');
|
console.log('');
|
||||||
log('Install complete. Next:', 'blue');
|
log('Install complete. Next:', 'blue');
|
||||||
log(' 1. Mint an agent key: (in Center) node dist/cli.js user apikey --email <agent-email>', 'cyan');
|
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(' 2. Bind it to an agent (one-time), either:', 'cyan');
|
||||||
log(' 3. As an agent, call the fabric-register tool with that key', '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('');
|
console.log('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`\nInstall failed: ${e.message}`, 'red');
|
log(`\nInstall failed: ${e.message}`, 'red');
|
||||||
|
|||||||
@@ -14,7 +14,17 @@
|
|||||||
"create-work-channel",
|
"create-work-channel",
|
||||||
"create-report-channel",
|
"create-report-channel",
|
||||||
"create-discussion-channel",
|
"create-discussion-channel",
|
||||||
"discussion-complete"
|
"create-sub-discussion",
|
||||||
|
"discussion-complete",
|
||||||
|
"close-sub-discussion",
|
||||||
|
"fabric-canvas",
|
||||||
|
"fabric-channel",
|
||||||
|
"fabric-send-message",
|
||||||
|
"fabric-send-sys-msg",
|
||||||
|
"fabric-channel-list",
|
||||||
|
"fabric-message-history",
|
||||||
|
"fabric-guild-list",
|
||||||
|
"fabric-channel-set-purpose"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
@@ -39,6 +49,15 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Fabric Center API base, e.g. http://localhost:7001/api"
|
"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" },
|
"dmSecurity": { "type": "string" },
|
||||||
"dmPolicy": { "type": "string" },
|
"dmPolicy": { "type": "string" },
|
||||||
"enabled": { "type": "boolean" },
|
"enabled": { "type": "boolean" },
|
||||||
@@ -59,10 +78,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"required": ["commandsSyncKey"]
|
||||||
},
|
},
|
||||||
"uiHints": {
|
"uiHints": {
|
||||||
"centerApiBase": { "label": "Center API base" }
|
"centerApiBase": { "label": "Center API base" },
|
||||||
|
"commandsSyncKey": { "label": "Commands sync key" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ export type FabricAccountConfig = {
|
|||||||
|
|
||||||
export type FabricChannelConfig = {
|
export type FabricChannelConfig = {
|
||||||
centerApiBase?: string;
|
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>;
|
accounts?: Record<string, FabricAccountConfig>;
|
||||||
defaultAccount?: string;
|
defaultAccount?: string;
|
||||||
} & FabricAccountConfig;
|
} & FabricAccountConfig;
|
||||||
@@ -35,6 +44,19 @@ function section(cfg: Cfg): FabricChannelConfig {
|
|||||||
return cfg.channels?.fabric ?? {};
|
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[] {
|
export function listFabricAccountIds(cfg: Cfg): string[] {
|
||||||
const accts = section(cfg).accounts ?? {};
|
const accts = section(cfg).accounts ?? {};
|
||||||
const ids = Object.keys(accts);
|
const ids = Object.keys(accts);
|
||||||
|
|||||||
108
src/channel-meta.ts
Normal file
108
src/channel-meta.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Channel-meta cache. Records (channelId → xType) for every fabric
|
||||||
|
* channel the gateway has seen at least one inbound message in.
|
||||||
|
*
|
||||||
|
* Populated lazily from inbound (`recordChannelType` is called for
|
||||||
|
* every `message.created` event with non-empty `xType`). Persisted to
|
||||||
|
* `~/.openclaw/fabric-channel-meta.json` so the cache survives
|
||||||
|
* gateway restarts (so the very first DM after restart still gets the
|
||||||
|
* right xType without waiting for a fresh inbound).
|
||||||
|
*
|
||||||
|
* Exposed cross-plugin via `globalThis.__fabric.getChannelType`. Used
|
||||||
|
* by ClawPrompts' fabric-chat-injector to narrow its prompt injection
|
||||||
|
* to xType==='dm' only.
|
||||||
|
*
|
||||||
|
* Failure mode: lookup misses (channel never seen / inbound dropped
|
||||||
|
* xType) return null. Callers MUST treat null as "unknown" — DO NOT
|
||||||
|
* fall back to "assume DM" or you re-introduce the false-positive on
|
||||||
|
* group channels.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const CACHE_FILE = join(homedir(), '.openclaw', 'fabric-channel-meta.json');
|
||||||
|
|
||||||
|
interface ChannelMetaFile {
|
||||||
|
// channelId → xType ('dm' | 'triage' | 'group' | etc.)
|
||||||
|
channels: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let memory = new Map<string, string>();
|
||||||
|
let loaded = false;
|
||||||
|
let dirty = false;
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function load(): void {
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
|
try {
|
||||||
|
if (!existsSync(CACHE_FILE)) return;
|
||||||
|
const raw = readFileSync(CACHE_FILE, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as ChannelMetaFile;
|
||||||
|
for (const [k, v] of Object.entries(parsed.channels ?? {})) {
|
||||||
|
if (typeof k === 'string' && typeof v === 'string') memory.set(k, v);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — start with empty cache on corruption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush(): void {
|
||||||
|
if (flushTimer) return;
|
||||||
|
// Debounce writes — many inbound messages may arrive in a burst.
|
||||||
|
// 250ms coalesces them; on gateway_stop the channel plugin can force
|
||||||
|
// a synchronous flush via flushChannelMeta().
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null;
|
||||||
|
if (!dirty) return;
|
||||||
|
dirty = false;
|
||||||
|
flushSync();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushSync(): void {
|
||||||
|
try {
|
||||||
|
const dir = dirname(CACHE_FILE);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
const out: ChannelMetaFile = { channels: Object.fromEntries(memory) };
|
||||||
|
const tmp = CACHE_FILE + '.tmp';
|
||||||
|
writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
||||||
|
renameSync(tmp, CACHE_FILE);
|
||||||
|
} catch {
|
||||||
|
// swallow — cache is an optimization; loss-on-write is recoverable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by inbound on every message.created. xType empty → no-op. */
|
||||||
|
export function recordChannelType(channelId: string, xType: string | undefined): void {
|
||||||
|
if (!channelId || !xType) return;
|
||||||
|
load();
|
||||||
|
const existing = memory.get(channelId);
|
||||||
|
if (existing === xType) return;
|
||||||
|
memory.set(channelId, xType);
|
||||||
|
dirty = true;
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cross-plugin lookup. null when channel never seen / unknown. */
|
||||||
|
export function getChannelType(channelId: string): string | null {
|
||||||
|
if (!channelId) return null;
|
||||||
|
load();
|
||||||
|
return memory.get(channelId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force-flush — called on plugin shutdown to make sure recently
|
||||||
|
* recorded entries hit disk before the gateway dies. */
|
||||||
|
export function flushChannelMeta(): void {
|
||||||
|
if (flushTimer) {
|
||||||
|
clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
}
|
||||||
|
if (dirty) {
|
||||||
|
dirty = false;
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHANNEL_META_PATH = CACHE_FILE;
|
||||||
@@ -21,6 +21,39 @@ import {
|
|||||||
resolveDefaultFabricAccountId,
|
resolveDefaultFabricAccountId,
|
||||||
type ResolvedFabricAccount,
|
type ResolvedFabricAccount,
|
||||||
} from './accounts.js';
|
} from './accounts.js';
|
||||||
|
import { getChannelType } from './channel-meta.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a Fabric channel xType to an openclaw routing peer.kind / ChatType.
|
||||||
|
*
|
||||||
|
* Fabric distinguishes channels by xType ('dm' | 'triage' | 'group' |
|
||||||
|
* 'broadcast' | 'announce' | ...). Openclaw's session router only knows
|
||||||
|
* 'direct' | 'group' | 'channel'. We collapse:
|
||||||
|
* - 'dm' → 'direct' (1:1 conversation; agent always speaks)
|
||||||
|
* - rest → 'group' (multi-party; turn-engine gates speech)
|
||||||
|
*
|
||||||
|
* Sessions are keyed by peer.kind, so inbound and outbound MUST agree —
|
||||||
|
* otherwise the agent's outbound message lands in a different session
|
||||||
|
* than the inbound that triggered it and conversation state splits.
|
||||||
|
*
|
||||||
|
* Outbound has no live xType (the agent target is just a channelId), so
|
||||||
|
* it consults the channel-meta cache populated by inbound. Cache miss
|
||||||
|
* (channel never observed) falls back to 'group' — same as the pre-fix
|
||||||
|
* behavior, no regression on cold cache. The proactive-DM-first-message
|
||||||
|
* edge case (agent DMs a channel before any inbound) still lands as
|
||||||
|
* 'group' on that one outbound; the next inbound + outbound pair will
|
||||||
|
* agree on 'direct'.
|
||||||
|
*/
|
||||||
|
export type FabricPeerRouting = { peerKind: 'direct' | 'group'; chatType: 'direct' | 'group' };
|
||||||
|
|
||||||
|
export function fabricPeerRoutingForXType(xType: string | null | undefined): FabricPeerRouting {
|
||||||
|
if (xType === 'dm') return { peerKind: 'direct', chatType: 'direct' };
|
||||||
|
return { peerKind: 'group', chatType: 'group' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fabricPeerRoutingForChannel(channelId: string): FabricPeerRouting {
|
||||||
|
return fabricPeerRoutingForXType(getChannelType(channelId));
|
||||||
|
}
|
||||||
|
|
||||||
type AnyCfg = { channels?: { fabric?: unknown }; [k: string]: unknown };
|
type AnyCfg = { channels?: { fabric?: unknown }; [k: string]: unknown };
|
||||||
|
|
||||||
@@ -45,13 +78,18 @@ export function looksLikeFabricTargetId(raw: string): boolean {
|
|||||||
export function resolveFabricOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
export function resolveFabricOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||||
const id = stripFabricTargetPrefix(params.target);
|
const id = stripFabricTargetPrefix(params.target);
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
|
// Consult the channel-meta cache populated by inbound — DM channels
|
||||||
|
// need peer.kind='direct' so the outbound session key matches the
|
||||||
|
// inbound one. Cache miss falls back to 'group' (the pre-fix default,
|
||||||
|
// no regression on cold cache).
|
||||||
|
const { peerKind, chatType } = fabricPeerRoutingForChannel(id);
|
||||||
return buildChannelOutboundSessionRoute({
|
return buildChannelOutboundSessionRoute({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
channel: 'fabric',
|
channel: 'fabric',
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
peer: { kind: 'group', id },
|
peer: { kind: peerKind, id },
|
||||||
chatType: 'group',
|
chatType,
|
||||||
from: `fabric:channel:${id}`,
|
from: `fabric:channel:${id}`,
|
||||||
to: `fabric:${id}`,
|
to: `fabric:${id}`,
|
||||||
});
|
});
|
||||||
@@ -115,6 +153,20 @@ export const fabricChannelPlugin = createChatChannelPlugin<ResolvedFabricAccount
|
|||||||
resolveAccount: (cfg, accountId) => resolveFabricAccount(cfg as never, accountId),
|
resolveAccount: (cfg, accountId) => resolveFabricAccount(cfg as never, accountId),
|
||||||
defaultAccountId: (cfg) => resolveDefaultFabricAccountId(cfg as never),
|
defaultAccountId: (cfg) => resolveDefaultFabricAccountId(cfg as never),
|
||||||
isConfigured: (account: ResolvedFabricAccount) => Boolean(account.fabricApiKey),
|
isConfigured: (account: ResolvedFabricAccount) => Boolean(account.fabricApiKey),
|
||||||
|
// openclaw's channelManager.getRuntimeSnapshot() — called every minute
|
||||||
|
// by the channel-health-monitor — defaults `configured: true` when the
|
||||||
|
// plugin doesn't expose describeAccount (see applyDescribedAccountFields
|
||||||
|
// in server-channels). Without this, fabric's synthetic 'default'
|
||||||
|
// account (returned by listFabricAccountIds when channels.fabric.accounts
|
||||||
|
// is empty — the prod shape) gets snapshot {enabled:true, configured:true,
|
||||||
|
// running:false} → isManagedAccount=true → not-running → restart loop
|
||||||
|
// every ~10 min, logging `[fabric:default] health-monitor: restarting`.
|
||||||
|
// Mirror isConfigured here so the snapshot truthfully reports false for
|
||||||
|
// any account without a fabricApiKey.
|
||||||
|
describeAccount: (account: ResolvedFabricAccount) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
configured: Boolean(account.fabricApiKey),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
// Minimal setup adapter: Fabric is configured directly under
|
// Minimal setup adapter: Fabric is configured directly under
|
||||||
// channels.fabric.* (no interactive wizard). applyAccountConfig is the
|
// channels.fabric.* (no interactive wizard). applyAccountConfig is the
|
||||||
@@ -159,7 +211,10 @@ export const fabricChannelPlugin = createChatChannelPlugin<ResolvedFabricAccount
|
|||||||
cfg?: unknown;
|
cfg?: unknown;
|
||||||
config?: unknown;
|
config?: unknown;
|
||||||
}) => {
|
}) => {
|
||||||
// openclaw passes config under cfg or config depending on path
|
// 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;
|
const cfg = (ctx.cfg ?? ctx.config ?? {}) as AnyCfg;
|
||||||
try {
|
try {
|
||||||
const r = await sendToFabric(cfg, ctx.accountId ?? null, ctx.to, ctx.text);
|
const r = await sendToFabric(cfg, ctx.accountId ?? null, ctx.to, ctx.text);
|
||||||
|
|||||||
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,15 @@ export type FabricSession = {
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
user: { id: string; email: string; name: string };
|
user: { id: string; email: string; name: string };
|
||||||
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: string }>;
|
guilds: Array<{
|
||||||
|
nodeId: string;
|
||||||
|
name: string;
|
||||||
|
endpoint: string;
|
||||||
|
status: string;
|
||||||
|
// free-form description of this guild's role; admin-set on Center.
|
||||||
|
// null when the admin hasn't filled it in yet.
|
||||||
|
purpose?: string | null;
|
||||||
|
}>;
|
||||||
guildAccessTokens: Array<{ guildNodeId: string; token: string }>;
|
guildAccessTokens: Array<{ guildNodeId: string; token: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +37,32 @@ export class FabricClient {
|
|||||||
return (await res.json()) as T;
|
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).
|
// Exchange an agent API key for a Fabric user session (+ guild tokens).
|
||||||
agentLogin(apiKey: string): Promise<FabricSession> {
|
agentLogin(apiKey: string): Promise<FabricSession> {
|
||||||
return this.post<FabricSession>(`${this.centerApiBase}/auth/agent/login`, { apiKey });
|
return this.post<FabricSession>(`${this.centerApiBase}/auth/agent/login`, { apiKey });
|
||||||
@@ -74,11 +108,30 @@ export class FabricClient {
|
|||||||
memberUserIds?: string[];
|
memberUserIds?: string[];
|
||||||
onDuty?: string;
|
onDuty?: string;
|
||||||
listeners?: string[];
|
listeners?: string[];
|
||||||
|
// free-form purpose; optional. Existing agents can also set/update
|
||||||
|
// it later via setChannelPurpose().
|
||||||
|
purpose?: string;
|
||||||
},
|
},
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
|
return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PATCH /api/channels/:id — backend currently only patches `purpose`.
|
||||||
|
// Caller must be a member of the channel (or any user if public).
|
||||||
|
setChannelPurpose(
|
||||||
|
guildEndpoint: string,
|
||||||
|
guildToken: string,
|
||||||
|
channelId: string,
|
||||||
|
purpose: string,
|
||||||
|
): Promise<{ id: string; name: string; xType: string; purpose: string | null }> {
|
||||||
|
return this.req(
|
||||||
|
'PATCH',
|
||||||
|
`${guildEndpoint}/api/channels/${channelId}`,
|
||||||
|
guildToken,
|
||||||
|
{ purpose },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
closeChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
|
closeChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
|
||||||
return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
|
return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
|
||||||
}
|
}
|
||||||
@@ -86,4 +139,166 @@ export class FabricClient {
|
|||||||
joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
|
joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
|
||||||
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
|
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;
|
||||||
|
purpose?: string | null;
|
||||||
|
}>> {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|||||||
539
src/inbound.ts
539
src/inbound.ts
@@ -5,6 +5,10 @@ import { io, type Socket } from 'socket.io-client';
|
|||||||
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
||||||
import type { FabricClient, FabricSession } from './fabric-client.js';
|
import type { FabricClient, FabricSession } from './fabric-client.js';
|
||||||
import type { IdentityRegistry } from './identity.js';
|
import type { IdentityRegistry } from './identity.js';
|
||||||
|
import { resolveCoalesce } from './accounts.js';
|
||||||
|
import { fabricPeerRoutingForXType } from './channel.js';
|
||||||
|
import { recordChannelType } from './channel-meta.js';
|
||||||
|
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
|
||||||
|
|
||||||
// COMPAT NOTE (openclaw v2026.5.7): the inbound path mirrors how bundled
|
// COMPAT NOTE (openclaw v2026.5.7): the inbound path mirrors how bundled
|
||||||
// channels (nextcloud-talk) drive the kernel:
|
// channels (nextcloud-talk) drive the kernel:
|
||||||
@@ -16,7 +20,16 @@ import type { IdentityRegistry } from './identity.js';
|
|||||||
type Core = {
|
type Core = {
|
||||||
channel: {
|
channel: {
|
||||||
routing: { resolveAgentRoute: (p: unknown) => { agentId: string; sessionKey: string; accountId?: string } };
|
routing: { resolveAgentRoute: (p: unknown) => { agentId: string; sessionKey: string; accountId?: string } };
|
||||||
session: { resolveStorePath: (store: unknown, o: { agentId: string }) => 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 };
|
reply: { finalizeInboundContext: (p: Record<string, unknown>) => unknown };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -33,11 +46,210 @@ type FabricMessage = {
|
|||||||
channelId?: string;
|
channelId?: string;
|
||||||
attachments?: FabricAttachment[];
|
attachments?: FabricAttachment[];
|
||||||
wakeup?: boolean;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Walk cfg.bindings for the entry that ties `agentId` to a fabric account.
|
||||||
|
// Returns the binding's match.accountId (the slot label routing keys on);
|
||||||
|
// returns undefined when the agent has no explicit fabric binding so the
|
||||||
|
// caller can fall back to agentId without changing pre-existing semantics
|
||||||
|
// for agents whose binding accountId == agent_id anyway.
|
||||||
|
function findFabricBindingAccountId(cfg: unknown, agentId: string): string | undefined {
|
||||||
|
const bindings = (cfg as { bindings?: Array<{
|
||||||
|
agentId?: string;
|
||||||
|
match?: { channel?: string; accountId?: string };
|
||||||
|
}> })?.bindings;
|
||||||
|
if (!Array.isArray(bindings)) return undefined;
|
||||||
|
for (const b of bindings) {
|
||||||
|
if (
|
||||||
|
b?.agentId === agentId &&
|
||||||
|
b?.match?.channel === 'fabric' &&
|
||||||
|
typeof b?.match?.accountId === 'string' &&
|
||||||
|
b.match.accountId.length > 0
|
||||||
|
) {
|
||||||
|
return b.match.accountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export class FabricInbound {
|
export class FabricInbound {
|
||||||
private sockets: Socket[] = [];
|
private sockets: Socket[] = [];
|
||||||
private seen = new Set<string>();
|
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(
|
constructor(
|
||||||
private readonly core: unknown, // PluginRuntime
|
private readonly core: unknown, // PluginRuntime
|
||||||
@@ -71,43 +283,217 @@ export class FabricInbound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
for (const t of this.channelSyncTimers) clearInterval(t);
|
||||||
|
this.channelSyncTimers = [];
|
||||||
for (const s of this.sockets) s.disconnect();
|
for (const s of this.sockets) s.disconnect();
|
||||||
this.sockets = [];
|
this.sockets = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-account metadata harvested during `start()` — used by
|
||||||
|
* PresenceSync to know where to push each agent's HF status.
|
||||||
|
*
|
||||||
|
* `fabricUserId` is filled from `session.user.id` after agent-login.
|
||||||
|
* `guildBaseUrl` is the FIRST guild the agent is connected to (multi-
|
||||||
|
* guild presence push is a future concern; for sim/prod-v1 each agent
|
||||||
|
* is in one guild).
|
||||||
|
*
|
||||||
|
* Returns ONLY agents that successfully connected — failed-login
|
||||||
|
* agents have no fabricUserId yet and are excluded.
|
||||||
|
*/
|
||||||
|
getPresenceAccounts(): Array<{
|
||||||
|
agentId: string;
|
||||||
|
fabricUserId: string;
|
||||||
|
guildBaseUrl: string;
|
||||||
|
guildNodeId: string;
|
||||||
|
fabricApiKey: string;
|
||||||
|
}> {
|
||||||
|
const out: Array<{
|
||||||
|
agentId: string;
|
||||||
|
fabricUserId: string;
|
||||||
|
guildBaseUrl: string;
|
||||||
|
guildNodeId: string;
|
||||||
|
fabricApiKey: string;
|
||||||
|
}> = [];
|
||||||
|
for (const entry of this.identity.list()) {
|
||||||
|
if (!entry.fabricUserId) continue;
|
||||||
|
const presenceGuild = this.firstGuildByAgent.get(entry.agentId);
|
||||||
|
if (!presenceGuild) continue;
|
||||||
|
out.push({
|
||||||
|
agentId: entry.agentId,
|
||||||
|
fabricUserId: entry.fabricUserId,
|
||||||
|
guildBaseUrl: presenceGuild.endpoint,
|
||||||
|
guildNodeId: presenceGuild.nodeId,
|
||||||
|
fabricApiKey: entry.fabricApiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filled by connectAgent for each (agent, guild). Tracks ONLY the first
|
||||||
|
// guild per agent (used as the presence-push target). Stores both
|
||||||
|
// endpoint and nodeId — presence-sync needs both: endpoint to build
|
||||||
|
// the URL, nodeId to pick the matching guildAccessToken from a fresh
|
||||||
|
// agent-login response.
|
||||||
|
private firstGuildByAgent = new Map<string, { endpoint: string; nodeId: string }>();
|
||||||
|
|
||||||
private async connectAgent(agentId: string, session: FabricSession): Promise<void> {
|
private async connectAgent(agentId: string, session: FabricSession): Promise<void> {
|
||||||
const selfUserId = session.user.id;
|
const selfUserId = session.user.id;
|
||||||
|
// First-guild capture for presence-sync push target. session.guilds is
|
||||||
|
// already in priority order from Center; we take the first one with a
|
||||||
|
// valid endpoint and stop. Multi-guild presence is a future concern.
|
||||||
|
if (!this.firstGuildByAgent.has(agentId)) {
|
||||||
|
const firstGuild = session.guilds.find((g) => typeof g.endpoint === 'string' && g.endpoint.length > 0);
|
||||||
|
if (firstGuild) this.firstGuildByAgent.set(agentId, { endpoint: firstGuild.endpoint, nodeId: firstGuild.nodeId });
|
||||||
|
}
|
||||||
for (const g of session.guilds) {
|
for (const g of session.guilds) {
|
||||||
const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
|
const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
|
||||||
if (!tok) continue;
|
if (!tok) continue;
|
||||||
|
// Use the *callback* form of `auth` so socket.io re-evaluates the JWT
|
||||||
|
// on every (re)connect. The single-shot `auth: { token: tok }` shape
|
||||||
|
// captured the token in closure: after socket.io's silent auto-reconnect
|
||||||
|
// the backend got the same JWT that expired ~15 min into the session
|
||||||
|
// (guildAccessToken TTL = 900s) and silently rejected the handshake at
|
||||||
|
// the application layer. The client's `connect` event still fired (TCP
|
||||||
|
// succeeded), so the plugin happily ran the channel-resync, emitted
|
||||||
|
// `join_channel` into the void, and logged "joined N channel(s)" while
|
||||||
|
// the backend was actually broadcasting message.created to a room with
|
||||||
|
// zero subscribers. End user symptom: DMs to agents silently dropped.
|
||||||
const socket = io(`${g.endpoint}/realtime`, {
|
const socket = io(`${g.endpoint}/realtime`, {
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
auth: { token: tok },
|
auth: (cb) => {
|
||||||
|
// Best-effort fresh token; on transient failure fall back to the
|
||||||
|
// last known good one. tokenCache also keeps HTTP calls (attachment
|
||||||
|
// download / reply post) from 401'ing in the same window.
|
||||||
|
this.freshGuildToken(agentId, g.nodeId, session)
|
||||||
|
.then((fresh) => cb({ token: fresh ?? tok }))
|
||||||
|
.catch(() => cb({ token: tok }));
|
||||||
|
},
|
||||||
autoConnect: false,
|
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 {
|
||||||
|
freshTok = await this.freshGuildToken(agentId, g.nodeId, session);
|
||||||
|
} catch {
|
||||||
|
freshTok = tok;
|
||||||
|
}
|
||||||
|
const authTok = freshTok ?? tok;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`,
|
`${g.endpoint}/api/channels?guildId=${encodeURIComponent(g.nodeId)}`,
|
||||||
{ headers: { authorization: `Bearer ${tok}` } },
|
{ headers: { authorization: `Bearer ${authTok}` } },
|
||||||
);
|
);
|
||||||
const channels = res.ok ? ((await res.json()) as Array<{ id: string }>) : [];
|
if (!res.ok) return;
|
||||||
for (const c of channels) socket.emit('join_channel', { channelId: c.id });
|
const channels = (await res.json()) as Array<{ id: string }>;
|
||||||
this.log.info(`fabric: agent ${agentId} joined ${channels.length} channel(s) on ${g.nodeId}`);
|
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 {
|
} catch {
|
||||||
/* best effort */
|
/* 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: { 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) => {
|
socket.on('message.created', (m: FabricMessage) => {
|
||||||
const channelId = m.channelId ?? '';
|
const channelId = m.channelId ?? '';
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
|
// Record xType into the channel-meta cache before self-author
|
||||||
|
// / dedup gates — channel type doesn't depend on who sent the
|
||||||
|
// message, and recording it on observer-only triage messages
|
||||||
|
// is still useful (the next consumer asking
|
||||||
|
// __fabric.getChannelType wants the answer regardless of
|
||||||
|
// whether THIS message was delivered to an agent).
|
||||||
|
recordChannelType(channelId, m.xType);
|
||||||
if (m.authorUserId && m.authorUserId === selfUserId) return;
|
if (m.authorUserId && m.authorUserId === selfUserId) return;
|
||||||
const key = `${agentId}:${m.messageId}`;
|
const key = `${agentId}:${m.messageId}`;
|
||||||
if (this.seen.has(key)) return;
|
if (this.seen.has(key)) return;
|
||||||
this.seen.add(key);
|
this.seen.add(key);
|
||||||
if (this.seen.size > 5000) this.seen.clear();
|
if (this.seen.size > 5000) this.seen.clear();
|
||||||
void this.dispatch(agentId, g, channelId, m, session);
|
// 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();
|
socket.connect();
|
||||||
this.sockets.push(socket);
|
this.sockets.push(socket);
|
||||||
@@ -168,33 +554,40 @@ export class FabricInbound {
|
|||||||
m: FabricMessage,
|
m: FabricMessage,
|
||||||
session: FabricSession,
|
session: FabricSession,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// wakeup === false -> drop (Fabric already decided this agent is silent)
|
|
||||||
if (m.wakeup !== true) {
|
|
||||||
this.log.info(`fabric: drop (no wakeup) agent=${agentId} channel=${channelId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.log.info(`fabric: dispatch agent=${agentId} channel=${channelId}`);
|
|
||||||
|
|
||||||
const core = this.core as Core & Record<string, unknown>;
|
const core = this.core as Core & Record<string, unknown>;
|
||||||
const cfg = this.cfg as { session?: { store?: unknown } };
|
const cfg = this.cfg as { session?: { store?: unknown } };
|
||||||
try {
|
try {
|
||||||
|
// Route by xType. DM channels need peer.kind='direct' so openclaw
|
||||||
|
// treats them as 1:1 (sessionKey 'agent:<id>:fabric:direct:<chan>'
|
||||||
|
// and ctx.ChatType='direct') rather than as a multi-party group.
|
||||||
|
// Without this, the agent's user-prompt metadata says
|
||||||
|
// 'is_group_chat: true' on a DM and downstream prompt logic
|
||||||
|
// (commands-handlers `isDirectMessage` checks ChatType==='direct')
|
||||||
|
// misclassifies the turn.
|
||||||
|
const { peerKind, chatType } = fabricPeerRoutingForXType(m.xType);
|
||||||
|
// resolveAgentRoute needs the *binding* accountId (the channel-side
|
||||||
|
// slot name) — not the openclaw agentId. For most agents the binding
|
||||||
|
// is `{agentId: X, match: {channel: fabric, accountId: X}}` so the
|
||||||
|
// two coincide; but for shared-placeholder cases (e.g. the recruitment
|
||||||
|
// `interviewee` slot bound to multiple agents over its lifetime) the
|
||||||
|
// binding accountId is the slot label ("interviewee", "Neon", …) not
|
||||||
|
// the agent_id. Passing agentId there returned bindings=0 and silently
|
||||||
|
// fell back to `main`, hijacking sub-discussion turns. Look up the
|
||||||
|
// agent's fabric binding accountId here; fall back to agentId when no
|
||||||
|
// explicit binding exists (preserves prior behavior for agents with
|
||||||
|
// no fabric binding declared).
|
||||||
|
const bindingAccountId = findFabricBindingAccountId(this.cfg, agentId) ?? agentId;
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg: this.cfg,
|
cfg: this.cfg,
|
||||||
channel: 'fabric',
|
channel: 'fabric',
|
||||||
accountId: agentId,
|
accountId: bindingAccountId,
|
||||||
peer: { kind: 'group', id: channelId },
|
peer: { kind: peerKind, id: channelId },
|
||||||
});
|
});
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const gt = session.guildAccessTokens.find((t) => t.guildNodeId === guild.nodeId)?.token;
|
const baseCtx: Record<string, unknown> = {
|
||||||
|
|
||||||
// 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({
|
|
||||||
Body: m.content,
|
Body: m.content,
|
||||||
BodyForAgent: m.content,
|
BodyForAgent: m.content,
|
||||||
RawBody: m.content,
|
RawBody: m.content,
|
||||||
@@ -203,7 +596,7 @@ export class FabricInbound {
|
|||||||
To: `fabric:${channelId}`,
|
To: `fabric:${channelId}`,
|
||||||
SessionKey: route.sessionKey,
|
SessionKey: route.sessionKey,
|
||||||
AccountId: route.accountId ?? agentId,
|
AccountId: route.accountId ?? agentId,
|
||||||
ChatType: 'group',
|
ChatType: chatType,
|
||||||
ConversationLabel: `fabric:${guild.nodeId}`,
|
ConversationLabel: `fabric:${guild.nodeId}`,
|
||||||
SenderId: m.authorUserId ?? 'fabric',
|
SenderId: m.authorUserId ?? 'fabric',
|
||||||
Provider: 'fabric',
|
Provider: 'fabric',
|
||||||
@@ -212,14 +605,66 @@ export class FabricInbound {
|
|||||||
Timestamp: m.createdAt ? Date.parse(m.createdAt) : Date.now(),
|
Timestamp: m.createdAt ? Date.parse(m.createdAt) : Date.now(),
|
||||||
OriginatingChannel: 'fabric',
|
OriginatingChannel: 'fabric',
|
||||||
OriginatingTo: `fabric:${channelId}`,
|
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
|
...(media.paths.length
|
||||||
? {
|
? {
|
||||||
MediaPaths: media.paths,
|
MediaPaths: media.paths,
|
||||||
MediaTypes: media.types,
|
MediaTypes: media.types,
|
||||||
MediaUrls: media.urls,
|
|
||||||
MediaPath: media.paths[0],
|
MediaPath: media.paths[0],
|
||||||
MediaType: media.types[0],
|
MediaType: media.types[0],
|
||||||
MediaUrl: media.urls[0],
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
@@ -236,19 +681,47 @@ export class FabricInbound {
|
|||||||
const text = (payload?.text ?? '').trim();
|
const text = (payload?.text ?? '').trim();
|
||||||
this.log.info(`fabric: deliver agent=${agentId} channel=${channelId} len=${text.length}`);
|
this.log.info(`fabric: deliver agent=${agentId} channel=${channelId} len=${text.length}`);
|
||||||
if (!text || !gt) return;
|
if (!text || !gt) return;
|
||||||
await this.client.postMessage(guild.endpoint, gt, channelId, text, session.user.id);
|
// Buffer segments; the merged message is posted right after
|
||||||
this.log.info(`fabric: posted reply agent=${agentId} channel=${channelId}`);
|
// 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) =>
|
onRecordError: (err: unknown) =>
|
||||||
this.log.warn(`fabric: session record failed agent=${agentId}: ${String(err)}`),
|
this.log.warn(`fabric: session record failed agent=${agentId}: ${String(err)}`),
|
||||||
onDispatchError: (err: unknown, info: { kind: string }) =>
|
onDispatchError: (err: unknown, info: { kind: string }) =>
|
||||||
this.log.warn(`fabric: ${info.kind} dispatch failed agent=${agentId}: ${String(err)}`),
|
this.log.warn(`fabric: ${info.kind} dispatch failed agent=${agentId}: ${String(err)}`),
|
||||||
// Fabric has no length limit: deliver the whole reply as ONE message.
|
// - disableBlockStreaming: Fabric has no length limit, deliver the
|
||||||
replyOptions: { disableBlockStreaming: true } as never,
|
// 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}`);
|
this.log.info(`fabric: dispatch returned agent=${agentId} channel=${channelId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log.warn(`fabric: dispatch 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
177
src/presence-sync.ts
Normal file
177
src/presence-sync.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* presence-sync — read each connected agent's HF status (via the
|
||||||
|
* cross-plugin `globalThis.__hfAgentStatus.get(agentId)` exposed by
|
||||||
|
* HarborForge.OpenclawPlugin) and push diffs to Fabric.Backend.Guild
|
||||||
|
* `PUT /api/agents/:userId/presence` so the backend can apply
|
||||||
|
* busy-discard on `announce`-type channel deliveries.
|
||||||
|
*
|
||||||
|
* Push model: we only PUT when an agent's status actually changes
|
||||||
|
* (since the last push). The HF-side accessor has its own TTL cache
|
||||||
|
* to absorb the every-30s polling.
|
||||||
|
*
|
||||||
|
* Auth: the endpoint sits behind ApiKeyGuard (global APP_GUARD per
|
||||||
|
* app.module.js) which expects `Authorization: Bearer <guild-token>`
|
||||||
|
* — NOT the agent's fabricApiKey directly. So before each PUT we do
|
||||||
|
* a fresh agent-login (or reuse a cached token if still within its
|
||||||
|
* 15-min JWT TTL) and pull the guildAccessToken matching the target
|
||||||
|
* guild. Status changes are rare enough that login overhead is fine.
|
||||||
|
*
|
||||||
|
* If HF plugin isn't loaded (`__hfAgentStatus` undefined), the loop
|
||||||
|
* is a no-op — Fabric backend defaults presence to 'unknown' which is
|
||||||
|
* treated as not-busy. Announce-channel delivery still works; busy
|
||||||
|
* filtering simply doesn't kick in.
|
||||||
|
*/
|
||||||
|
import type { FabricClient } from './fabric-client.js';
|
||||||
|
|
||||||
|
type HfStatus = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline';
|
||||||
|
type Bridge = { get(agentId: string): Promise<HfStatus | undefined> };
|
||||||
|
type Logger = { info: (m: string) => void; warn: (m: string) => void };
|
||||||
|
|
||||||
|
export interface PresenceSyncAccount {
|
||||||
|
agentId: string;
|
||||||
|
fabricUserId: string; // the agent's Fabric Center user id (UUID)
|
||||||
|
guildBaseUrl: string; // e.g. https://fabric.hangman-lab.top/guild/<id>
|
||||||
|
guildNodeId: string; // which guildAccessTokens[].guildNodeId to pick
|
||||||
|
fabricApiKey: string; // existing per-account key (used for agent-login)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guild access JWTs expire every 900s. Refresh ~2 min early to stay
|
||||||
|
// safely inside the window even if a tick runs late.
|
||||||
|
const TOKEN_TTL_MS = (15 - 2) * 60 * 1000;
|
||||||
|
|
||||||
|
interface CachedToken {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number; // epoch ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PresenceSync {
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private readonly lastStatus = new Map<string, HfStatus>(); // by agentId
|
||||||
|
private readonly accounts = new Map<string, PresenceSyncAccount>();
|
||||||
|
private readonly tokenCache = new Map<string, CachedToken>(); // by agentId
|
||||||
|
|
||||||
|
// Mutex flag: a tick iterates accounts serially with `await` on each
|
||||||
|
// agent-login + PUT round-trip, so a single tick can easily run 20+s
|
||||||
|
// when there are many accounts. setInterval(intervalMs) does NOT wait
|
||||||
|
// for the previous tick to finish — without this guard the next tick
|
||||||
|
// fires on top of a still-running one and two parallel iterations
|
||||||
|
// PUT the same agentId within milliseconds. That tipped the backend's
|
||||||
|
// first-time-insert race (separate fix in Fabric.Backend.Guild) into
|
||||||
|
// 500s on prod. Guarded ticks just skip a beat instead.
|
||||||
|
private inflight = false;
|
||||||
|
|
||||||
|
constructor(private readonly logger: Logger, private readonly client: FabricClient) {}
|
||||||
|
|
||||||
|
setAccounts(accounts: PresenceSyncAccount[]): void {
|
||||||
|
this.accounts.clear();
|
||||||
|
for (const a of accounts) this.accounts.set(a.agentId, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(intervalMs = 30_000): void {
|
||||||
|
if (this.timer) return;
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.tick().catch((err) => this.logger.warn(`fabric: presence-sync error: ${String(err)}`));
|
||||||
|
}, intervalMs);
|
||||||
|
// run once immediately so initial state lands fast
|
||||||
|
void this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a fresh guildAccessToken for `acct`, caching it under the
|
||||||
|
* agentId until just before its JWT expiry. Returns null on login
|
||||||
|
* failure or if the session has no matching guild — caller logs +
|
||||||
|
* skips the PUT.
|
||||||
|
*/
|
||||||
|
private async ensureGuildToken(acct: PresenceSyncAccount): Promise<string | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = this.tokenCache.get(acct.agentId);
|
||||||
|
if (cached && cached.expiresAt > now) return cached.token;
|
||||||
|
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await this.client.agentLogin(acct.fabricApiKey);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`fabric: presence-sync agent-login failed for ${acct.agentId}: ${String(err)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry = session.guildAccessTokens.find((g) => g.guildNodeId === acct.guildNodeId);
|
||||||
|
if (!entry?.token) {
|
||||||
|
this.logger.warn(
|
||||||
|
`fabric: presence-sync no guild token for ${acct.agentId} guild=${acct.guildNodeId}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.tokenCache.set(acct.agentId, { token: entry.token, expiresAt: now + TOKEN_TTL_MS });
|
||||||
|
return entry.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick(): Promise<void> {
|
||||||
|
// Mutex: see the `inflight` field declaration for the why. Drop
|
||||||
|
// overlapping ticks rather than letting them run concurrently —
|
||||||
|
// status is gated by `lastStatus !== bridge.get`, so skipping a
|
||||||
|
// beat costs nothing the next beat won't catch.
|
||||||
|
if (this.inflight) return;
|
||||||
|
this.inflight = true;
|
||||||
|
try {
|
||||||
|
await this.tickInner();
|
||||||
|
} finally {
|
||||||
|
this.inflight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tickInner(): Promise<void> {
|
||||||
|
const bridge = (globalThis as Record<string, unknown>)['__hfAgentStatus'] as Bridge | undefined;
|
||||||
|
if (!bridge || typeof bridge.get !== 'function') return; // HF plugin not loaded — skip
|
||||||
|
|
||||||
|
for (const [agentId, acct] of this.accounts) {
|
||||||
|
let status: HfStatus | undefined;
|
||||||
|
try {
|
||||||
|
status = await bridge.get(agentId);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!status) continue;
|
||||||
|
if (this.lastStatus.get(agentId) === status) continue; // no change → no PUT
|
||||||
|
|
||||||
|
const guildToken = await this.ensureGuildToken(acct);
|
||||||
|
if (!guildToken) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Endpoint: PUT /api/agents/:userId/presence. ApiKeyGuard (global
|
||||||
|
// APP_GUARD) requires `Authorization: Bearer <guildAccessToken>`
|
||||||
|
// — NOT the agent's raw fabricApiKey. Pre-v1: this loop sent
|
||||||
|
// x-api-key and got 401 "missing bearer token" forever. The /api
|
||||||
|
// prefix is required because the guild backend sets a global
|
||||||
|
// 'api' prefix in main.ts setGlobalPrefix('api').
|
||||||
|
const url = `${acct.guildBaseUrl.replace(/\/$/, '')}/api/agents/${encodeURIComponent(acct.fabricUserId)}/presence`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
authorization: `Bearer ${guildToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status, source: 'hf-plugin' }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
this.lastStatus.set(agentId, status);
|
||||||
|
this.logger.info(`fabric: presence-sync ${agentId} → ${status}`);
|
||||||
|
} else {
|
||||||
|
// 401 here usually means the cached token went stale unexpectedly
|
||||||
|
// (server-side rotation or clock skew) — drop the cache so the
|
||||||
|
// next tick re-logs-in.
|
||||||
|
if (res.status === 401) this.tokenCache.delete(agentId);
|
||||||
|
this.logger.warn(`fabric: presence-sync PUT ${agentId} failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`fabric: presence-sync PUT ${agentId} threw: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/sub-discussion-hook.ts
Normal file
76
src/sub-discussion-hook.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { IdentityRegistry } from './identity.js';
|
||||||
|
import type { SubDiscussionStore } from './sub-discussion-store.js';
|
||||||
|
|
||||||
|
// Plugin-local before_prompt_build hook that injects per-(agent, channel)
|
||||||
|
// guides for sub-discussion channels created via the `create-sub-discussion`
|
||||||
|
// tool. Mirrors the pattern used by ClawPrompts' fabric-chat-injector
|
||||||
|
// (channelId-aware injection) but with content dynamically supplied at
|
||||||
|
// channel-creation time instead of read from static files via PrismFacet's
|
||||||
|
// router/rule registry.
|
||||||
|
//
|
||||||
|
// Match logic per turn:
|
||||||
|
// ctx.channelId → store.find() → sub-discussion entry
|
||||||
|
// ctx.agentId → identity.findByAgentId().fabricUserId
|
||||||
|
// ─ matches entry.hostUserId → inject hostGuide
|
||||||
|
// ─ matches entry.guestUserIds → inject guestGuide
|
||||||
|
// ─ neither → no injection
|
||||||
|
//
|
||||||
|
// Fail-closed on unknown agentId/channelId — we never inject "the wrong"
|
||||||
|
// guide, only the right one or nothing.
|
||||||
|
|
||||||
|
const _G = globalThis as Record<string, unknown>;
|
||||||
|
const DEDUP_KEY = '_fabricSubDiscussionHookDedup';
|
||||||
|
|
||||||
|
type PromptCtx = {
|
||||||
|
agentId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
messageProvider?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerSubDiscussionHook(
|
||||||
|
api: {
|
||||||
|
on: (hook: string, handler: (...args: unknown[]) => unknown) => void;
|
||||||
|
logger: { info: (m: string) => void; warn: (m: string) => void };
|
||||||
|
},
|
||||||
|
store: SubDiscussionStore,
|
||||||
|
identity: IdentityRegistry,
|
||||||
|
): void {
|
||||||
|
if (!(_G[DEDUP_KEY] instanceof WeakSet)) _G[DEDUP_KEY] = new WeakSet<object>();
|
||||||
|
const dedup = _G[DEDUP_KEY] as WeakSet<object>;
|
||||||
|
|
||||||
|
api.on('before_prompt_build', async (...args: unknown[]) => {
|
||||||
|
const event = args[0];
|
||||||
|
const ctx = (args[1] ?? {}) as PromptCtx;
|
||||||
|
// The hook fires both for fabric-driven turns (channelId set) and
|
||||||
|
// for other triggers (HF wake, exec-event, etc.) — drop those.
|
||||||
|
if (typeof event === 'object' && event !== null) {
|
||||||
|
if (dedup.has(event)) return undefined;
|
||||||
|
dedup.add(event);
|
||||||
|
}
|
||||||
|
const agentId = (ctx.agentId ?? '').trim();
|
||||||
|
const channelId = (ctx.channelId ?? '').trim();
|
||||||
|
if (!agentId || !channelId) return undefined;
|
||||||
|
const provider = (ctx.messageProvider ?? '').toLowerCase();
|
||||||
|
if (provider && provider !== 'fabric') return undefined;
|
||||||
|
|
||||||
|
const entry = store.find(channelId);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
|
||||||
|
const ident = identity.findByAgentId(agentId);
|
||||||
|
const myUserId = (ident?.fabricUserId ?? '').trim();
|
||||||
|
if (!myUserId) {
|
||||||
|
// identity registry caches fabricUserId after the first agentLogin
|
||||||
|
// in inbound.ts. If it's missing here, the agent likely hasn't
|
||||||
|
// completed login yet — skip rather than guess.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (myUserId === entry.hostUserId) {
|
||||||
|
return { appendSystemContext: entry.hostGuide };
|
||||||
|
}
|
||||||
|
if (entry.guestUserIds.includes(myUserId)) {
|
||||||
|
return { appendSystemContext: entry.guestGuide };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
76
src/sub-discussion-store.ts
Normal file
76
src/sub-discussion-store.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
|
// Records per-(sub-discussion-channel) state created by the
|
||||||
|
// `create-sub-discussion` tool and consumed by:
|
||||||
|
// 1. `before_prompt_build` hook — looks up by (agentId, channelId) to
|
||||||
|
// inject the host or guest guide as appendSystemContext.
|
||||||
|
// 2. `close-sub-discussion` tool — looks up by sub channelId to find
|
||||||
|
// the parent channel to post the callback to and to validate that
|
||||||
|
// the caller is the original host.
|
||||||
|
//
|
||||||
|
// One sub-discussion = one channel. Lifetime: from create-sub-discussion
|
||||||
|
// return until close-sub-discussion (or gateway-stop / disk corruption).
|
||||||
|
// We persist to a small JSON file so a gateway restart mid-interview
|
||||||
|
// doesn't strand both host and guest with no guide.
|
||||||
|
|
||||||
|
export type SubDiscussionEntry = {
|
||||||
|
subChannelId: string;
|
||||||
|
hostAgentId: string; // the openclaw agentId that called create-sub-discussion
|
||||||
|
hostUserId: string; // the host's Fabric Center userId (session.user.id)
|
||||||
|
guestUserIds: string[]; // Fabric Center userIds invited as guests
|
||||||
|
hostGuide: string; // appended to host's session system prompt while in this channel
|
||||||
|
guestGuide: string; // appended to each guest's session system prompt while in this channel
|
||||||
|
callbackGuildNodeId: string; // where to post the callback when close is called
|
||||||
|
callbackChannelId: string; // parent channel to post callback to (system msg)
|
||||||
|
createdAt: string; // ISO timestamp
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreFile = { entries: SubDiscussionEntry[] };
|
||||||
|
|
||||||
|
export class SubDiscussionStore {
|
||||||
|
private byChannelId = new Map<string, SubDiscussionEntry>();
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string) {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
if (!existsSync(this.filePath)) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(readFileSync(this.filePath, 'utf8')) as StoreFile;
|
||||||
|
for (const e of data.entries ?? []) {
|
||||||
|
if (e?.subChannelId) this.byChannelId.set(e.subChannelId, e);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupt file → start empty; first mutation rewrites cleanly.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
mkdirSync(dirname(this.filePath), { recursive: true });
|
||||||
|
const data: StoreFile = { entries: [...this.byChannelId.values()] };
|
||||||
|
writeFileSync(this.filePath, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): SubDiscussionEntry[] {
|
||||||
|
return [...this.byChannelId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
find(subChannelId: string): SubDiscussionEntry | undefined {
|
||||||
|
return this.byChannelId.get(subChannelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(entry: SubDiscussionEntry): void {
|
||||||
|
this.byChannelId.set(entry.subChannelId, entry);
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(subChannelId: string): SubDiscussionEntry | undefined {
|
||||||
|
const e = this.byChannelId.get(subChannelId);
|
||||||
|
if (!e) return undefined;
|
||||||
|
this.byChannelId.delete(subChannelId);
|
||||||
|
this.persist();
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
792
src/tools.ts
792
src/tools.ts
@@ -1,5 +1,7 @@
|
|||||||
import type { FabricClient } from './fabric-client.js';
|
import type { FabricClient } from './fabric-client.js';
|
||||||
import type { IdentityRegistry } from './identity.js';
|
import type { IdentityRegistry } from './identity.js';
|
||||||
|
import type { SubDiscussionStore } from './sub-discussion-store.js';
|
||||||
|
import { resolveCommandsSyncKey } from './accounts.js';
|
||||||
|
|
||||||
// OpenClaw tool registration api (loose typing — concrete shape from
|
// OpenClaw tool registration api (loose typing — concrete shape from
|
||||||
// openclaw/plugin-sdk/core at host SDK version).
|
// openclaw/plugin-sdk/core at host SDK version).
|
||||||
@@ -10,6 +12,9 @@ type ToolApi = {
|
|||||||
|
|
||||||
type Ctx = { agentId?: string };
|
type Ctx = { agentId?: string };
|
||||||
|
|
||||||
|
// Loose config shape — just the fields we read here.
|
||||||
|
type ToolsCfg = { channels?: { fabric?: { commandsSyncKey?: string } }; [k: string]: unknown };
|
||||||
|
|
||||||
const X_BY_KIND: Record<string, string> = {
|
const X_BY_KIND: Record<string, string> = {
|
||||||
chat: 'general',
|
chat: 'general',
|
||||||
work: 'work',
|
work: 'work',
|
||||||
@@ -17,15 +22,35 @@ const X_BY_KIND: Record<string, string> = {
|
|||||||
discussion: 'discuss',
|
discussion: 'discuss',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Delay between create-sub-discussion's channel create and greeting post.
|
||||||
|
// Backend pushes channel.joined to invitee sockets on create; that push
|
||||||
|
// has to traverse socket.io rooms before the guest plugin can sub the
|
||||||
|
// channel:<id> room. If the greeting is posted before that, the guest's
|
||||||
|
// turn-activation wakeup misses (the socket isn't in the room yet).
|
||||||
|
// 500ms is empirically slack enough on local sim + production t3, and
|
||||||
|
// short enough not to feel laggy from the host's tool-result POV. Bump
|
||||||
|
// via FABRIC_SUB_DISCUSSION_GREETING_DELAY_MS env if needed.
|
||||||
|
const GREETING_DELAY_MS = (() => {
|
||||||
|
const v = Number.parseInt(process.env.FABRIC_SUB_DISCUSSION_GREETING_DELAY_MS ?? '', 10);
|
||||||
|
return Number.isFinite(v) && v >= 0 ? v : 500;
|
||||||
|
})();
|
||||||
|
|
||||||
export function registerFabricTools(
|
export function registerFabricTools(
|
||||||
api: ToolApi,
|
api: ToolApi,
|
||||||
client: FabricClient,
|
client: FabricClient,
|
||||||
identity: IdentityRegistry,
|
identity: IdentityRegistry,
|
||||||
|
store: SubDiscussionStore,
|
||||||
|
cfg: ToolsCfg,
|
||||||
): void {
|
): void {
|
||||||
// Resolve the calling agent's Fabric session + a guild's token/endpoint.
|
// Resolve the calling agent's Fabric session + a guild's token/endpoint.
|
||||||
const ctxGuild = async (agentId: string, guildNodeId: string) => {
|
const ctxGuild = async (agentId: string, guildNodeId: string) => {
|
||||||
const entry = identity.findByAgentId(agentId);
|
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 session = await client.agentLogin(entry.fabricApiKey);
|
||||||
const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
|
const guild = session.guilds.find((g) => g.nodeId === guildNodeId);
|
||||||
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
const token = session.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
|
||||||
@@ -33,36 +58,18 @@ export function registerFabricTools(
|
|||||||
return { session, guild, token };
|
return { session, guild, token };
|
||||||
};
|
};
|
||||||
|
|
||||||
// fabric-register: bind this agent to a Fabric API key.
|
// NOTE: binding an agent's Fabric API key is intentionally NOT a tool.
|
||||||
api.registerTool((ctx: Ctx) => ({
|
// It's a one-time step done out-of-band via the installed script
|
||||||
name: 'fabric-register',
|
// ~/.openclaw/bin/fabric-register --api-key <fak_…> (AGENT_ID or --agent-id)
|
||||||
description: "Register this agent's Fabric API key (minted via Center CLI `user apikey`).",
|
// or via static config (channels.fabric.accounts.<agentId>).
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: false,
|
|
||||||
required: ['fabricApiKey'],
|
|
||||||
properties: {
|
|
||||||
fabricApiKey: { type: 'string', description: 'Fabric Center API key (fak_…)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute: 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 };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') =>
|
const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') =>
|
||||||
api.registerTool((ctx: Ctx) => ({
|
api.registerTool((ctx: Ctx) => ({
|
||||||
name: `create-${kind}-channel`,
|
name: `create-${kind}-channel`,
|
||||||
description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`,
|
description:
|
||||||
|
`Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` +
|
||||||
|
'Optionally pass `purpose` to describe what this channel is for — ' +
|
||||||
|
'agents browse channels by purpose via fabric-channel-list.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -74,13 +81,22 @@ export function registerFabricTools(
|
|||||||
memberUserIds: { type: 'array', items: { type: 'string' } },
|
memberUserIds: { type: 'array', items: { type: 'string' } },
|
||||||
onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' },
|
onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' },
|
||||||
listeners: { type: 'array', items: { type: 'string' } },
|
listeners: { type: 'array', items: { type: 'string' } },
|
||||||
|
purpose: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
"Free-form description of what this channel is for. Optional but " +
|
||||||
|
'strongly recommended so other agents can find this channel by ' +
|
||||||
|
'intent (via fabric-channel-list). Can be edited later with ' +
|
||||||
|
'fabric-channel-set-purpose.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
execute: async (p: {
|
execute: async (_id: string, p: {
|
||||||
guildNodeId: string;
|
guildNodeId: string;
|
||||||
name: string;
|
name: string;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
memberUserIds?: string[];
|
memberUserIds?: string[];
|
||||||
|
purpose?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const agentId = ctx.agentId;
|
const agentId = ctx.agentId;
|
||||||
if (!agentId) return { ok: false, error: 'no agent context' };
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||||
@@ -91,6 +107,7 @@ export function registerFabricTools(
|
|||||||
xType: X_BY_KIND[kind],
|
xType: X_BY_KIND[kind],
|
||||||
isPublic: p.isPublic ?? false,
|
isPublic: p.isPublic ?? false,
|
||||||
memberUserIds: p.memberUserIds ?? [],
|
memberUserIds: p.memberUserIds ?? [],
|
||||||
|
...(p.purpose !== undefined ? { purpose: p.purpose } : {}),
|
||||||
});
|
});
|
||||||
return { ok: true, channelId: ch.id };
|
return { ok: true, channelId: ch.id };
|
||||||
},
|
},
|
||||||
@@ -117,7 +134,7 @@ export function registerFabricTools(
|
|||||||
callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' },
|
callbackChannelId: { type: 'string', description: 'optional channel to also post the summary to' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
execute: async (p: {
|
execute: async (_id: string, p: {
|
||||||
guildNodeId: string;
|
guildNodeId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -136,4 +153,721 @@ export function registerFabricTools(
|
|||||||
return { ok: true, closed: true };
|
return { ok: true, closed: true };
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────
|
||||||
|
// create-sub-discussion: open a discuss-type sub-channel hanging off
|
||||||
|
// the caller's current channel. Designed for host-driven multi-turn
|
||||||
|
// exchanges (interview, brainstorm, narrow Q&A) where the guests are
|
||||||
|
// either fresh agents without workflow capability (recruitment
|
||||||
|
// interviewee) or peers that just need a short scoped chat without
|
||||||
|
// entering their own subflow.
|
||||||
|
//
|
||||||
|
// What it does on top of plain create-discussion-channel:
|
||||||
|
// 1. Persists a store entry indexed by the new sub channelId, carrying:
|
||||||
|
// host agent + userId, guest userIds, host/guest guide texts,
|
||||||
|
// callback (parent) channel info.
|
||||||
|
// 2. Auto-posts `greetingMsg` using the host's own Fabric account so
|
||||||
|
// turn rotation's activation rule (first author → newOrder[0],
|
||||||
|
// currentSpeaker → newOrder[1], wakeup → newOrder[1]) puts the
|
||||||
|
// first guest on the spot immediately — no race where host posts
|
||||||
|
// before guest's socket subs the channel room (we wait
|
||||||
|
// GREETING_DELAY_MS for backend's channel.joined push to land).
|
||||||
|
// 3. The accompanying before_prompt_build hook (sub-discussion-hook
|
||||||
|
// registered from index.ts) then injects `hostGuideMsg` into the
|
||||||
|
// host's session prompt and `guestGuideMsg` into each guest's
|
||||||
|
// session prompt whenever a turn in this channel fires — so the
|
||||||
|
// two roles see different instructions, no shared guide file.
|
||||||
|
// ───────────────────────────────────────────────────────────────────
|
||||||
|
api.registerTool((ctx: Ctx) => ({
|
||||||
|
name: 'create-sub-discussion',
|
||||||
|
description:
|
||||||
|
'Open a host-driven sub-discussion channel (x_type=discuss) hanging off your current channel, ' +
|
||||||
|
'with role-specific system-prompt guides for host and guests. Use this for interviews / scoped ' +
|
||||||
|
'Q&A where you stay in control of when the conversation ends. Returns the sub channelId; ' +
|
||||||
|
'reach it via fabric-send-message in the rotating turn order. Close with close-sub-discussion ' +
|
||||||
|
'to write a callback back into the parent channel.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: [
|
||||||
|
'guildNodeId',
|
||||||
|
'currentChannelId',
|
||||||
|
'channelName',
|
||||||
|
'greetingMsg',
|
||||||
|
'hostGuideMsg',
|
||||||
|
'guestGuideMsg',
|
||||||
|
'guests',
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
guildNodeId: { type: 'string', description: 'Fabric guild node id (same guild for parent + sub).' },
|
||||||
|
currentChannelId: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Channel id you are currently in (parent). Used as the callback target on close.',
|
||||||
|
},
|
||||||
|
channelName: { type: 'string', description: 'Display name for the new sub-discussion channel.' },
|
||||||
|
greetingMsg: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'First message posted by YOU (the host) in the sub channel. Triggers turn rotation so ' +
|
||||||
|
"the first guest's session wakes immediately with both your greeting in history and the " +
|
||||||
|
'guest guide injected as system prompt.',
|
||||||
|
},
|
||||||
|
hostGuideMsg: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
"Appended to YOUR session's system prompt whenever a turn fires in this sub channel. " +
|
||||||
|
'Use it to remind yourself of the procedure (what to ask, when to call close-sub-discussion).',
|
||||||
|
},
|
||||||
|
guestGuideMsg: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
"Appended to EACH GUEST's session system prompt for turns in this sub channel. Use it to " +
|
||||||
|
'orient guests with no prior workflow context (e.g. a fresh interviewee). Keep it short; ' +
|
||||||
|
'long guides bloat every turn.',
|
||||||
|
},
|
||||||
|
guests: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
minItems: 1,
|
||||||
|
description:
|
||||||
|
'Fabric Center userIds invited as guests. Resolve via fabric-channel-list members or the ' +
|
||||||
|
'<name>@<role>.hangman-lab.top email convention.',
|
||||||
|
},
|
||||||
|
purpose: { type: 'string', description: 'Optional channel.purpose for discoverability.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (
|
||||||
|
_id: string,
|
||||||
|
p: {
|
||||||
|
guildNodeId: string;
|
||||||
|
currentChannelId: string;
|
||||||
|
channelName: string;
|
||||||
|
greetingMsg: string;
|
||||||
|
hostGuideMsg: string;
|
||||||
|
guestGuideMsg: string;
|
||||||
|
guests: string[];
|
||||||
|
purpose?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const agentId = ctx.agentId;
|
||||||
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||||
|
if (!Array.isArray(p.guests) || p.guests.length === 0) {
|
||||||
|
return { ok: false, error: 'guests must be a non-empty array of Fabric userIds' };
|
||||||
|
}
|
||||||
|
const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||||
|
const ch = await client.createChannel(guild.endpoint, token, {
|
||||||
|
guildId: p.guildNodeId,
|
||||||
|
name: p.channelName,
|
||||||
|
xType: 'discuss',
|
||||||
|
isPublic: false,
|
||||||
|
memberUserIds: p.guests,
|
||||||
|
...(p.purpose !== undefined ? { purpose: p.purpose } : {}),
|
||||||
|
});
|
||||||
|
store.add({
|
||||||
|
subChannelId: ch.id,
|
||||||
|
hostAgentId: agentId,
|
||||||
|
hostUserId: session.user.id,
|
||||||
|
guestUserIds: [...p.guests],
|
||||||
|
hostGuide: p.hostGuideMsg,
|
||||||
|
guestGuide: p.guestGuideMsg,
|
||||||
|
callbackGuildNodeId: p.guildNodeId,
|
||||||
|
callbackChannelId: p.currentChannelId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
// Let backend's channel.joined push reach guest sockets before our
|
||||||
|
// greeting fires — otherwise the wakeup emitted by turn-activation
|
||||||
|
// races a not-yet-subscribed socket.io room.
|
||||||
|
if (GREETING_DELAY_MS > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, GREETING_DELAY_MS));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.postMessage(guild.endpoint, token, ch.id, p.greetingMsg, session.user.id);
|
||||||
|
} catch (err) {
|
||||||
|
api.logger.warn(
|
||||||
|
`fabric: create-sub-discussion greeting post failed channel=${ch.id} err=${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ok: true, subChannelId: ch.id };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────
|
||||||
|
// close-sub-discussion: post a system-authored callback into the
|
||||||
|
// parent channel + close the sub-discussion channel. Only the original
|
||||||
|
// host can call this. Uses the Guild's x-fabric-system-key path (shared
|
||||||
|
// secret = commandsSyncKey) so the callback lands as a guild/system
|
||||||
|
// author, not the host's personal account — and can wake the host on
|
||||||
|
// the parent channel to continue whatever workflow opened the sub.
|
||||||
|
// ───────────────────────────────────────────────────────────────────
|
||||||
|
api.registerTool((ctx: Ctx) => ({
|
||||||
|
name: 'close-sub-discussion',
|
||||||
|
description:
|
||||||
|
'Close a sub-discussion channel you opened (host-only) and write a callback to the parent ' +
|
||||||
|
'channel as a system message. Pass `wakeupHost: false` to land the callback silently in ' +
|
||||||
|
'history without waking yourself.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['subChannelId', 'callbackMsg'],
|
||||||
|
properties: {
|
||||||
|
subChannelId: { type: 'string', description: 'The sub-discussion channelId returned by create-sub-discussion.' },
|
||||||
|
callbackMsg: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Content to post into the parent channel as a system-authored message. Typical content: ' +
|
||||||
|
'the conclusion / extracted data from the sub-discussion, so the next turn on the parent ' +
|
||||||
|
'channel can act on it.',
|
||||||
|
},
|
||||||
|
wakeupHost: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Whether to wake YOU (the host) on the parent channel. Default true — for recruitment ' +
|
||||||
|
'interview flow where the next workflow step needs to run immediately. Pass false for ' +
|
||||||
|
'fire-and-forget logging.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (
|
||||||
|
_id: string,
|
||||||
|
p: { subChannelId: string; callbackMsg: string; wakeupHost?: boolean },
|
||||||
|
) => {
|
||||||
|
const agentId = ctx.agentId;
|
||||||
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||||
|
const entry = store.find(p.subChannelId);
|
||||||
|
if (!entry) {
|
||||||
|
return { ok: false, error: `sub-discussion not found: ${p.subChannelId}` };
|
||||||
|
}
|
||||||
|
if (entry.hostAgentId !== agentId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `only the host (${entry.hostAgentId}) may close this sub-discussion`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const systemKey = resolveCommandsSyncKey(cfg);
|
||||||
|
if (!systemKey) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'channels.fabric.commandsSyncKey is not configured — close-sub-discussion needs it for ' +
|
||||||
|
'the x-fabric-system-key callback. Configure via openclaw config.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { guild, token } = await ctxGuild(agentId, entry.callbackGuildNodeId);
|
||||||
|
const wakeup = p.wakeupHost !== false;
|
||||||
|
// 1) Post callback into parent channel via the system-key path.
|
||||||
|
const url = `${guild.endpoint}/api/channels/${encodeURIComponent(entry.callbackChannelId)}/messages`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-fabric-system-key': systemKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: p.callbackMsg,
|
||||||
|
wakeupUserId: wakeup ? entry.hostUserId : null,
|
||||||
|
}),
|
||||||
|
}).catch((err) => {
|
||||||
|
throw new Error(`callback POST failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `callback POST ${url} -> ${res.status} ${text}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 2) Close the sub channel using the host's own bearer (the host is
|
||||||
|
// a member of the channel — channel.close auth is per-member).
|
||||||
|
try {
|
||||||
|
await client.closeChannel(guild.endpoint, token, entry.subChannelId);
|
||||||
|
} catch (err) {
|
||||||
|
api.logger.warn(
|
||||||
|
`fabric: close-sub-discussion: sub channel close failed channel=${entry.subChannelId} err=${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 3) Drop the store entry so the prompt-injection hook stops firing
|
||||||
|
// for this channel (the sub is closed; any straggler turns would
|
||||||
|
// just hit the closed-channel write reject downstream).
|
||||||
|
store.remove(entry.subChannelId);
|
||||||
|
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 (_id: string, 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 (_id: string, 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 (_id: string, 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-send-sys-msg: post a system-authored message (author =
|
||||||
|
// sentinel UUID 0000…, not the calling agent) using the Guild's
|
||||||
|
// x-fabric-system-key path. Use for cross-agent broadcasts where you
|
||||||
|
// don't want the message tied to one agent's identity — Dialectic
|
||||||
|
// topic announcements / lifecycle events, host-system advisories,
|
||||||
|
// etc. Caller doesn't need to be a member of the channel (the
|
||||||
|
// backend isSystem branch skips assertParticipant), but must be a
|
||||||
|
// member of the guild (their session resolves the guild endpoint).
|
||||||
|
//
|
||||||
|
// Shared secret: reads channels.fabric.commandsSyncKey (same value
|
||||||
|
// as the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY env). Empty
|
||||||
|
// config → tool returns ok:false with a clear error, no fall-through
|
||||||
|
// to regular agent posting.
|
||||||
|
// ───────────────────────────────────────────────────────────────────
|
||||||
|
api.registerTool((ctx: Ctx) => ({
|
||||||
|
name: 'fabric-send-sys-msg',
|
||||||
|
description:
|
||||||
|
'Send a SYSTEM-AUTHORED message into a Fabric channel (author = guild sentinel, not you). ' +
|
||||||
|
'Use for cross-agent broadcasts that should not be attributed to a single agent — ' +
|
||||||
|
'Dialectic announce-channel topic broadcasts, lifecycle events, system advisories. ' +
|
||||||
|
'Optionally precise-wake one recipient via wakeupUserId; otherwise the message lands ' +
|
||||||
|
'silently in history (no wake).',
|
||||||
|
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).' },
|
||||||
|
wakeupUserId: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
"Optional: a single Fabric userId to wake with this message (everyone else in the " +
|
||||||
|
'channel sees it but with wakeup=false). Omit for fully silent broadcast.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (
|
||||||
|
_id: string,
|
||||||
|
p: { guildNodeId: string; channelId: string; content: string; wakeupUserId?: string },
|
||||||
|
) => {
|
||||||
|
const agentId = ctx.agentId;
|
||||||
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||||
|
const systemKey = resolveCommandsSyncKey(cfg);
|
||||||
|
if (!systemKey) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'channels.fabric.commandsSyncKey is not configured — fabric-send-sys-msg needs it for ' +
|
||||||
|
'the x-fabric-system-key header. Configure via openclaw config.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { guild } = await ctxGuild(agentId, p.guildNodeId);
|
||||||
|
const url = `${guild.endpoint}/api/channels/${encodeURIComponent(p.channelId)}/messages`;
|
||||||
|
const wakeup = typeof p.wakeupUserId === 'string' && p.wakeupUserId.trim()
|
||||||
|
? p.wakeupUserId.trim()
|
||||||
|
: null;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-fabric-system-key': systemKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: p.content, wakeupUserId: wakeup }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
return { ok: false, error: `POST ${url} -> ${res.status} ${text}` };
|
||||||
|
}
|
||||||
|
const json = (await res.json().catch(() => null)) as
|
||||||
|
| { messageId?: string; seq?: number; authorUserId?: string }
|
||||||
|
| null;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
messageId: json?.messageId,
|
||||||
|
seq: json?.seq,
|
||||||
|
authorUserId: json?.authorUserId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 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 (_id: string, 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,
|
||||||
|
purpose: c.purpose ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// fabric-guild-list: enumerate guilds the calling agent belongs to.
|
||||||
|
// Each row carries `purpose` — free-form description of what the
|
||||||
|
// guild is for (admin-set). Use this as the first step when a
|
||||||
|
// workflow says "find the right guild for X" — pick by purpose,
|
||||||
|
// then fabric-channel-list to find the right channel inside it.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
api.registerTool((ctx: Ctx) => ({
|
||||||
|
name: 'fabric-guild-list',
|
||||||
|
description:
|
||||||
|
'List guilds the calling agent is a member of. Returns ' +
|
||||||
|
'{nodeId, name, purpose, status} per row. ' +
|
||||||
|
"`purpose` is a free-form description of what each guild is for — " +
|
||||||
|
'pick the guild whose purpose matches your intent. Use this tool ' +
|
||||||
|
'BEFORE fabric-channel-list when a workflow asks you to pick the ' +
|
||||||
|
'right guild by intent (e.g. "find a guild whose purpose mentions ' +
|
||||||
|
'debate broadcasts" → then list its announce-type channels).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
nameFilter: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'optional case-insensitive substring match on guild name',
|
||||||
|
},
|
||||||
|
purposeFilter: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'optional case-insensitive substring match on guild purpose ' +
|
||||||
|
'(e.g. "debate", "announcements")',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (_id: string, p: { nameFilter?: string; purposeFilter?: string }) => {
|
||||||
|
const agentId = ctx.agentId;
|
||||||
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||||
|
const entry = identity.findByAgentId(agentId);
|
||||||
|
if (!entry) return { ok: false, error: `agent ${agentId} not registered` };
|
||||||
|
const session = await client.agentLogin(entry.fabricApiKey);
|
||||||
|
const nameNeedle = (p.nameFilter ?? '').toLowerCase();
|
||||||
|
const purposeNeedle = (p.purposeFilter ?? '').toLowerCase();
|
||||||
|
const guilds = session.guilds.filter((g) => {
|
||||||
|
if (nameNeedle && !g.name.toLowerCase().includes(nameNeedle)) return false;
|
||||||
|
if (purposeNeedle) {
|
||||||
|
const purp = (g.purpose ?? '').toLowerCase();
|
||||||
|
if (!purp.includes(purposeNeedle)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
count: guilds.length,
|
||||||
|
guilds: guilds.map((g) => ({
|
||||||
|
nodeId: g.nodeId,
|
||||||
|
name: g.name,
|
||||||
|
status: g.status,
|
||||||
|
purpose: g.purpose ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// fabric-channel-set-purpose: set/update a channel's free-form
|
||||||
|
// purpose description. Caller must be a channel member (or the
|
||||||
|
// channel must be public). Use this to backfill purpose on existing
|
||||||
|
// channels, or to refine it after a channel's role evolves.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
api.registerTool((ctx: Ctx) => ({
|
||||||
|
name: 'fabric-channel-set-purpose',
|
||||||
|
description:
|
||||||
|
"Set or update a channel's free-form purpose description. " +
|
||||||
|
'Channel membership required (or the channel must be public). ' +
|
||||||
|
'Pass empty string to clear. Use this to make a channel ' +
|
||||||
|
'discoverable to other agents via fabric-channel-list.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['guildNodeId', 'channelId', 'purpose'],
|
||||||
|
properties: {
|
||||||
|
guildNodeId: { type: 'string' },
|
||||||
|
channelId: { type: 'string' },
|
||||||
|
purpose: {
|
||||||
|
type: 'string',
|
||||||
|
description: "What this channel is for. Pass '' (empty string) to clear.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (_id: string, p: { guildNodeId: string; channelId: string; purpose: string }) => {
|
||||||
|
const agentId = ctx.agentId;
|
||||||
|
if (!agentId) return { ok: false, error: 'no agent context' };
|
||||||
|
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
|
||||||
|
const res = await client.setChannelPurpose(
|
||||||
|
guild.endpoint,
|
||||||
|
token,
|
||||||
|
p.channelId,
|
||||||
|
p.purpose,
|
||||||
|
);
|
||||||
|
return { ok: true, channel: res };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 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 (_id: string, 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