@botcord/daemon 0.1.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/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 =
@@ -34,6 +34,68 @@ const DIRECT_HINT =
34
34
  '[If the conversation has naturally concluded or no response is needed, ' +
35
35
  'reply with exactly "NO_REPLY" and nothing else.]';
36
36
 
37
+ /**
38
+ * Read the BotCord envelope type from a raw inbound message. Returns
39
+ * `undefined` when the message didn't come from the BotCord channel or the
40
+ * raw shape is unexpected — callers treat that the same as "message".
41
+ */
42
+ function readEnvelopeType(raw: unknown): string | undefined {
43
+ if (!raw || typeof raw !== "object") return undefined;
44
+ const env = (raw as { envelope?: unknown }).envelope;
45
+ if (!env || typeof env !== "object") return undefined;
46
+ const t = (env as { type?: unknown }).type;
47
+ return typeof t === "string" ? t : undefined;
48
+ }
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
+
37
99
  /**
38
100
  * Compose the user-turn text for a BotCord inbound message.
39
101
  *
@@ -54,6 +116,11 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
54
116
  // system-context handles context; wrapping here would just add noise.
55
117
  if (sender.kind === "owner") return trimmed;
56
118
 
119
+ const batch = readBatch(msg.raw);
120
+ if (batch) {
121
+ return composeBatchedTurn(msg, batch);
122
+ }
123
+
57
124
  const conversation = msg.conversation;
58
125
  const isGroup = conversation.kind === "group";
59
126
  const roomTitle =
@@ -82,12 +149,93 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
82
149
 
83
150
  const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
84
151
 
85
- return [
152
+ // Contact-request envelopes travel through the same "inbound message"
153
+ // path as regular messages, but carry an additional expectation: the
154
+ // agent should surface the request to its owner rather than auto-accept
155
+ // or auto-reject. Mirrors `plugin/src/inbound.ts` §handleA2ASingle.
156
+ const isContactRequest = readEnvelopeType(msg.raw) === "contact_request";
157
+ const contactRequestHint = isContactRequest
158
+ ? "[You received a contact request from " +
159
+ sanitizedSenderLabel +
160
+ ". Use the botcord_notify tool to inform your owner about this request so " +
161
+ "they can decide whether to accept or reject it. Include the sender's " +
162
+ "agent ID and any message they attached.]"
163
+ : null;
164
+
165
+ const lines: string[] = [
86
166
  headerFields.join(" | "),
87
167
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
88
168
  trimmed,
89
169
  `</${tag}>`,
90
170
  "",
91
171
  hint,
92
- ].join("\n");
172
+ ];
173
+ if (contactRequestHint) {
174
+ lines.push("", contactRequestHint);
175
+ }
176
+ return lines.join("\n");
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");
93
241
  }