@clawling/clawchat-plugin-openclaw 2026.8.5-2 → 2026.8.14-1
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/src/inbound.js +7 -2
- package/dist/src/protocol-types.js +8 -0
- package/dist/src/runtime.js +29 -0
- package/dist/src/ws-client.js +52 -2
- package/package.json +1 -1
- package/prompts/default-group-bio.md +23 -14
- package/prompts/default-owner-behavior.md +26 -22
- package/src/inbound.ts +7 -1
- package/src/protocol-types.ts +9 -0
- package/src/runtime.ts +31 -1
- package/src/ws-client.ts +50 -2
package/dist/src/inbound.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EVENT, } from "./protocol-types.js";
|
|
1
|
+
import { EVENT, MENTION_ALL_USER_ID, } from "./protocol-types.js";
|
|
2
2
|
import { extractMediaFragments, fragmentsToText } from "./message-mapper.js";
|
|
3
3
|
import { hasRenderableText, isInboundMessagePayload } from "./protocol.js";
|
|
4
4
|
function normalizeSender(sender) {
|
|
@@ -73,11 +73,16 @@ function mentionedUserIds(mentions) {
|
|
|
73
73
|
/**
|
|
74
74
|
* Exported for direct unit testing. Direct chats always count as addressed;
|
|
75
75
|
* group chats require a mention unless config opts into all group messages.
|
|
76
|
+
*
|
|
77
|
+
* `MENTION_ALL_USER_ID` ("all") is the `@everyone` sentinel — it addresses
|
|
78
|
+
* every member of the room, this agent included, so it counts as a mention
|
|
79
|
+
* even though it never equals our own user id.
|
|
76
80
|
*/
|
|
77
81
|
export function detectMention(params) {
|
|
78
82
|
if (params.chatType === "direct")
|
|
79
83
|
return true;
|
|
80
|
-
|
|
84
|
+
const ids = mentionedUserIds(normalizeMentionUsers(params.mentions));
|
|
85
|
+
return ids.includes(params.userId) || ids.includes(MENTION_ALL_USER_ID);
|
|
81
86
|
}
|
|
82
87
|
export async function dispatchOpenclawClawlingInbound(params) {
|
|
83
88
|
const { envelope, account, log } = params;
|
|
@@ -22,6 +22,14 @@ export const EVENT = {
|
|
|
22
22
|
PING: "ping",
|
|
23
23
|
PONG: "pong",
|
|
24
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* The `user_id` an `@everyone` mention carries on the wire — a reserved
|
|
27
|
+
* sentinel, not a real user id. The server never expands it into one mention
|
|
28
|
+
* per member, so every reader must decide for itself that it includes them.
|
|
29
|
+
* It can appear both as a `mention` fragment and as a `context.mentions`
|
|
30
|
+
* element. See `docs/client-integration.md` §10.2.
|
|
31
|
+
*/
|
|
32
|
+
export const MENTION_ALL_USER_ID = "all";
|
|
25
33
|
export class AuthError extends Error {
|
|
26
34
|
name = "AuthError";
|
|
27
35
|
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -13,6 +13,7 @@ import { ClawlingApiError } from "./api-types.js";
|
|
|
13
13
|
import { RefreshManager } from "./refresh-manager.js";
|
|
14
14
|
import { runOpenclawClawlingLogin, } from "./login.runtime.js";
|
|
15
15
|
import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
|
|
16
|
+
import { isValidChatId } from "./ws-client.js";
|
|
16
17
|
import { dispatchOpenclawClawlingInbound } from "./inbound.js";
|
|
17
18
|
import { PendingConsentStore, cleanupTombstonedManagedSkills, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
|
|
18
19
|
import { fetchInboundMedia } from "./media-runtime.js";
|
|
@@ -321,6 +322,22 @@ function withClawChatSessionScope(cfg) {
|
|
|
321
322
|
},
|
|
322
323
|
};
|
|
323
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Synthetic envelope builder for the post-activation greeting turn.
|
|
327
|
+
*
|
|
328
|
+
* The fourth synthetic INBOUND envelope carrying a chat_id, alongside
|
|
329
|
+
* `buildPermissionResultEnvelope`, `buildAwarenessNoteEnvelope` and the
|
|
330
|
+
* reply-dispatcher's owner-direct path — and it carries the same invariant.
|
|
331
|
+
*
|
|
332
|
+
* `conversationId` MUST be a conversation idcode (`cnv_…`, `isValidChatId`),
|
|
333
|
+
* the one recorded at activation from `agents/connect`'s `conversation.id`.
|
|
334
|
+
* The agent's in-turn reply INHERITS this envelope's chat_id
|
|
335
|
+
* (src/inbound.ts → runtime.dispatch → src/reply-dispatcher.ts), and msghub
|
|
336
|
+
* resolves a chat_id only through member-backend — anything else comes back
|
|
337
|
+
* "invalid conversation id", so the greeting is refused at the outbound
|
|
338
|
+
* boundary after a full LLM turn has already been spent. Callers must check at
|
|
339
|
+
* the source and skip rather than hand an unroutable id down here.
|
|
340
|
+
*/
|
|
324
341
|
function buildActivationBootstrapEnvelope(params) {
|
|
325
342
|
const text = buildActivationBootstrapText();
|
|
326
343
|
const now = Date.now();
|
|
@@ -2845,6 +2862,18 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2845
2862
|
return;
|
|
2846
2863
|
}
|
|
2847
2864
|
const claimedBootstrap = bootstrap;
|
|
2865
|
+
// The claimed id is about to become the chat_id of a synthetic INBOUND
|
|
2866
|
+
// envelope, and the agent's in-turn reply inherits it. A statically
|
|
2867
|
+
// invalid one can never be delivered — and unlike a dead chat it can
|
|
2868
|
+
// never become valid either — so skip it here instead of buying a full
|
|
2869
|
+
// LLM turn whose answer the outbound boundary will refuse. Release the
|
|
2870
|
+
// claim (a later re-login can record a real conversation id) but do NOT
|
|
2871
|
+
// arm a retry: retrying an unroutable id would just loop.
|
|
2872
|
+
if (!isValidChatId(claimedBootstrap.conversationId)) {
|
|
2873
|
+
log?.error?.(`[${accountId}] clawchat-plugin-openclaw activation bootstrap skipped: chat_id="${claimedBootstrap.conversationId}" reason=invalid_chat_id`);
|
|
2874
|
+
releaseBootstrap();
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2848
2877
|
claimedInFlight = true;
|
|
2849
2878
|
incrementActivationBootstrapInFlight(accountId);
|
|
2850
2879
|
bootstrapGreetingDelivered = false;
|
package/dist/src/ws-client.js
CHANGED
|
@@ -131,6 +131,28 @@ export const SERVER_REJECTION_TTL_MS = 600_000;
|
|
|
131
131
|
* MUST stay identical to the hermes plugin's `CHAT_ID_PREFIX`.
|
|
132
132
|
*/
|
|
133
133
|
export const CHAT_ID_PREFIX = "cnv_";
|
|
134
|
+
/**
|
|
135
|
+
* Total length of a conversation id: 3-char prefix + `_` + 26-char body.
|
|
136
|
+
*
|
|
137
|
+
* member-backend mints every id as `<prefix>_<26 Crockford base32 chars of a
|
|
138
|
+
* UUID v7>` and its validator refuses anything of another length outright, so
|
|
139
|
+
* the length is part of the contract, not a coincidence.
|
|
140
|
+
*/
|
|
141
|
+
const CHAT_ID_LENGTH = 30;
|
|
142
|
+
/**
|
|
143
|
+
* Crockford base32 — the alphabet member-backend encodes the 128-bit id body
|
|
144
|
+
* with. `I`, `L`, `O` and `U` are deliberately absent (they read as `1`, `1`,
|
|
145
|
+
* `0` and `V`), and member-backend's decoder does NOT fold them in: it fails.
|
|
146
|
+
*
|
|
147
|
+
* Decoding is case-insensitive there, so both cases are accepted here. The
|
|
148
|
+
* 3-char prefix is NOT: it must be lowercase `a`..`z`, which `CHAT_ID_PREFIX`
|
|
149
|
+
* already pins.
|
|
150
|
+
*/
|
|
151
|
+
const CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
152
|
+
const CROCKFORD_SYMBOLS = new Set([
|
|
153
|
+
...CROCKFORD_ALPHABET,
|
|
154
|
+
...CROCKFORD_ALPHABET.toLowerCase(),
|
|
155
|
+
]);
|
|
134
156
|
/**
|
|
135
157
|
* Whether `chatId` can name a ClawChat conversation at all.
|
|
136
158
|
*
|
|
@@ -138,9 +160,30 @@ export const CHAT_ID_PREFIX = "cnv_";
|
|
|
138
160
|
* exists, is alive, or admits this sender. Those are server-side questions
|
|
139
161
|
* answered by `message.error`; this one is answerable locally, and a frame that
|
|
140
162
|
* fails it is always wrong to put on the wire.
|
|
163
|
+
*
|
|
164
|
+
* Mirrors member-backend's own id validator, which is the only authority on the
|
|
165
|
+
* shape. The prefix alone is NOT the rule: `cnv_…:thr_1`, a bare `cnv_`, and a
|
|
166
|
+
* `cnv_`-prefixed display name all pass a prefix test and are still answered
|
|
167
|
+
* with `code=400: invalid conversation id`.
|
|
168
|
+
*
|
|
169
|
+
* MUST stay equivalent to the hermes plugin's validator; the shared fixture
|
|
170
|
+
* `src/__fixtures__/permission-events/invalid-chat-id-outbound.json` pins the
|
|
171
|
+
* rule and the samples for both.
|
|
141
172
|
*/
|
|
142
173
|
export function isValidChatId(chatId) {
|
|
143
|
-
|
|
174
|
+
if (typeof chatId !== "string")
|
|
175
|
+
return false;
|
|
176
|
+
// Pins bytes 0-2 (the lowercase `cnv` prefix) and byte 3 (the `_`
|
|
177
|
+
// separator) in one check; the length pins the 26-char body.
|
|
178
|
+
if (chatId.length !== CHAT_ID_LENGTH)
|
|
179
|
+
return false;
|
|
180
|
+
if (!chatId.startsWith(CHAT_ID_PREFIX))
|
|
181
|
+
return false;
|
|
182
|
+
for (let i = CHAT_ID_PREFIX.length; i < CHAT_ID_LENGTH; i++) {
|
|
183
|
+
if (!CROCKFORD_SYMBOLS.has(chatId[i]))
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
144
187
|
}
|
|
145
188
|
export class ClawChatClient extends EventEmitter {
|
|
146
189
|
opts;
|
|
@@ -374,7 +417,14 @@ export class ClawChatClient extends EventEmitter {
|
|
|
374
417
|
// valid. Rejecting at this entry point is therefore sufficient: the frame
|
|
375
418
|
// never reaches the reconnect queue, so no replay path can resurrect it.
|
|
376
419
|
//
|
|
377
|
-
// Frames with no chat_id (connect / ping / pong) are untouched.
|
|
420
|
+
// Frames with no chat_id (connect / ping / pong) are untouched. The test is
|
|
421
|
+
// `!== undefined`, NOT a truthiness check, and that asymmetry is the
|
|
422
|
+
// contract: a chat_id that is ABSENT means "this event has no conversation"
|
|
423
|
+
// and passes, while one that is PRESENT-but-unusable — explicit `null`,
|
|
424
|
+
// `""`, a malformed id — is a frame that meant to address a conversation
|
|
425
|
+
// and named none, so it is dropped. msghub agrees: it decodes a JSON null
|
|
426
|
+
// into the empty string and every business event requires a non-empty
|
|
427
|
+
// chat_id.
|
|
378
428
|
if (env.chat_id !== undefined && !isValidChatId(env.chat_id))
|
|
379
429
|
return;
|
|
380
430
|
this.sendWire(JSON.stringify(env), { bypassReconnectQueue: env.event === EVENT.CONNECT });
|
package/package.json
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
## What
|
|
1
|
+
## What kind of group this is
|
|
2
2
|
|
|
3
|
-
- Until
|
|
4
|
-
|
|
5
|
-
-
|
|
3
|
+
- Until you know otherwise, treat this as an ordinary social group in the
|
|
4
|
+
owner's circle.
|
|
5
|
+
- Members may chat, share news, kick ideas around, or make small plans together.
|
|
6
|
+
- Read the group's tone from how members actually talk to each other. Do not
|
|
7
|
+
assume it is a meeting, a ticket queue, or a task board.
|
|
6
8
|
|
|
7
|
-
## How
|
|
9
|
+
## How to take part
|
|
8
10
|
|
|
9
|
-
- Reply briefly and directly when mentioned,
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
11
|
+
- Reply briefly and directly when you are mentioned, asked a question outright,
|
|
12
|
+
or can add something the group does not already have.
|
|
13
|
+
- Answer factual or background questions, and help weigh options, when you can
|
|
14
|
+
do it clearly.
|
|
15
|
+
- When a discussion has scattered, a short recap of the topic, what has been
|
|
16
|
+
agreed, and what is still open can help. Offer it once; do not chair the
|
|
17
|
+
conversation.
|
|
18
|
+
- Otherwise, listen. Most messages here do not need you.
|
|
13
19
|
|
|
14
|
-
## What
|
|
20
|
+
## What to be careful about here
|
|
15
21
|
|
|
16
|
-
- Do not bring private
|
|
17
|
-
|
|
18
|
-
- Do not
|
|
19
|
-
- Do not
|
|
22
|
+
- Do not bring private detail about a member into this group — not from a direct
|
|
23
|
+
chat, not from another group, not from the owner's memory.
|
|
24
|
+
- Do not reply to other bots or agents here, even when they mention you.
|
|
25
|
+
- Do not act publicly for the owner in or about this group unless they clearly
|
|
26
|
+
agreed.
|
|
27
|
+
- Do not turn casual conversation into task tracking, and do not take over a
|
|
28
|
+
topic nobody asked you to run.
|
|
@@ -1,27 +1,31 @@
|
|
|
1
|
-
##
|
|
1
|
+
## How to be here
|
|
2
2
|
|
|
3
|
-
- Be present
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
- Remember preferences the owner has already expressed, and follow them without making the owner repeat the same request.
|
|
3
|
+
- Be present with warmth and attention. These are specific people, not tasks to
|
|
4
|
+
process: pick up what mattered to them last time, continue the relationship
|
|
5
|
+
that already exists, and do not act as if every conversation starts from zero.
|
|
6
|
+
- Remember what the owner has already told you and act on it, rather than making
|
|
7
|
+
them say it again.
|
|
9
8
|
|
|
10
|
-
## Where
|
|
9
|
+
## Where you can act on your own
|
|
11
10
|
|
|
12
|
-
- In
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
11
|
+
- In a direct chat, respond naturally and keep the relationship going without
|
|
12
|
+
checking in about every small thing.
|
|
13
|
+
- When someone asks for facts, background, or help weighing options, answer
|
|
14
|
+
clearly and briefly.
|
|
15
|
+
- In a group, listen by default. Speak when you are mentioned, asked directly,
|
|
16
|
+
or can genuinely move the discussion forward.
|
|
17
|
+
- Build up what you know about the people you meet — and use it only where it
|
|
18
|
+
belongs.
|
|
16
19
|
|
|
17
|
-
## What
|
|
20
|
+
## What to avoid
|
|
18
21
|
|
|
19
|
-
- Do not take outward-facing action
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
- Do not take outward-facing action on the owner's behalf unless they clearly
|
|
23
|
+
agreed: making promises or accepting arrangements, inviting people, joining or
|
|
24
|
+
adding people to groups, contacting someone, publishing publicly, changing
|
|
25
|
+
profile information, sending a message that matters.
|
|
26
|
+
- Do not pretend to know what the owner would think. When you are unsure, say
|
|
27
|
+
so, or ask the other person to wait for the owner.
|
|
28
|
+
- **Do not carry private detail across contexts.** What you learned in a direct
|
|
29
|
+
chat does not belong in a group; what one group said does not belong in
|
|
30
|
+
another; what is in the owner's memory does not belong in front of their
|
|
31
|
+
friends. This is the one mistake here that cannot be taken back.
|
package/src/inbound.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
EVENT,
|
|
3
|
+
MENTION_ALL_USER_ID,
|
|
3
4
|
type ChatType,
|
|
4
5
|
type Envelope,
|
|
5
6
|
} from "./protocol-types.ts";
|
|
@@ -180,6 +181,10 @@ function mentionedUserIds(mentions: MentionedUser[]): string[] {
|
|
|
180
181
|
/**
|
|
181
182
|
* Exported for direct unit testing. Direct chats always count as addressed;
|
|
182
183
|
* group chats require a mention unless config opts into all group messages.
|
|
184
|
+
*
|
|
185
|
+
* `MENTION_ALL_USER_ID` ("all") is the `@everyone` sentinel — it addresses
|
|
186
|
+
* every member of the room, this agent included, so it counts as a mention
|
|
187
|
+
* even though it never equals our own user id.
|
|
183
188
|
*/
|
|
184
189
|
export function detectMention(params: {
|
|
185
190
|
mentions: unknown[];
|
|
@@ -187,7 +192,8 @@ export function detectMention(params: {
|
|
|
187
192
|
userId: string;
|
|
188
193
|
}): boolean {
|
|
189
194
|
if (params.chatType === "direct") return true;
|
|
190
|
-
|
|
195
|
+
const ids = mentionedUserIds(normalizeMentionUsers(params.mentions));
|
|
196
|
+
return ids.includes(params.userId) || ids.includes(MENTION_ALL_USER_ID);
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
export async function dispatchOpenclawClawlingInbound(
|
package/src/protocol-types.ts
CHANGED
|
@@ -23,6 +23,15 @@ export const EVENT = {
|
|
|
23
23
|
PONG: "pong",
|
|
24
24
|
} as const;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The `user_id` an `@everyone` mention carries on the wire — a reserved
|
|
28
|
+
* sentinel, not a real user id. The server never expands it into one mention
|
|
29
|
+
* per member, so every reader must decide for itself that it includes them.
|
|
30
|
+
* It can appear both as a `mention` fragment and as a `context.mentions`
|
|
31
|
+
* element. See `docs/client-integration.md` §10.2.
|
|
32
|
+
*/
|
|
33
|
+
export const MENTION_ALL_USER_ID = "all";
|
|
34
|
+
|
|
26
35
|
export type KnownEventName = typeof EVENT[keyof typeof EVENT];
|
|
27
36
|
export type EventName = KnownEventName | string;
|
|
28
37
|
export type ChatType = "direct" | "group";
|
package/src/runtime.ts
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
resolveOpenclawClawlingAccount,
|
|
41
41
|
type ResolvedOpenclawClawlingAccount,
|
|
42
42
|
} from "./config.ts";
|
|
43
|
-
import type
|
|
43
|
+
import { isValidChatId, type ClawlingChatClient } from "./ws-client.ts";
|
|
44
44
|
import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inbound.ts";
|
|
45
45
|
import {
|
|
46
46
|
PendingConsentStore,
|
|
@@ -479,6 +479,22 @@ function withClawChatSessionScope(cfg: OpenClawConfig): OpenClawConfig {
|
|
|
479
479
|
};
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Synthetic envelope builder for the post-activation greeting turn.
|
|
484
|
+
*
|
|
485
|
+
* The fourth synthetic INBOUND envelope carrying a chat_id, alongside
|
|
486
|
+
* `buildPermissionResultEnvelope`, `buildAwarenessNoteEnvelope` and the
|
|
487
|
+
* reply-dispatcher's owner-direct path — and it carries the same invariant.
|
|
488
|
+
*
|
|
489
|
+
* `conversationId` MUST be a conversation idcode (`cnv_…`, `isValidChatId`),
|
|
490
|
+
* the one recorded at activation from `agents/connect`'s `conversation.id`.
|
|
491
|
+
* The agent's in-turn reply INHERITS this envelope's chat_id
|
|
492
|
+
* (src/inbound.ts → runtime.dispatch → src/reply-dispatcher.ts), and msghub
|
|
493
|
+
* resolves a chat_id only through member-backend — anything else comes back
|
|
494
|
+
* "invalid conversation id", so the greeting is refused at the outbound
|
|
495
|
+
* boundary after a full LLM turn has already been spent. Callers must check at
|
|
496
|
+
* the source and skip rather than hand an unroutable id down here.
|
|
497
|
+
*/
|
|
482
498
|
function buildActivationBootstrapEnvelope(params: {
|
|
483
499
|
account: ResolvedOpenclawClawlingAccount;
|
|
484
500
|
conversationId: string;
|
|
@@ -3379,6 +3395,20 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3379
3395
|
return;
|
|
3380
3396
|
}
|
|
3381
3397
|
const claimedBootstrap = bootstrap;
|
|
3398
|
+
// The claimed id is about to become the chat_id of a synthetic INBOUND
|
|
3399
|
+
// envelope, and the agent's in-turn reply inherits it. A statically
|
|
3400
|
+
// invalid one can never be delivered — and unlike a dead chat it can
|
|
3401
|
+
// never become valid either — so skip it here instead of buying a full
|
|
3402
|
+
// LLM turn whose answer the outbound boundary will refuse. Release the
|
|
3403
|
+
// claim (a later re-login can record a real conversation id) but do NOT
|
|
3404
|
+
// arm a retry: retrying an unroutable id would just loop.
|
|
3405
|
+
if (!isValidChatId(claimedBootstrap.conversationId)) {
|
|
3406
|
+
log?.error?.(
|
|
3407
|
+
`[${accountId}] clawchat-plugin-openclaw activation bootstrap skipped: chat_id="${claimedBootstrap.conversationId}" reason=invalid_chat_id`,
|
|
3408
|
+
);
|
|
3409
|
+
releaseBootstrap();
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3382
3412
|
claimedInFlight = true;
|
|
3383
3413
|
incrementActivationBootstrapInFlight(accountId);
|
|
3384
3414
|
bootstrapGreetingDelivered = false;
|
package/src/ws-client.ts
CHANGED
|
@@ -202,6 +202,30 @@ export const SERVER_REJECTION_TTL_MS = 600_000;
|
|
|
202
202
|
*/
|
|
203
203
|
export const CHAT_ID_PREFIX = "cnv_";
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Total length of a conversation id: 3-char prefix + `_` + 26-char body.
|
|
207
|
+
*
|
|
208
|
+
* member-backend mints every id as `<prefix>_<26 Crockford base32 chars of a
|
|
209
|
+
* UUID v7>` and its validator refuses anything of another length outright, so
|
|
210
|
+
* the length is part of the contract, not a coincidence.
|
|
211
|
+
*/
|
|
212
|
+
const CHAT_ID_LENGTH = 30;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Crockford base32 — the alphabet member-backend encodes the 128-bit id body
|
|
216
|
+
* with. `I`, `L`, `O` and `U` are deliberately absent (they read as `1`, `1`,
|
|
217
|
+
* `0` and `V`), and member-backend's decoder does NOT fold them in: it fails.
|
|
218
|
+
*
|
|
219
|
+
* Decoding is case-insensitive there, so both cases are accepted here. The
|
|
220
|
+
* 3-char prefix is NOT: it must be lowercase `a`..`z`, which `CHAT_ID_PREFIX`
|
|
221
|
+
* already pins.
|
|
222
|
+
*/
|
|
223
|
+
const CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
224
|
+
const CROCKFORD_SYMBOLS: ReadonlySet<string> = new Set([
|
|
225
|
+
...CROCKFORD_ALPHABET,
|
|
226
|
+
...CROCKFORD_ALPHABET.toLowerCase(),
|
|
227
|
+
]);
|
|
228
|
+
|
|
205
229
|
/**
|
|
206
230
|
* Whether `chatId` can name a ClawChat conversation at all.
|
|
207
231
|
*
|
|
@@ -209,9 +233,26 @@ export const CHAT_ID_PREFIX = "cnv_";
|
|
|
209
233
|
* exists, is alive, or admits this sender. Those are server-side questions
|
|
210
234
|
* answered by `message.error`; this one is answerable locally, and a frame that
|
|
211
235
|
* fails it is always wrong to put on the wire.
|
|
236
|
+
*
|
|
237
|
+
* Mirrors member-backend's own id validator, which is the only authority on the
|
|
238
|
+
* shape. The prefix alone is NOT the rule: `cnv_…:thr_1`, a bare `cnv_`, and a
|
|
239
|
+
* `cnv_`-prefixed display name all pass a prefix test and are still answered
|
|
240
|
+
* with `code=400: invalid conversation id`.
|
|
241
|
+
*
|
|
242
|
+
* MUST stay equivalent to the hermes plugin's validator; the shared fixture
|
|
243
|
+
* `src/__fixtures__/permission-events/invalid-chat-id-outbound.json` pins the
|
|
244
|
+
* rule and the samples for both.
|
|
212
245
|
*/
|
|
213
246
|
export function isValidChatId(chatId: unknown): chatId is string {
|
|
214
|
-
|
|
247
|
+
if (typeof chatId !== "string") return false;
|
|
248
|
+
// Pins bytes 0-2 (the lowercase `cnv` prefix) and byte 3 (the `_`
|
|
249
|
+
// separator) in one check; the length pins the 26-char body.
|
|
250
|
+
if (chatId.length !== CHAT_ID_LENGTH) return false;
|
|
251
|
+
if (!chatId.startsWith(CHAT_ID_PREFIX)) return false;
|
|
252
|
+
for (let i = CHAT_ID_PREFIX.length; i < CHAT_ID_LENGTH; i++) {
|
|
253
|
+
if (!CROCKFORD_SYMBOLS.has(chatId[i]!)) return false;
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
215
256
|
}
|
|
216
257
|
|
|
217
258
|
/**
|
|
@@ -468,7 +509,14 @@ export class ClawChatClient extends EventEmitter {
|
|
|
468
509
|
// valid. Rejecting at this entry point is therefore sufficient: the frame
|
|
469
510
|
// never reaches the reconnect queue, so no replay path can resurrect it.
|
|
470
511
|
//
|
|
471
|
-
// Frames with no chat_id (connect / ping / pong) are untouched.
|
|
512
|
+
// Frames with no chat_id (connect / ping / pong) are untouched. The test is
|
|
513
|
+
// `!== undefined`, NOT a truthiness check, and that asymmetry is the
|
|
514
|
+
// contract: a chat_id that is ABSENT means "this event has no conversation"
|
|
515
|
+
// and passes, while one that is PRESENT-but-unusable — explicit `null`,
|
|
516
|
+
// `""`, a malformed id — is a frame that meant to address a conversation
|
|
517
|
+
// and named none, so it is dropped. msghub agrees: it decodes a JSON null
|
|
518
|
+
// into the empty string and every business event requires a non-empty
|
|
519
|
+
// chat_id.
|
|
472
520
|
if (env.chat_id !== undefined && !isValidChatId(env.chat_id)) return;
|
|
473
521
|
this.sendWire(JSON.stringify(env), { bypassReconnectQueue: env.event === EVENT.CONNECT });
|
|
474
522
|
}
|