@botcord/daemon 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon-config-map.js +4 -3
- package/dist/gateway/channels/botcord.d.ts +11 -0
- package/dist/gateway/channels/botcord.js +79 -5
- package/dist/gateway/router.d.ts +8 -3
- package/dist/gateway/router.js +14 -4
- package/dist/turn-text.js +88 -1
- package/package.json +1 -1
- package/src/__tests__/turn-text.test.ts +111 -0
- package/src/daemon-config-map.ts +4 -3
- package/src/gateway/__tests__/botcord-channel.test.ts +63 -0
- package/src/gateway/__tests__/router.test.ts +27 -1
- package/src/gateway/channels/botcord.ts +95 -5
- package/src/gateway/router.ts +18 -4
- package/src/turn-text.ts +119 -1
|
@@ -113,9 +113,10 @@ export function toGatewayConfig(cfg, opts = {}) {
|
|
|
113
113
|
const routes = (cfg.routes ?? []).map(mapRoute);
|
|
114
114
|
// Synthesize a per-agent route for every bound agent and hand it to the
|
|
115
115
|
// gateway via the managed-routes bucket (plan §10.1). User-authored
|
|
116
|
-
// `cfg.routes[]` stay untouched
|
|
117
|
-
//
|
|
118
|
-
// defaultRoute
|
|
116
|
+
// `cfg.routes[]` stay untouched. Match priority (see router.ts):
|
|
117
|
+
// `routes[] with explicit accountId → managedRoutes → other routes[] →
|
|
118
|
+
// defaultRoute`. Broad prefix/kind rules no longer clobber the agent's
|
|
119
|
+
// chosen runtime — only routes that name the agent by `accountId` do.
|
|
119
120
|
const managedMap = buildManagedRoutes(agentIds, opts.agentRuntimes ?? {}, defaultRoute);
|
|
120
121
|
return {
|
|
121
122
|
channels,
|
|
@@ -82,6 +82,17 @@ declare function normalizeInbox(msg: InboxMessage, options: {
|
|
|
82
82
|
channelId: string;
|
|
83
83
|
accountId: string;
|
|
84
84
|
}): GatewayInboundMessage | null;
|
|
85
|
+
/**
|
|
86
|
+
* Shape of the `raw` field when the channel batches multiple messages into
|
|
87
|
+
* one envelope. Keeps the latest message's InboxMessage fields at top level
|
|
88
|
+
* so existing accesses (`raw.envelope.type`, `raw.source_type`, …) still
|
|
89
|
+
* work, and exposes the full list via `raw.batch`. `composeBotCordUserTurn`
|
|
90
|
+
* reads `raw.batch` to build one `<agent-message>` / `<human-message>` block
|
|
91
|
+
* per entry.
|
|
92
|
+
*/
|
|
93
|
+
export interface BatchedInboxRaw extends InboxMessage {
|
|
94
|
+
batch: InboxMessage[];
|
|
95
|
+
}
|
|
85
96
|
/**
|
|
86
97
|
* Construct a BotCord channel adapter.
|
|
87
98
|
*
|
|
@@ -113,6 +113,50 @@ function normalizeInbox(msg, options) {
|
|
|
113
113
|
trace: { id: msg.hub_msg_id, streamable },
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Normalize a group of InboxMessages for the same `(room, topic)` into a
|
|
118
|
+
* single `GatewayInboundMessage`. The envelope carries the latest msg's
|
|
119
|
+
* metadata (routing, session key, trace) and a `raw.batch` array the
|
|
120
|
+
* composer uses to render per-sender blocks.
|
|
121
|
+
*
|
|
122
|
+
* `mentioned` is sticky: true if ANY message in the group is a mention.
|
|
123
|
+
* Returns null if no message in the group is normalizable on its own.
|
|
124
|
+
*/
|
|
125
|
+
function normalizeInboxBatch(msgs, options) {
|
|
126
|
+
if (msgs.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
if (msgs.length === 1)
|
|
129
|
+
return normalizeInbox(msgs[0], options);
|
|
130
|
+
const latest = msgs[msgs.length - 1];
|
|
131
|
+
const base = normalizeInbox(latest, options);
|
|
132
|
+
if (!base)
|
|
133
|
+
return null;
|
|
134
|
+
// Fold sibling metadata into the base envelope. `text` is kept non-empty
|
|
135
|
+
// when at least one batched member has a body, so the dispatcher's empty-
|
|
136
|
+
// text skip rule doesn't drop the whole batch just because the latest
|
|
137
|
+
// envelope was e.g. a zero-payload contact_request.
|
|
138
|
+
const anyMentioned = msgs.some((m) => m.mentioned === true);
|
|
139
|
+
let representativeText = base.text ?? "";
|
|
140
|
+
if (!representativeText.trim()) {
|
|
141
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
142
|
+
const m = msgs[i];
|
|
143
|
+
const candidate = m.text ??
|
|
144
|
+
(typeof m.envelope?.payload?.text === "string"
|
|
145
|
+
? m.envelope.payload.text
|
|
146
|
+
: "");
|
|
147
|
+
if (candidate && candidate.trim()) {
|
|
148
|
+
representativeText = candidate;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
...base,
|
|
155
|
+
text: representativeText,
|
|
156
|
+
mentioned: anyMentioned,
|
|
157
|
+
raw: { ...latest, batch: msgs },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
116
160
|
/**
|
|
117
161
|
* Construct a BotCord channel adapter.
|
|
118
162
|
*
|
|
@@ -161,9 +205,14 @@ export function createBotCordChannel(options) {
|
|
|
161
205
|
log.info("botcord inbox drained", { count: msgs.length });
|
|
162
206
|
if (msgs.length === 0)
|
|
163
207
|
return;
|
|
208
|
+
// First pass: ack duplicates/skipped messages so Hub stops requeueing,
|
|
209
|
+
// and collect eligible messages preserving poll order. Grouping by
|
|
210
|
+
// `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
|
|
211
|
+
// same conversation thread folds into one turn so the agent sees all
|
|
212
|
+
// new messages at once instead of running N turns back-to-back.
|
|
213
|
+
const eligible = [];
|
|
164
214
|
for (const msg of msgs) {
|
|
165
215
|
if (!rememberSeen(msg.hub_msg_id)) {
|
|
166
|
-
// Already emitted; ack again so Hub stops requeueing.
|
|
167
216
|
try {
|
|
168
217
|
await client.ackMessages([msg.hub_msg_id]);
|
|
169
218
|
}
|
|
@@ -177,7 +226,6 @@ export function createBotCordChannel(options) {
|
|
|
177
226
|
accountId: options.accountId,
|
|
178
227
|
});
|
|
179
228
|
if (!normalized) {
|
|
180
|
-
// Not eligible (wrong type, missing room, etc.) — ack so it drops.
|
|
181
229
|
try {
|
|
182
230
|
await client.ackMessages([msg.hub_msg_id]);
|
|
183
231
|
}
|
|
@@ -186,16 +234,42 @@ export function createBotCordChannel(options) {
|
|
|
186
234
|
}
|
|
187
235
|
continue;
|
|
188
236
|
}
|
|
237
|
+
eligible.push(msg);
|
|
238
|
+
}
|
|
239
|
+
if (eligible.length === 0)
|
|
240
|
+
return;
|
|
241
|
+
// Group by `(room_id, topic)`. Insertion order is the poll order, so
|
|
242
|
+
// iterating the map yields groups with the same external chronology.
|
|
243
|
+
const groups = new Map();
|
|
244
|
+
for (const msg of eligible) {
|
|
245
|
+
const topic = msg.topic_id ?? msg.topic ?? "";
|
|
246
|
+
const key = `${msg.room_id ?? ""}:${topic}`;
|
|
247
|
+
const list = groups.get(key);
|
|
248
|
+
if (list)
|
|
249
|
+
list.push(msg);
|
|
250
|
+
else
|
|
251
|
+
groups.set(key, [msg]);
|
|
252
|
+
}
|
|
253
|
+
for (const group of groups.values()) {
|
|
254
|
+
const normalized = normalizeInboxBatch(group, {
|
|
255
|
+
channelId: options.id,
|
|
256
|
+
accountId: options.accountId,
|
|
257
|
+
});
|
|
258
|
+
if (!normalized)
|
|
259
|
+
continue;
|
|
260
|
+
const hubIds = group.map((m) => m.hub_msg_id);
|
|
189
261
|
const envelope = {
|
|
190
262
|
message: normalized,
|
|
191
263
|
ack: {
|
|
192
264
|
accept: async () => {
|
|
193
265
|
try {
|
|
194
|
-
|
|
266
|
+
// Ack the entire batch together so Hub never re-delivers any
|
|
267
|
+
// member of this turn if the agent succeeds on the group.
|
|
268
|
+
await client.ackMessages(hubIds);
|
|
195
269
|
}
|
|
196
270
|
catch (err) {
|
|
197
271
|
log.warn("botcord ack failed — relying on seen-cache dedup", {
|
|
198
|
-
|
|
272
|
+
hubMsgIds: hubIds,
|
|
199
273
|
err: String(err),
|
|
200
274
|
});
|
|
201
275
|
}
|
|
@@ -207,7 +281,7 @@ export function createBotCordChannel(options) {
|
|
|
207
281
|
}
|
|
208
282
|
catch (err) {
|
|
209
283
|
log.error("botcord emit threw", {
|
|
210
|
-
|
|
284
|
+
hubMsgIds: hubIds,
|
|
211
285
|
err: String(err),
|
|
212
286
|
});
|
|
213
287
|
}
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -3,8 +3,13 @@ import type { GatewayConfig, GatewayInboundMessage, GatewayRoute, RouteMatch } f
|
|
|
3
3
|
export declare function matchesRoute(message: GatewayInboundMessage, match: RouteMatch | undefined): boolean;
|
|
4
4
|
/**
|
|
5
5
|
* Picks the first matching route in priority order:
|
|
6
|
-
* 1. `config.routes[]`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 1. `config.routes[]` entries whose `match.accountId` names this message's
|
|
7
|
+
* accountId — explicit operator override for a specific agent.
|
|
8
|
+
* 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
|
|
9
|
+
* the user picked when provisioning the agent). Broad user routes do
|
|
10
|
+
* NOT clobber this, because the agent's runtime is itself an explicit
|
|
11
|
+
* user choice — a catch-all prefix rule shouldn't silently downgrade it.
|
|
12
|
+
* 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
|
|
13
|
+
* 4. `config.defaultRoute`.
|
|
9
14
|
*/
|
|
10
15
|
export declare function resolveRoute(message: GatewayInboundMessage, config: Pick<GatewayConfig, "defaultRoute" | "routes">, managedRoutes?: readonly GatewayRoute[]): GatewayRoute;
|
package/dist/gateway/router.js
CHANGED
|
@@ -28,15 +28,21 @@ export function matchesRoute(message, match) {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Picks the first matching route in priority order:
|
|
31
|
-
* 1. `config.routes[]`
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* 1. `config.routes[]` entries whose `match.accountId` names this message's
|
|
32
|
+
* accountId — explicit operator override for a specific agent.
|
|
33
|
+
* 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
|
|
34
|
+
* the user picked when provisioning the agent). Broad user routes do
|
|
35
|
+
* NOT clobber this, because the agent's runtime is itself an explicit
|
|
36
|
+
* user choice — a catch-all prefix rule shouldn't silently downgrade it.
|
|
37
|
+
* 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
|
|
38
|
+
* 4. `config.defaultRoute`.
|
|
34
39
|
*/
|
|
35
40
|
export function resolveRoute(message, config, managedRoutes) {
|
|
36
41
|
const routes = config.routes ?? [];
|
|
37
42
|
for (const route of routes) {
|
|
38
|
-
if (matchesRoute(message, route.match))
|
|
43
|
+
if (route.match?.accountId === message.accountId && matchesRoute(message, route.match)) {
|
|
39
44
|
return route;
|
|
45
|
+
}
|
|
40
46
|
}
|
|
41
47
|
if (managedRoutes) {
|
|
42
48
|
for (const route of managedRoutes) {
|
|
@@ -44,5 +50,9 @@ export function resolveRoute(message, config, managedRoutes) {
|
|
|
44
50
|
return route;
|
|
45
51
|
}
|
|
46
52
|
}
|
|
53
|
+
for (const route of routes) {
|
|
54
|
+
if (matchesRoute(message, route.match))
|
|
55
|
+
return route;
|
|
56
|
+
}
|
|
47
57
|
return config.defaultRoute;
|
|
48
58
|
}
|
package/dist/turn-text.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sanitizeSenderName } from "./gateway/index.js";
|
|
1
|
+
import { sanitizeSenderName, sanitizeUntrustedContent } from "./gateway/index.js";
|
|
2
2
|
import { classifyActivitySender } from "./sender-classify.js";
|
|
3
3
|
const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly mentioned or addressed. ' +
|
|
4
4
|
'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
|
|
@@ -18,6 +18,39 @@ function readEnvelopeType(raw) {
|
|
|
18
18
|
const t = env.type;
|
|
19
19
|
return typeof t === "string" ? t : undefined;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Read the `raw.batch` array emitted by the BotCord channel when inbox
|
|
23
|
+
* drain groups multiple messages for the same `(room, topic)`. Returns the
|
|
24
|
+
* list when present and well-shaped, else null. Single-message envelopes
|
|
25
|
+
* have no `batch` field and fall through to the single-message path.
|
|
26
|
+
*/
|
|
27
|
+
function readBatch(raw) {
|
|
28
|
+
if (!raw || typeof raw !== "object")
|
|
29
|
+
return null;
|
|
30
|
+
const b = raw.batch;
|
|
31
|
+
if (!Array.isArray(b) || b.length < 2)
|
|
32
|
+
return null;
|
|
33
|
+
return b;
|
|
34
|
+
}
|
|
35
|
+
function entryFromLabel(e) {
|
|
36
|
+
const envType = typeof e.envelope?.type === "string" ? e.envelope.type : undefined;
|
|
37
|
+
const isHuman = e.source_type === "dashboard_human_room" ||
|
|
38
|
+
(typeof e.envelope?.from === "string" && e.envelope.from.startsWith("hu_"));
|
|
39
|
+
const fromId = typeof e.envelope?.from === "string" ? e.envelope.from : "unknown";
|
|
40
|
+
const label = isHuman
|
|
41
|
+
? typeof e.source_user_name === "string" && e.source_user_name
|
|
42
|
+
? e.source_user_name
|
|
43
|
+
: "User"
|
|
44
|
+
: fromId;
|
|
45
|
+
return { label, kind: isHuman ? "human" : "agent", envelopeType: envType };
|
|
46
|
+
}
|
|
47
|
+
function entryText(e) {
|
|
48
|
+
if (typeof e.text === "string")
|
|
49
|
+
return e.text;
|
|
50
|
+
if (typeof e.envelope?.payload?.text === "string")
|
|
51
|
+
return e.envelope.payload.text;
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
21
54
|
/**
|
|
22
55
|
* Compose the user-turn text for a BotCord inbound message.
|
|
23
56
|
*
|
|
@@ -37,6 +70,10 @@ export function composeBotCordUserTurn(msg) {
|
|
|
37
70
|
// system-context handles context; wrapping here would just add noise.
|
|
38
71
|
if (sender.kind === "owner")
|
|
39
72
|
return trimmed;
|
|
73
|
+
const batch = readBatch(msg.raw);
|
|
74
|
+
if (batch) {
|
|
75
|
+
return composeBatchedTurn(msg, batch);
|
|
76
|
+
}
|
|
40
77
|
const conversation = msg.conversation;
|
|
41
78
|
const isGroup = conversation.kind === "group";
|
|
42
79
|
const roomTitle = typeof conversation.title === "string" ? conversation.title : undefined;
|
|
@@ -85,3 +122,53 @@ export function composeBotCordUserTurn(msg) {
|
|
|
85
122
|
}
|
|
86
123
|
return lines.join("\n");
|
|
87
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Render a batched turn (≥2 messages from the same room/topic folded into
|
|
127
|
+
* one envelope by `botcord.ts:normalizeInboxBatch`). Mirrors plugin's
|
|
128
|
+
* `handleA2AGroup` output shape so Claude Code sees the same prompt
|
|
129
|
+
* whether driven by OpenClaw or by daemon.
|
|
130
|
+
*/
|
|
131
|
+
function composeBatchedTurn(msg, batch) {
|
|
132
|
+
const conversation = msg.conversation;
|
|
133
|
+
const isGroup = conversation.kind === "group";
|
|
134
|
+
const roomTitle = typeof conversation.title === "string" ? conversation.title : undefined;
|
|
135
|
+
const header = [
|
|
136
|
+
`[BotCord Messages (${batch.length} new)]`,
|
|
137
|
+
`to: ${msg.accountId}`,
|
|
138
|
+
];
|
|
139
|
+
if (isGroup && roomTitle) {
|
|
140
|
+
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
141
|
+
header.push(`room: ${safeRoom}`);
|
|
142
|
+
}
|
|
143
|
+
if (msg.mentioned) {
|
|
144
|
+
header.push("mentioned: true");
|
|
145
|
+
}
|
|
146
|
+
const blocks = [];
|
|
147
|
+
const contactRequestSenders = [];
|
|
148
|
+
for (const entry of batch) {
|
|
149
|
+
const { label, kind, envelopeType } = entryFromLabel(entry);
|
|
150
|
+
const safeLabel = sanitizeSenderName(label);
|
|
151
|
+
const raw = entryText(entry);
|
|
152
|
+
// Owner-trust bypass is handled at the outer level — by the time we
|
|
153
|
+
// reach a batched turn the sender classifier has already returned
|
|
154
|
+
// non-owner. Still sanitize defensively.
|
|
155
|
+
const safeBody = sanitizeUntrustedContent(raw);
|
|
156
|
+
const tag = kind === "human" ? "human-message" : "agent-message";
|
|
157
|
+
blocks.push(`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`);
|
|
158
|
+
if (envelopeType === "contact_request") {
|
|
159
|
+
contactRequestSenders.push(safeLabel);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
|
|
163
|
+
const lines = [header.join(" | "), blocks.join("\n"), "", hint];
|
|
164
|
+
if (contactRequestSenders.length > 0) {
|
|
165
|
+
// Dedup + list — multiple distinct senders show as "A, B".
|
|
166
|
+
const unique = Array.from(new Set(contactRequestSenders));
|
|
167
|
+
lines.push("", "[You received a contact request from " +
|
|
168
|
+
unique.join(", ") +
|
|
169
|
+
". Use the botcord_notify tool to inform your owner about this request so " +
|
|
170
|
+
"they can decide whether to accept or reject it. Include the sender's " +
|
|
171
|
+
"agent ID and any message they attached.]");
|
|
172
|
+
}
|
|
173
|
+
return lines.join("\n");
|
|
174
|
+
}
|
package/package.json
CHANGED
|
@@ -131,6 +131,117 @@ describe("composeBotCordUserTurn", () => {
|
|
|
131
131
|
expect(out).not.toContain("contact request from");
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
it("renders a multi-message batch as [BotCord Messages (N new)] with one block per sender", () => {
|
|
135
|
+
const batch = [
|
|
136
|
+
{
|
|
137
|
+
hub_msg_id: "m1",
|
|
138
|
+
text: "first message",
|
|
139
|
+
envelope: { from: "ag_alice", type: "message" },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
hub_msg_id: "m2",
|
|
143
|
+
text: "second message",
|
|
144
|
+
envelope: { from: "ag_bob", type: "message" },
|
|
145
|
+
mentioned: true,
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
const out = composeBotCordUserTurn(
|
|
149
|
+
makeMessage({
|
|
150
|
+
text: "second message",
|
|
151
|
+
sender: { id: "ag_bob", kind: "agent" },
|
|
152
|
+
conversation: { id: "rm_team", kind: "group", title: "Ouraca" },
|
|
153
|
+
mentioned: true,
|
|
154
|
+
raw: { batch, envelope: { type: "message", from: "ag_bob" } },
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
expect(out).toContain("[BotCord Messages (2 new)]");
|
|
158
|
+
expect(out).toContain("room: Ouraca");
|
|
159
|
+
expect(out).toContain("mentioned: true");
|
|
160
|
+
expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
|
|
161
|
+
expect(out).toContain("first message");
|
|
162
|
+
expect(out).toContain('<agent-message sender="ag_bob" sender_kind="agent">');
|
|
163
|
+
expect(out).toContain("second message");
|
|
164
|
+
// Single-message header must NOT appear in batch mode.
|
|
165
|
+
expect(out).not.toContain("[BotCord Message]");
|
|
166
|
+
// Group hint still appears after the blocks.
|
|
167
|
+
expect(out).toContain("do NOT reply unless");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("batched path tags dashboard_human_room senders as human-message", () => {
|
|
171
|
+
const batch = [
|
|
172
|
+
{
|
|
173
|
+
hub_msg_id: "m1",
|
|
174
|
+
text: "hi bot",
|
|
175
|
+
envelope: { from: "ag_me", type: "message" },
|
|
176
|
+
source_type: "dashboard_human_room",
|
|
177
|
+
source_user_name: "Alice",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
hub_msg_id: "m2",
|
|
181
|
+
text: "你好",
|
|
182
|
+
envelope: { from: "ag_peer", type: "message" },
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
const out = composeBotCordUserTurn(
|
|
186
|
+
makeMessage({
|
|
187
|
+
text: "你好",
|
|
188
|
+
sender: { id: "ag_peer", kind: "agent" },
|
|
189
|
+
conversation: { id: "rm_team", kind: "group" },
|
|
190
|
+
raw: { batch, envelope: { type: "message", from: "ag_peer" } },
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
expect(out).toContain('<human-message sender="Alice" sender_kind="human">');
|
|
194
|
+
expect(out).toContain("hi bot");
|
|
195
|
+
expect(out).toContain('<agent-message sender="ag_peer" sender_kind="agent">');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("batched path appends a single notify-owner hint listing every contact_request sender", () => {
|
|
199
|
+
const batch = [
|
|
200
|
+
{
|
|
201
|
+
hub_msg_id: "m1",
|
|
202
|
+
text: "please add me",
|
|
203
|
+
envelope: { from: "ag_stranger_a", type: "contact_request" },
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
hub_msg_id: "m2",
|
|
207
|
+
text: "add me too",
|
|
208
|
+
envelope: { from: "ag_stranger_b", type: "contact_request" },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
hub_msg_id: "m3",
|
|
212
|
+
text: "normal reply",
|
|
213
|
+
envelope: { from: "ag_old_friend", type: "message" },
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
const out = composeBotCordUserTurn(
|
|
217
|
+
makeMessage({
|
|
218
|
+
text: "normal reply",
|
|
219
|
+
sender: { id: "ag_old_friend", kind: "agent" },
|
|
220
|
+
conversation: { id: "rm_dm_x", kind: "direct" },
|
|
221
|
+
raw: { batch, envelope: { type: "message", from: "ag_old_friend" } },
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
expect(out).toContain("contact request from ag_stranger_a, ag_stranger_b");
|
|
225
|
+
// Direct hint (not group) for a DM room.
|
|
226
|
+
expect(out).toContain("naturally concluded");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("falls back to the single-message path when raw.batch has only one entry", () => {
|
|
230
|
+
const out = composeBotCordUserTurn(
|
|
231
|
+
makeMessage({
|
|
232
|
+
raw: {
|
|
233
|
+
batch: [
|
|
234
|
+
{ hub_msg_id: "m1", text: "solo", envelope: { from: "ag_x", type: "message" } },
|
|
235
|
+
],
|
|
236
|
+
envelope: { type: "message", from: "ag_x" },
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
// batch length 1 → readBatch returns null → single-message header.
|
|
241
|
+
expect(out).toContain("[BotCord Message]");
|
|
242
|
+
expect(out).not.toContain("[BotCord Messages (");
|
|
243
|
+
});
|
|
244
|
+
|
|
134
245
|
it("sanitizes room names so newline-based injection can't reshape the header", () => {
|
|
135
246
|
const out = composeBotCordUserTurn(
|
|
136
247
|
makeMessage({
|
package/src/daemon-config-map.ts
CHANGED
|
@@ -150,9 +150,10 @@ export function toGatewayConfig(
|
|
|
150
150
|
|
|
151
151
|
// Synthesize a per-agent route for every bound agent and hand it to the
|
|
152
152
|
// gateway via the managed-routes bucket (plan §10.1). User-authored
|
|
153
|
-
// `cfg.routes[]` stay untouched
|
|
154
|
-
//
|
|
155
|
-
// defaultRoute
|
|
153
|
+
// `cfg.routes[]` stay untouched. Match priority (see router.ts):
|
|
154
|
+
// `routes[] with explicit accountId → managedRoutes → other routes[] →
|
|
155
|
+
// defaultRoute`. Broad prefix/kind rules no longer clobber the agent's
|
|
156
|
+
// chosen runtime — only routes that name the agent by `accountId` do.
|
|
156
157
|
const managedMap = buildManagedRoutes(
|
|
157
158
|
agentIds,
|
|
158
159
|
opts.agentRuntimes ?? {},
|
|
@@ -281,6 +281,69 @@ describe("createBotCordChannel — inbox normalization", () => {
|
|
|
281
281
|
}
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
it("groups two messages in the same room/topic into one batched envelope", async () => {
|
|
285
|
+
const server = await startAuthOkServer();
|
|
286
|
+
try {
|
|
287
|
+
const polled = [
|
|
288
|
+
makeInbox({
|
|
289
|
+
hub_msg_id: "m_b1",
|
|
290
|
+
room_id: "rm_team",
|
|
291
|
+
room_name: "Team",
|
|
292
|
+
text: "hi all",
|
|
293
|
+
envelope: { from: "ag_alice" } as InboxMessage["envelope"],
|
|
294
|
+
}),
|
|
295
|
+
makeInbox({
|
|
296
|
+
hub_msg_id: "m_b2",
|
|
297
|
+
room_id: "rm_team",
|
|
298
|
+
room_name: "Team",
|
|
299
|
+
text: "yeah",
|
|
300
|
+
envelope: { from: "ag_bob" } as InboxMessage["envelope"],
|
|
301
|
+
mentioned: true,
|
|
302
|
+
}),
|
|
303
|
+
];
|
|
304
|
+
const client = makeClient({
|
|
305
|
+
pollInbox: vi.fn().mockResolvedValue({ messages: polled, count: 2, has_more: false }),
|
|
306
|
+
getHubUrl: vi.fn().mockReturnValue(server.url),
|
|
307
|
+
});
|
|
308
|
+
const channel = createBotCordChannel({
|
|
309
|
+
id: "botcord-main",
|
|
310
|
+
accountId: "ag_self",
|
|
311
|
+
agentId: "ag_self",
|
|
312
|
+
client,
|
|
313
|
+
hubBaseUrl: server.url,
|
|
314
|
+
});
|
|
315
|
+
const abort = new AbortController();
|
|
316
|
+
const emits: GatewayInboundEnvelope[] = [];
|
|
317
|
+
const startP = channel.start({
|
|
318
|
+
config: stubConfig,
|
|
319
|
+
accountId: "ag_self",
|
|
320
|
+
abortSignal: abort.signal,
|
|
321
|
+
log: silentLog,
|
|
322
|
+
emit: async (env) => {
|
|
323
|
+
emits.push(env);
|
|
324
|
+
},
|
|
325
|
+
setStatus: () => {},
|
|
326
|
+
});
|
|
327
|
+
await vi.waitFor(() => expect(emits).toHaveLength(1));
|
|
328
|
+
const env = emits[0]!.message;
|
|
329
|
+
// Last sender wins for representative metadata; mentioned is sticky.
|
|
330
|
+
expect(env.sender.id).toBe("ag_bob");
|
|
331
|
+
expect(env.mentioned).toBe(true);
|
|
332
|
+
const raw = env.raw as { batch?: Array<{ hub_msg_id: string }> };
|
|
333
|
+
expect(Array.isArray(raw.batch)).toBe(true);
|
|
334
|
+
expect(raw.batch!.map((m) => m.hub_msg_id)).toEqual(["m_b1", "m_b2"]);
|
|
335
|
+
|
|
336
|
+
// One accept() call acks BOTH hub ids together.
|
|
337
|
+
await emits[0]!.ack!.accept();
|
|
338
|
+
expect(client.ackMessages).toHaveBeenCalledWith(["m_b1", "m_b2"]);
|
|
339
|
+
|
|
340
|
+
abort.abort();
|
|
341
|
+
await startP;
|
|
342
|
+
} finally {
|
|
343
|
+
await server.close();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
284
347
|
it("sanitizes prompt-injection markers in untrusted text but not in owner-chat", async () => {
|
|
285
348
|
const { emits, server } = await startWithInbox([
|
|
286
349
|
makeInbox({
|
|
@@ -78,13 +78,39 @@ describe("resolveRoute", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
describe("managedRoutes", () => {
|
|
81
|
-
it("user
|
|
81
|
+
it("user route with explicit accountId wins over managed for same agent", () => {
|
|
82
82
|
const user = makeRoute({ runtime: "user", match: { accountId: "ag_1" } });
|
|
83
83
|
const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_1" } });
|
|
84
84
|
const msg = makeMessage({ accountId: "ag_1" });
|
|
85
85
|
expect(resolveRoute(msg, { defaultRoute, routes: [user] }, [managed])).toBe(user);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
it("broad user route (no accountId) does NOT override managed per-agent route", () => {
|
|
89
|
+
const broad = makeRoute({
|
|
90
|
+
runtime: "broad",
|
|
91
|
+
match: { conversationPrefix: "rm_oc_" },
|
|
92
|
+
});
|
|
93
|
+
const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_1" } });
|
|
94
|
+
const msg = makeMessage({
|
|
95
|
+
accountId: "ag_1",
|
|
96
|
+
conversation: { id: "rm_oc_abc", kind: "group" },
|
|
97
|
+
});
|
|
98
|
+
expect(resolveRoute(msg, { defaultRoute, routes: [broad] }, [managed])).toBe(managed);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("broad user route applies when no managed route matches the agent", () => {
|
|
102
|
+
const broad = makeRoute({
|
|
103
|
+
runtime: "broad",
|
|
104
|
+
match: { conversationPrefix: "rm_oc_" },
|
|
105
|
+
});
|
|
106
|
+
const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_other" } });
|
|
107
|
+
const msg = makeMessage({
|
|
108
|
+
accountId: "ag_1",
|
|
109
|
+
conversation: { id: "rm_oc_abc", kind: "group" },
|
|
110
|
+
});
|
|
111
|
+
expect(resolveRoute(msg, { defaultRoute, routes: [broad] }, [managed])).toBe(broad);
|
|
112
|
+
});
|
|
113
|
+
|
|
88
114
|
it("no user match + managed match → managed wins", () => {
|
|
89
115
|
const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_1" } });
|
|
90
116
|
const msg = makeMessage({ accountId: "ag_1" });
|
|
@@ -196,6 +196,66 @@ function normalizeInbox(
|
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Shape of the `raw` field when the channel batches multiple messages into
|
|
201
|
+
* one envelope. Keeps the latest message's InboxMessage fields at top level
|
|
202
|
+
* so existing accesses (`raw.envelope.type`, `raw.source_type`, …) still
|
|
203
|
+
* work, and exposes the full list via `raw.batch`. `composeBotCordUserTurn`
|
|
204
|
+
* reads `raw.batch` to build one `<agent-message>` / `<human-message>` block
|
|
205
|
+
* per entry.
|
|
206
|
+
*/
|
|
207
|
+
export interface BatchedInboxRaw extends InboxMessage {
|
|
208
|
+
batch: InboxMessage[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Normalize a group of InboxMessages for the same `(room, topic)` into a
|
|
213
|
+
* single `GatewayInboundMessage`. The envelope carries the latest msg's
|
|
214
|
+
* metadata (routing, session key, trace) and a `raw.batch` array the
|
|
215
|
+
* composer uses to render per-sender blocks.
|
|
216
|
+
*
|
|
217
|
+
* `mentioned` is sticky: true if ANY message in the group is a mention.
|
|
218
|
+
* Returns null if no message in the group is normalizable on its own.
|
|
219
|
+
*/
|
|
220
|
+
function normalizeInboxBatch(
|
|
221
|
+
msgs: InboxMessage[],
|
|
222
|
+
options: { channelId: string; accountId: string },
|
|
223
|
+
): GatewayInboundMessage | null {
|
|
224
|
+
if (msgs.length === 0) return null;
|
|
225
|
+
if (msgs.length === 1) return normalizeInbox(msgs[0]!, options);
|
|
226
|
+
|
|
227
|
+
const latest = msgs[msgs.length - 1]!;
|
|
228
|
+
const base = normalizeInbox(latest, options);
|
|
229
|
+
if (!base) return null;
|
|
230
|
+
|
|
231
|
+
// Fold sibling metadata into the base envelope. `text` is kept non-empty
|
|
232
|
+
// when at least one batched member has a body, so the dispatcher's empty-
|
|
233
|
+
// text skip rule doesn't drop the whole batch just because the latest
|
|
234
|
+
// envelope was e.g. a zero-payload contact_request.
|
|
235
|
+
const anyMentioned = msgs.some((m) => m.mentioned === true);
|
|
236
|
+
let representativeText = base.text ?? "";
|
|
237
|
+
if (!representativeText.trim()) {
|
|
238
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
239
|
+
const m = msgs[i]!;
|
|
240
|
+
const candidate =
|
|
241
|
+
m.text ??
|
|
242
|
+
(typeof m.envelope?.payload?.text === "string"
|
|
243
|
+
? (m.envelope.payload.text as string)
|
|
244
|
+
: "");
|
|
245
|
+
if (candidate && candidate.trim()) {
|
|
246
|
+
representativeText = candidate;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
...base,
|
|
253
|
+
text: representativeText,
|
|
254
|
+
mentioned: anyMentioned,
|
|
255
|
+
raw: { ...latest, batch: msgs } satisfies BatchedInboxRaw,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
199
259
|
/**
|
|
200
260
|
* Construct a BotCord channel adapter.
|
|
201
261
|
*
|
|
@@ -250,9 +310,14 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
250
310
|
log.info("botcord inbox drained", { count: msgs.length });
|
|
251
311
|
if (msgs.length === 0) return;
|
|
252
312
|
|
|
313
|
+
// First pass: ack duplicates/skipped messages so Hub stops requeueing,
|
|
314
|
+
// and collect eligible messages preserving poll order. Grouping by
|
|
315
|
+
// `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
|
|
316
|
+
// same conversation thread folds into one turn so the agent sees all
|
|
317
|
+
// new messages at once instead of running N turns back-to-back.
|
|
318
|
+
const eligible: InboxMessage[] = [];
|
|
253
319
|
for (const msg of msgs) {
|
|
254
320
|
if (!rememberSeen(msg.hub_msg_id)) {
|
|
255
|
-
// Already emitted; ack again so Hub stops requeueing.
|
|
256
321
|
try {
|
|
257
322
|
await client.ackMessages([msg.hub_msg_id]);
|
|
258
323
|
} catch (err) {
|
|
@@ -265,7 +330,6 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
265
330
|
accountId: options.accountId,
|
|
266
331
|
});
|
|
267
332
|
if (!normalized) {
|
|
268
|
-
// Not eligible (wrong type, missing room, etc.) — ack so it drops.
|
|
269
333
|
try {
|
|
270
334
|
await client.ackMessages([msg.hub_msg_id]);
|
|
271
335
|
} catch (err) {
|
|
@@ -273,15 +337,41 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
273
337
|
}
|
|
274
338
|
continue;
|
|
275
339
|
}
|
|
340
|
+
eligible.push(msg);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (eligible.length === 0) return;
|
|
344
|
+
|
|
345
|
+
// Group by `(room_id, topic)`. Insertion order is the poll order, so
|
|
346
|
+
// iterating the map yields groups with the same external chronology.
|
|
347
|
+
const groups = new Map<string, InboxMessage[]>();
|
|
348
|
+
for (const msg of eligible) {
|
|
349
|
+
const topic = msg.topic_id ?? msg.topic ?? "";
|
|
350
|
+
const key = `${msg.room_id ?? ""}:${topic}`;
|
|
351
|
+
const list = groups.get(key);
|
|
352
|
+
if (list) list.push(msg);
|
|
353
|
+
else groups.set(key, [msg]);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const group of groups.values()) {
|
|
357
|
+
const normalized = normalizeInboxBatch(group, {
|
|
358
|
+
channelId: options.id,
|
|
359
|
+
accountId: options.accountId,
|
|
360
|
+
});
|
|
361
|
+
if (!normalized) continue;
|
|
362
|
+
|
|
363
|
+
const hubIds = group.map((m) => m.hub_msg_id);
|
|
276
364
|
const envelope: GatewayInboundEnvelope = {
|
|
277
365
|
message: normalized,
|
|
278
366
|
ack: {
|
|
279
367
|
accept: async () => {
|
|
280
368
|
try {
|
|
281
|
-
|
|
369
|
+
// Ack the entire batch together so Hub never re-delivers any
|
|
370
|
+
// member of this turn if the agent succeeds on the group.
|
|
371
|
+
await client.ackMessages(hubIds);
|
|
282
372
|
} catch (err) {
|
|
283
373
|
log.warn("botcord ack failed — relying on seen-cache dedup", {
|
|
284
|
-
|
|
374
|
+
hubMsgIds: hubIds,
|
|
285
375
|
err: String(err),
|
|
286
376
|
});
|
|
287
377
|
}
|
|
@@ -292,7 +382,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
292
382
|
await emit(envelope);
|
|
293
383
|
} catch (err) {
|
|
294
384
|
log.error("botcord emit threw", {
|
|
295
|
-
|
|
385
|
+
hubMsgIds: hubIds,
|
|
296
386
|
err: String(err),
|
|
297
387
|
});
|
|
298
388
|
}
|
package/src/gateway/router.ts
CHANGED
|
@@ -38,9 +38,14 @@ export function matchesRoute(
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Picks the first matching route in priority order:
|
|
41
|
-
* 1. `config.routes[]`
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* 1. `config.routes[]` entries whose `match.accountId` names this message's
|
|
42
|
+
* accountId — explicit operator override for a specific agent.
|
|
43
|
+
* 2. `managedRoutes` (daemon-synthesized per-agent, reflects the runtime
|
|
44
|
+
* the user picked when provisioning the agent). Broad user routes do
|
|
45
|
+
* NOT clobber this, because the agent's runtime is itself an explicit
|
|
46
|
+
* user choice — a catch-all prefix rule shouldn't silently downgrade it.
|
|
47
|
+
* 3. Remaining `config.routes[]` (broad prefix/kind/channel rules).
|
|
48
|
+
* 4. `config.defaultRoute`.
|
|
44
49
|
*/
|
|
45
50
|
export function resolveRoute(
|
|
46
51
|
message: GatewayInboundMessage,
|
|
@@ -48,13 +53,22 @@ export function resolveRoute(
|
|
|
48
53
|
managedRoutes?: readonly GatewayRoute[],
|
|
49
54
|
): GatewayRoute {
|
|
50
55
|
const routes = config.routes ?? [];
|
|
56
|
+
|
|
51
57
|
for (const route of routes) {
|
|
52
|
-
if (matchesRoute(message, route.match))
|
|
58
|
+
if (route.match?.accountId === message.accountId && matchesRoute(message, route.match)) {
|
|
59
|
+
return route;
|
|
60
|
+
}
|
|
53
61
|
}
|
|
62
|
+
|
|
54
63
|
if (managedRoutes) {
|
|
55
64
|
for (const route of managedRoutes) {
|
|
56
65
|
if (matchesRoute(message, route.match)) return route;
|
|
57
66
|
}
|
|
58
67
|
}
|
|
68
|
+
|
|
69
|
+
for (const route of routes) {
|
|
70
|
+
if (matchesRoute(message, route.match)) return route;
|
|
71
|
+
}
|
|
72
|
+
|
|
59
73
|
return config.defaultRoute;
|
|
60
74
|
}
|
package/src/turn-text.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* model the context it needs.
|
|
25
25
|
*/
|
|
26
26
|
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
27
|
-
import { sanitizeSenderName } from "./gateway/index.js";
|
|
27
|
+
import { sanitizeSenderName, sanitizeUntrustedContent } from "./gateway/index.js";
|
|
28
28
|
import { classifyActivitySender } from "./sender-classify.js";
|
|
29
29
|
|
|
30
30
|
const GROUP_HINT =
|
|
@@ -47,6 +47,55 @@ function readEnvelopeType(raw: unknown): string | undefined {
|
|
|
47
47
|
return typeof t === "string" ? t : undefined;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Minimal shape of one batched inbound entry. Matches the BotCord channel
|
|
51
|
+
* `BatchedInboxRaw.batch[]` elements but expressed structurally so the
|
|
52
|
+
* composer doesn't import channel internals. */
|
|
53
|
+
interface BatchedEntry {
|
|
54
|
+
hub_msg_id?: unknown;
|
|
55
|
+
text?: unknown;
|
|
56
|
+
envelope?: { from?: unknown; type?: unknown; payload?: { text?: unknown } };
|
|
57
|
+
source_type?: unknown;
|
|
58
|
+
source_user_name?: unknown;
|
|
59
|
+
mentioned?: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read the `raw.batch` array emitted by the BotCord channel when inbox
|
|
64
|
+
* drain groups multiple messages for the same `(room, topic)`. Returns the
|
|
65
|
+
* list when present and well-shaped, else null. Single-message envelopes
|
|
66
|
+
* have no `batch` field and fall through to the single-message path.
|
|
67
|
+
*/
|
|
68
|
+
function readBatch(raw: unknown): BatchedEntry[] | null {
|
|
69
|
+
if (!raw || typeof raw !== "object") return null;
|
|
70
|
+
const b = (raw as { batch?: unknown }).batch;
|
|
71
|
+
if (!Array.isArray(b) || b.length < 2) return null;
|
|
72
|
+
return b as BatchedEntry[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function entryFromLabel(e: BatchedEntry): {
|
|
76
|
+
label: string;
|
|
77
|
+
kind: "human" | "agent";
|
|
78
|
+
envelopeType: string | undefined;
|
|
79
|
+
} {
|
|
80
|
+
const envType = typeof e.envelope?.type === "string" ? e.envelope.type : undefined;
|
|
81
|
+
const isHuman =
|
|
82
|
+
e.source_type === "dashboard_human_room" ||
|
|
83
|
+
(typeof e.envelope?.from === "string" && e.envelope.from.startsWith("hu_"));
|
|
84
|
+
const fromId = typeof e.envelope?.from === "string" ? e.envelope.from : "unknown";
|
|
85
|
+
const label = isHuman
|
|
86
|
+
? typeof e.source_user_name === "string" && e.source_user_name
|
|
87
|
+
? e.source_user_name
|
|
88
|
+
: "User"
|
|
89
|
+
: fromId;
|
|
90
|
+
return { label, kind: isHuman ? "human" : "agent", envelopeType: envType };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function entryText(e: BatchedEntry): string {
|
|
94
|
+
if (typeof e.text === "string") return e.text;
|
|
95
|
+
if (typeof e.envelope?.payload?.text === "string") return e.envelope.payload.text;
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
|
|
50
99
|
/**
|
|
51
100
|
* Compose the user-turn text for a BotCord inbound message.
|
|
52
101
|
*
|
|
@@ -67,6 +116,11 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
|
|
|
67
116
|
// system-context handles context; wrapping here would just add noise.
|
|
68
117
|
if (sender.kind === "owner") return trimmed;
|
|
69
118
|
|
|
119
|
+
const batch = readBatch(msg.raw);
|
|
120
|
+
if (batch) {
|
|
121
|
+
return composeBatchedTurn(msg, batch);
|
|
122
|
+
}
|
|
123
|
+
|
|
70
124
|
const conversation = msg.conversation;
|
|
71
125
|
const isGroup = conversation.kind === "group";
|
|
72
126
|
const roomTitle =
|
|
@@ -121,3 +175,67 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
|
|
|
121
175
|
}
|
|
122
176
|
return lines.join("\n");
|
|
123
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Render a batched turn (≥2 messages from the same room/topic folded into
|
|
181
|
+
* one envelope by `botcord.ts:normalizeInboxBatch`). Mirrors plugin's
|
|
182
|
+
* `handleA2AGroup` output shape so Claude Code sees the same prompt
|
|
183
|
+
* whether driven by OpenClaw or by daemon.
|
|
184
|
+
*/
|
|
185
|
+
function composeBatchedTurn(
|
|
186
|
+
msg: GatewayInboundMessage,
|
|
187
|
+
batch: BatchedEntry[],
|
|
188
|
+
): string {
|
|
189
|
+
const conversation = msg.conversation;
|
|
190
|
+
const isGroup = conversation.kind === "group";
|
|
191
|
+
const roomTitle =
|
|
192
|
+
typeof conversation.title === "string" ? conversation.title : undefined;
|
|
193
|
+
|
|
194
|
+
const header: string[] = [
|
|
195
|
+
`[BotCord Messages (${batch.length} new)]`,
|
|
196
|
+
`to: ${msg.accountId}`,
|
|
197
|
+
];
|
|
198
|
+
if (isGroup && roomTitle) {
|
|
199
|
+
const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
|
|
200
|
+
header.push(`room: ${safeRoom}`);
|
|
201
|
+
}
|
|
202
|
+
if (msg.mentioned) {
|
|
203
|
+
header.push("mentioned: true");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const blocks: string[] = [];
|
|
207
|
+
const contactRequestSenders: string[] = [];
|
|
208
|
+
for (const entry of batch) {
|
|
209
|
+
const { label, kind, envelopeType } = entryFromLabel(entry);
|
|
210
|
+
const safeLabel = sanitizeSenderName(label);
|
|
211
|
+
const raw = entryText(entry);
|
|
212
|
+
// Owner-trust bypass is handled at the outer level — by the time we
|
|
213
|
+
// reach a batched turn the sender classifier has already returned
|
|
214
|
+
// non-owner. Still sanitize defensively.
|
|
215
|
+
const safeBody = sanitizeUntrustedContent(raw);
|
|
216
|
+
const tag = kind === "human" ? "human-message" : "agent-message";
|
|
217
|
+
blocks.push(
|
|
218
|
+
`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`,
|
|
219
|
+
);
|
|
220
|
+
if (envelopeType === "contact_request") {
|
|
221
|
+
contactRequestSenders.push(safeLabel);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
|
|
226
|
+
const lines: string[] = [header.join(" | "), blocks.join("\n"), "", hint];
|
|
227
|
+
|
|
228
|
+
if (contactRequestSenders.length > 0) {
|
|
229
|
+
// Dedup + list — multiple distinct senders show as "A, B".
|
|
230
|
+
const unique = Array.from(new Set(contactRequestSenders));
|
|
231
|
+
lines.push(
|
|
232
|
+
"",
|
|
233
|
+
"[You received a contact request from " +
|
|
234
|
+
unique.join(", ") +
|
|
235
|
+
". Use the botcord_notify tool to inform your owner about this request so " +
|
|
236
|
+
"they can decide whether to accept or reject it. Include the sender's " +
|
|
237
|
+
"agent ID and any message they attached.]",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|