@newbase-clawchat/openclaw-clawchat 2026.4.24 → 2026.4.30
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/README.md +66 -16
- package/dist/index.js +27 -0
- package/dist/src/api-client.js +156 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +191 -0
- package/dist/src/client.js +176 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +214 -0
- package/dist/src/inbound.js +133 -0
- package/dist/src/login.runtime.js +130 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +82 -0
- package/dist/src/outbound.js +181 -0
- package/dist/src/protocol.js +38 -0
- package/dist/src/reply-dispatcher.js +440 -0
- package/dist/src/runtime.js +288 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/tools-schema.js +38 -0
- package/dist/src/tools.js +287 -0
- package/index.ts +2 -1
- package/openclaw.plugin.json +81 -1
- package/package.json +21 -9
- package/skills/clawchat-account-tools/SKILL.md +26 -0
- package/skills/clawchat-activate/SKILL.md +47 -0
- package/src/api-client.test.ts +6 -5
- package/src/api-client.ts +8 -3
- package/src/buffered-stream.test.ts +14 -4
- package/src/buffered-stream.ts +19 -11
- package/src/channel.outbound.test.ts +49 -35
- package/src/channel.test.ts +45 -10
- package/src/channel.ts +26 -17
- package/src/client.test.ts +9 -1
- package/src/client.ts +48 -21
- package/src/commands.test.ts +39 -0
- package/src/commands.ts +41 -0
- package/src/config.test.ts +40 -3
- package/src/config.ts +60 -4
- package/src/inbound.test.ts +9 -6
- package/src/inbound.ts +51 -16
- package/src/login.runtime.test.ts +142 -3
- package/src/login.runtime.ts +59 -26
- package/src/manifest.test.ts +183 -5
- package/src/outbound.test.ts +10 -7
- package/src/outbound.ts +8 -7
- package/src/plugin-entry.test.ts +27 -0
- package/src/protocol.ts +5 -0
- package/src/reply-dispatcher.test.ts +420 -3
- package/src/reply-dispatcher.ts +137 -12
- package/src/runtime.test.ts +23 -7
- package/src/runtime.ts +13 -1
- package/src/streaming.test.ts +12 -9
- package/src/streaming.ts +22 -12
- package/src/tools-schema.ts +28 -19
- package/src/tools.test.ts +181 -40
- package/src/tools.ts +107 -95
package/src/config.ts
CHANGED
|
@@ -8,10 +8,9 @@ export const CHANNEL_ID = "openclaw-clawchat" as const;
|
|
|
8
8
|
* login` works out of the box without requiring a prior `openclaw channel
|
|
9
9
|
* setup` call. Operators can still override either one via config.
|
|
10
10
|
*
|
|
11
|
-
* TODO: replace these placeholders with the production URLs.
|
|
12
11
|
*/
|
|
13
|
-
export const DEFAULT_BASE_URL = "
|
|
14
|
-
export const DEFAULT_WEBSOCKET_URL = "
|
|
12
|
+
export const DEFAULT_BASE_URL = "http://company.newbaselab.com:10086" as const;
|
|
13
|
+
export const DEFAULT_WEBSOCKET_URL = "ws://company.newbaselab.com:10086/ws" as const;
|
|
15
14
|
|
|
16
15
|
export type ReplyMode = "static" | "stream";
|
|
17
16
|
|
|
@@ -92,6 +91,8 @@ export type OpenclawClawlingConfig = {
|
|
|
92
91
|
groupMode?: GroupMode;
|
|
93
92
|
forwardThinking?: boolean;
|
|
94
93
|
forwardToolCalls?: boolean;
|
|
94
|
+
/** Emit approval/action rich fragments instead of plain fallback text. */
|
|
95
|
+
richInteractions?: boolean;
|
|
95
96
|
stream?: OpenclawClawlingStreamConfig;
|
|
96
97
|
reconnect?: OpenclawClawlingReconnectConfig;
|
|
97
98
|
heartbeat?: OpenclawClawlingHeartbeatConfig;
|
|
@@ -112,6 +113,7 @@ export const openclawClawlingConfigSchema = {
|
|
|
112
113
|
groupMode: { type: "string", enum: ["mention", "all"] },
|
|
113
114
|
forwardThinking: { type: "boolean" },
|
|
114
115
|
forwardToolCalls: { type: "boolean" },
|
|
116
|
+
richInteractions: { type: "boolean" },
|
|
115
117
|
stream: {
|
|
116
118
|
type: "object",
|
|
117
119
|
additionalProperties: false,
|
|
@@ -150,6 +152,57 @@ export const openclawClawlingConfigSchema = {
|
|
|
150
152
|
},
|
|
151
153
|
} as const;
|
|
152
154
|
|
|
155
|
+
function isOpenclawClawchatToolAllowEntry(entry: unknown): boolean {
|
|
156
|
+
return entry === CHANNEL_ID || entry === "group:plugins";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hasOpenclawClawchatToolAllow(cfg: OpenClawConfig): boolean {
|
|
160
|
+
const currentTools = ((cfg as { tools?: Record<string, unknown> }).tools ?? {}) as Record<
|
|
161
|
+
string,
|
|
162
|
+
unknown
|
|
163
|
+
>;
|
|
164
|
+
const currentAlsoAllow = Array.isArray(currentTools.alsoAllow) ? currentTools.alsoAllow : [];
|
|
165
|
+
const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow : [];
|
|
166
|
+
return [...currentAllow, ...currentAlsoAllow].some(isOpenclawClawchatToolAllowEntry);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function mergeToolPolicyEntryAllow(
|
|
170
|
+
cfg: OpenClawConfig,
|
|
171
|
+
entry: string,
|
|
172
|
+
isAlreadyCovered: (value: unknown) => boolean,
|
|
173
|
+
): OpenClawConfig {
|
|
174
|
+
const currentTools = ((cfg as { tools?: Record<string, unknown> }).tools ?? {}) as Record<
|
|
175
|
+
string,
|
|
176
|
+
unknown
|
|
177
|
+
>;
|
|
178
|
+
const currentAlsoAllow = Array.isArray(currentTools.alsoAllow)
|
|
179
|
+
? currentTools.alsoAllow.slice()
|
|
180
|
+
: [];
|
|
181
|
+
const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow.slice() : [];
|
|
182
|
+
const alreadyAllowed = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
|
|
183
|
+
if (currentAllow.length > 0) {
|
|
184
|
+
return {
|
|
185
|
+
...cfg,
|
|
186
|
+
tools: {
|
|
187
|
+
...currentTools,
|
|
188
|
+
allow: alreadyAllowed ? currentAllow : [...currentAllow, entry],
|
|
189
|
+
},
|
|
190
|
+
} as OpenClawConfig;
|
|
191
|
+
}
|
|
192
|
+
const alreadyAlsoAllowed = currentAlsoAllow.some(isAlreadyCovered);
|
|
193
|
+
return {
|
|
194
|
+
...cfg,
|
|
195
|
+
tools: {
|
|
196
|
+
...currentTools,
|
|
197
|
+
alsoAllow: alreadyAlsoAllowed ? currentAlsoAllow : [...currentAlsoAllow, entry],
|
|
198
|
+
},
|
|
199
|
+
} as OpenClawConfig;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function mergeOpenclawClawchatToolAllow(cfg: OpenClawConfig): OpenClawConfig {
|
|
203
|
+
return mergeToolPolicyEntryAllow(cfg, CHANNEL_ID, isOpenclawClawchatToolAllowEntry);
|
|
204
|
+
}
|
|
205
|
+
|
|
153
206
|
export type ResolvedOpenclawClawlingAccount = {
|
|
154
207
|
accountId: string;
|
|
155
208
|
name: string;
|
|
@@ -163,6 +216,7 @@ export type ResolvedOpenclawClawlingAccount = {
|
|
|
163
216
|
groupMode: GroupMode;
|
|
164
217
|
forwardThinking: boolean;
|
|
165
218
|
forwardToolCalls: boolean;
|
|
219
|
+
richInteractions: boolean;
|
|
166
220
|
allowFrom: string[];
|
|
167
221
|
stream: Required<OpenclawClawlingStreamConfig>;
|
|
168
222
|
reconnect: Required<OpenclawClawlingReconnectConfig>;
|
|
@@ -249,6 +303,8 @@ export function resolveOpenclawClawlingAccount(
|
|
|
249
303
|
typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
|
|
250
304
|
const forwardToolCalls =
|
|
251
305
|
typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
|
|
306
|
+
const richInteractions =
|
|
307
|
+
typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
|
|
252
308
|
|
|
253
309
|
return {
|
|
254
310
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
@@ -263,6 +319,7 @@ export function resolveOpenclawClawlingAccount(
|
|
|
263
319
|
groupMode,
|
|
264
320
|
forwardThinking,
|
|
265
321
|
forwardToolCalls,
|
|
322
|
+
richInteractions,
|
|
266
323
|
allowFrom: [],
|
|
267
324
|
stream: readStream(channel.stream),
|
|
268
325
|
reconnect: readReconnect(channel.reconnect),
|
|
@@ -274,4 +331,3 @@ export function resolveOpenclawClawlingAccount(
|
|
|
274
331
|
export function listOpenclawClawlingAccountIds(): string[] {
|
|
275
332
|
return [DEFAULT_ACCOUNT_ID];
|
|
276
333
|
}
|
|
277
|
-
|
package/src/inbound.test.ts
CHANGED
|
@@ -41,6 +41,7 @@ function buildSendEnvelope(
|
|
|
41
41
|
mentions: string[];
|
|
42
42
|
reply: unknown;
|
|
43
43
|
messageId: string;
|
|
44
|
+
chatId: string;
|
|
44
45
|
}> = {},
|
|
45
46
|
): Envelope<DownlinkMessageSendPayload> {
|
|
46
47
|
return {
|
|
@@ -48,6 +49,7 @@ function buildSendEnvelope(
|
|
|
48
49
|
event: overrides.event ?? "message.send",
|
|
49
50
|
trace_id: "trace-1",
|
|
50
51
|
emitted_at: 1776162600000,
|
|
52
|
+
chat_id: overrides.chatId,
|
|
51
53
|
to: { id: "agent-1", type: overrides.senderType ?? "direct" },
|
|
52
54
|
sender: {
|
|
53
55
|
sender_id: "user-1",
|
|
@@ -93,7 +95,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
93
95
|
envelope: buildSendEnvelope({ text: "hello there" }),
|
|
94
96
|
cfg: {},
|
|
95
97
|
runtime: {} as never,
|
|
96
|
-
account: baseAccount(),
|
|
98
|
+
account: baseAccount({ groupMode: "mention" }),
|
|
97
99
|
ingest,
|
|
98
100
|
});
|
|
99
101
|
expect(ingest).toHaveBeenCalledTimes(1);
|
|
@@ -110,7 +112,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
110
112
|
envelope: buildSendEnvelope({ senderType: "direct" }),
|
|
111
113
|
cfg: {},
|
|
112
114
|
runtime: {} as never,
|
|
113
|
-
account: baseAccount(),
|
|
115
|
+
account: baseAccount({ groupMode: "mention" }),
|
|
114
116
|
ingest,
|
|
115
117
|
});
|
|
116
118
|
const { wasMentioned } = ingest.mock.calls[0]![0];
|
|
@@ -145,7 +147,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
145
147
|
envelope: buildSendEnvelope({ senderType: "group" }),
|
|
146
148
|
cfg: {},
|
|
147
149
|
runtime: {} as never,
|
|
148
|
-
account: baseAccount(),
|
|
150
|
+
account: baseAccount({ groupMode: "mention" }),
|
|
149
151
|
ingest,
|
|
150
152
|
});
|
|
151
153
|
expect(ingest).not.toHaveBeenCalled();
|
|
@@ -174,7 +176,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
174
176
|
},
|
|
175
177
|
};
|
|
176
178
|
await dispatchOpenclawClawlingInbound({
|
|
177
|
-
envelope: buildSendEnvelope({ event: "message.reply", reply: replyRef }),
|
|
179
|
+
envelope: buildSendEnvelope({ event: "message.reply", reply: replyRef, chatId: "chat-1" }),
|
|
178
180
|
cfg: {},
|
|
179
181
|
runtime: {} as never,
|
|
180
182
|
account: baseAccount(),
|
|
@@ -183,8 +185,9 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
183
185
|
const { replyCtx } = ingest.mock.calls[0]![0];
|
|
184
186
|
expect(replyCtx).toEqual({
|
|
185
187
|
replyToMessageId: "m-orig",
|
|
188
|
+
replyPreviewChatId: "chat-1",
|
|
186
189
|
replyPreviewSenderId: "user-2",
|
|
187
|
-
|
|
190
|
+
replyPreviewNickName: "User Two",
|
|
188
191
|
replyPreviewText: "original text",
|
|
189
192
|
});
|
|
190
193
|
});
|
|
@@ -262,7 +265,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
262
265
|
expect(ingest).toHaveBeenCalledTimes(1);
|
|
263
266
|
const call = ingest.mock.calls[0]![0];
|
|
264
267
|
expect(call.senderId).toBe("user-1");
|
|
265
|
-
expect(call.
|
|
268
|
+
expect(call.senderNickName).toBe("User One");
|
|
266
269
|
expect(call.peer).toEqual({ kind: "direct", id: "user-1" });
|
|
267
270
|
});
|
|
268
271
|
|
package/src/inbound.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface IngestTurnParams {
|
|
|
29
29
|
mediaItems: MediaItem[];
|
|
30
30
|
replyCtx?: {
|
|
31
31
|
replyToMessageId: string;
|
|
32
|
+
replyPreviewChatId: string;
|
|
32
33
|
replyPreviewSenderId: string;
|
|
33
34
|
replyPreviewNickName: string;
|
|
34
35
|
replyPreviewText: string;
|
|
@@ -52,6 +53,29 @@ const DEDUP_MAX = 256;
|
|
|
52
53
|
const dedupSeen: string[] = [];
|
|
53
54
|
const dedupSet = new Set<string>();
|
|
54
55
|
|
|
56
|
+
type SenderLike = {
|
|
57
|
+
id?: unknown;
|
|
58
|
+
nick_name?: unknown;
|
|
59
|
+
sender_id?: unknown;
|
|
60
|
+
display_name?: unknown;
|
|
61
|
+
type?: unknown;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function normalizeSender(sender: unknown): { id: string; nickName: string; type?: ChatType } | null {
|
|
65
|
+
if (!sender || typeof sender !== "object") return null;
|
|
66
|
+
const s = sender as SenderLike;
|
|
67
|
+
const id = typeof s.id === "string" ? s.id : typeof s.sender_id === "string" ? s.sender_id : "";
|
|
68
|
+
if (!id) return null;
|
|
69
|
+
const type = s.type === "group" || s.type === "direct" ? s.type : undefined;
|
|
70
|
+
const nickName =
|
|
71
|
+
typeof s.nick_name === "string"
|
|
72
|
+
? s.nick_name
|
|
73
|
+
: typeof s.display_name === "string"
|
|
74
|
+
? s.display_name
|
|
75
|
+
: id;
|
|
76
|
+
return { id, nickName, ...(type ? { type } : {}) };
|
|
77
|
+
}
|
|
78
|
+
|
|
55
79
|
export function _resetDedupForTest(): void {
|
|
56
80
|
dedupSeen.length = 0;
|
|
57
81
|
dedupSet.clear();
|
|
@@ -101,20 +125,22 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
101
125
|
reply: {
|
|
102
126
|
reply_to_msg_id: string;
|
|
103
127
|
reply_preview: {
|
|
104
|
-
id
|
|
105
|
-
nick_name
|
|
128
|
+
id?: string;
|
|
129
|
+
nick_name?: string;
|
|
130
|
+
sender_id?: string;
|
|
131
|
+
display_name?: string;
|
|
106
132
|
fragments: Array<Record<string, unknown>>;
|
|
107
133
|
};
|
|
108
134
|
} | null;
|
|
109
135
|
};
|
|
110
136
|
/** Legacy fallback: older fixtures carried sender inside payload.message. */
|
|
111
|
-
sender?:
|
|
137
|
+
sender?: SenderLike;
|
|
112
138
|
};
|
|
113
139
|
|
|
114
140
|
// v2 envelopes carry sender on the envelope (RoutingSender); the legacy
|
|
115
141
|
// message.sender shape is accepted as a fallback for older fixtures.
|
|
116
|
-
const sender = envelope.sender ?? message.sender;
|
|
117
|
-
if (!sender
|
|
142
|
+
const sender = normalizeSender(envelope.sender ?? message.sender);
|
|
143
|
+
if (!sender) {
|
|
118
144
|
log?.info?.(
|
|
119
145
|
`[${account.accountId}] openclaw-clawchat skip: missing sender trace=${envelope.trace_id}`,
|
|
120
146
|
);
|
|
@@ -122,7 +148,10 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
122
148
|
}
|
|
123
149
|
// `chat_type` is on the envelope in the new protocol. Default to "direct"
|
|
124
150
|
// if the server didn't include it (defensive; shouldn't happen in practice).
|
|
125
|
-
const
|
|
151
|
+
const legacyTo = (envelope as Envelope<DownlinkMessageSendPayload> & {
|
|
152
|
+
to?: { type?: ChatType };
|
|
153
|
+
}).to;
|
|
154
|
+
const chatType: ChatType = envelope.chat_type ?? sender.type ?? legacyTo?.type ?? "direct";
|
|
126
155
|
const isGroup = chatType === "group";
|
|
127
156
|
if (payload.message_mode !== "normal") {
|
|
128
157
|
log?.info?.(
|
|
@@ -163,15 +192,6 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
163
192
|
return;
|
|
164
193
|
}
|
|
165
194
|
|
|
166
|
-
const replyCtx = message.context.reply
|
|
167
|
-
? {
|
|
168
|
-
replyToMessageId: message.context.reply.reply_to_msg_id,
|
|
169
|
-
replyPreviewSenderId: message.context.reply.reply_preview.id,
|
|
170
|
-
replyPreviewNickName: message.context.reply.reply_preview.nick_name,
|
|
171
|
-
replyPreviewText: fragmentsToText(message.context.reply.reply_preview.fragments as never),
|
|
172
|
-
}
|
|
173
|
-
: undefined;
|
|
174
|
-
|
|
175
195
|
log?.info?.(
|
|
176
196
|
`[${account.accountId}] openclaw-clawchat inbound event=${envelope.event === EVENT.MESSAGE_REPLY ? "reply" : "send"} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`,
|
|
177
197
|
);
|
|
@@ -181,13 +201,28 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
181
201
|
const chatId =
|
|
182
202
|
(envelope as Envelope<DownlinkMessageSendPayload> & { chat_id?: string }).chat_id ??
|
|
183
203
|
sender.id;
|
|
204
|
+
const replyCtx = message.context.reply
|
|
205
|
+
? {
|
|
206
|
+
replyToMessageId: message.context.reply.reply_to_msg_id,
|
|
207
|
+
replyPreviewChatId: chatId,
|
|
208
|
+
replyPreviewSenderId:
|
|
209
|
+
message.context.reply.reply_preview.id ??
|
|
210
|
+
message.context.reply.reply_preview.sender_id ??
|
|
211
|
+
"",
|
|
212
|
+
replyPreviewNickName:
|
|
213
|
+
message.context.reply.reply_preview.nick_name ??
|
|
214
|
+
message.context.reply.reply_preview.display_name ??
|
|
215
|
+
"",
|
|
216
|
+
replyPreviewText: fragmentsToText(message.context.reply.reply_preview.fragments as never),
|
|
217
|
+
}
|
|
218
|
+
: undefined;
|
|
184
219
|
|
|
185
220
|
await params.ingest({
|
|
186
221
|
channel: "openclaw-clawchat",
|
|
187
222
|
accountId: account.accountId,
|
|
188
223
|
peer: { kind: isGroup ? "group" : "direct", id: chatId },
|
|
189
224
|
senderId: sender.id,
|
|
190
|
-
senderNickName: sender.
|
|
225
|
+
senderNickName: sender.nickName,
|
|
191
226
|
rawBody,
|
|
192
227
|
messageId: payload.message_id,
|
|
193
228
|
traceId: envelope.trace_id,
|
|
@@ -95,7 +95,7 @@ describe("runOpenclawClawlingLogin", () => {
|
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
expect(agentsConnect).toHaveBeenCalledWith({
|
|
98
|
-
|
|
98
|
+
code: "INV-ABC",
|
|
99
99
|
platform: "openclaw",
|
|
100
100
|
type: "clawbot",
|
|
101
101
|
});
|
|
@@ -112,6 +112,145 @@ describe("runOpenclawClawlingLogin", () => {
|
|
|
112
112
|
expect(log).toHaveBeenCalledWith(expect.stringContaining("login succeeded"));
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
it("uses the runtime config mutator with auto reload intent for config writes", async () => {
|
|
116
|
+
const cfg = buildCfg({
|
|
117
|
+
baseUrl: "https://api.example.com",
|
|
118
|
+
websocketUrl: "wss://ws.example.com/v2/client",
|
|
119
|
+
});
|
|
120
|
+
const agentsConnect = vi.fn().mockResolvedValue({
|
|
121
|
+
agent: { user_id: "agent-123", nickname: "Bot" },
|
|
122
|
+
access_token: "access-tok",
|
|
123
|
+
refresh_token: "refresh-tok",
|
|
124
|
+
});
|
|
125
|
+
let mutatedCfg: OpenClawConfig | undefined;
|
|
126
|
+
const mutateConfigFile = vi.fn(async (params) => {
|
|
127
|
+
expect(params.afterWrite).toEqual({ mode: "auto" });
|
|
128
|
+
const draft = structuredClone(cfg) as OpenClawConfig;
|
|
129
|
+
await params.mutate(draft, { snapshot: {} as never, previousHash: "before" });
|
|
130
|
+
mutatedCfg = draft;
|
|
131
|
+
return { nextConfig: draft } as never;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await runOpenclawClawlingLogin({
|
|
135
|
+
cfg,
|
|
136
|
+
runtime: { log: vi.fn() },
|
|
137
|
+
readInviteCode: async () => "INV-ABC",
|
|
138
|
+
apiClientFactory: () => makeApiClient({ agentsConnect }),
|
|
139
|
+
mutateConfigFile,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(mutateConfigFile).toHaveBeenCalledTimes(1);
|
|
143
|
+
const section = (mutatedCfg!.channels as Record<string, Record<string, unknown>>)[
|
|
144
|
+
CHANNEL_ID
|
|
145
|
+
]!;
|
|
146
|
+
expect(section.token).toBe("access-tok");
|
|
147
|
+
expect(section.refreshToken).toBe("refresh-tok");
|
|
148
|
+
expect(section.userId).toBe("agent-123");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("preserves other configured channels when persisting ClawChat credentials", async () => {
|
|
152
|
+
const cfg = {
|
|
153
|
+
channels: {
|
|
154
|
+
telegram: {
|
|
155
|
+
enabled: true,
|
|
156
|
+
token: "telegram-token",
|
|
157
|
+
},
|
|
158
|
+
[CHANNEL_ID]: {
|
|
159
|
+
baseUrl: "https://api.example.com",
|
|
160
|
+
websocketUrl: "wss://ws.example.com/v2/client",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
} as unknown as OpenClawConfig;
|
|
164
|
+
const agentsConnect = vi.fn().mockResolvedValue({
|
|
165
|
+
agent: { user_id: "agent-123" },
|
|
166
|
+
access_token: "access-tok",
|
|
167
|
+
refresh_token: "refresh-tok",
|
|
168
|
+
});
|
|
169
|
+
const persistConfig = vi.fn();
|
|
170
|
+
|
|
171
|
+
await runOpenclawClawlingLogin({
|
|
172
|
+
cfg,
|
|
173
|
+
runtime: { log: vi.fn() },
|
|
174
|
+
readInviteCode: async () => "INV-ABC",
|
|
175
|
+
apiClientFactory: () => makeApiClient({ agentsConnect }),
|
|
176
|
+
persistConfig,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
|
|
180
|
+
expect(savedCfg.channels?.telegram).toEqual({
|
|
181
|
+
enabled: true,
|
|
182
|
+
token: "telegram-token",
|
|
183
|
+
});
|
|
184
|
+
expect(savedCfg.channels?.[CHANNEL_ID]).toMatchObject({
|
|
185
|
+
baseUrl: "https://api.example.com",
|
|
186
|
+
websocketUrl: "wss://ws.example.com/v2/client",
|
|
187
|
+
token: "access-tok",
|
|
188
|
+
userId: "agent-123",
|
|
189
|
+
refreshToken: "refresh-tok",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("enables the channel when login fills a pre-activation disabled skeleton", async () => {
|
|
194
|
+
const cfg = buildCfg({
|
|
195
|
+
enabled: false,
|
|
196
|
+
baseUrl: "https://api.example.com",
|
|
197
|
+
});
|
|
198
|
+
const agentsConnect = vi.fn().mockResolvedValue({
|
|
199
|
+
agent: { user_id: "agent-123" },
|
|
200
|
+
access_token: "access-tok",
|
|
201
|
+
refresh_token: "refresh-tok",
|
|
202
|
+
});
|
|
203
|
+
const persistConfig = vi.fn();
|
|
204
|
+
|
|
205
|
+
await runOpenclawClawlingLogin({
|
|
206
|
+
cfg,
|
|
207
|
+
runtime: { log: vi.fn() },
|
|
208
|
+
readInviteCode: async () => "INV-ABC",
|
|
209
|
+
apiClientFactory: () => makeApiClient({ agentsConnect }),
|
|
210
|
+
persistConfig,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
|
|
214
|
+
const section = (savedCfg.channels as Record<string, Record<string, unknown>>)[CHANNEL_ID]!;
|
|
215
|
+
expect(section.enabled).toBe(true);
|
|
216
|
+
expect(section.token).toBe("access-tok");
|
|
217
|
+
expect(section.userId).toBe("agent-123");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("allows openclaw-clawchat plugin tools after successful login without replacing policy", async () => {
|
|
221
|
+
const cfg = {
|
|
222
|
+
...buildCfg({ baseUrl: "https://api.example.com" }),
|
|
223
|
+
tools: {
|
|
224
|
+
profile: "coding",
|
|
225
|
+
allow: [],
|
|
226
|
+
deny: ["exec"],
|
|
227
|
+
alsoAllow: ["browser"],
|
|
228
|
+
},
|
|
229
|
+
} as unknown as OpenClawConfig;
|
|
230
|
+
const agentsConnect = vi.fn().mockResolvedValue({
|
|
231
|
+
agent: { user_id: "agent-123" },
|
|
232
|
+
access_token: "access-tok",
|
|
233
|
+
refresh_token: "refresh-tok",
|
|
234
|
+
});
|
|
235
|
+
const persistConfig = vi.fn();
|
|
236
|
+
|
|
237
|
+
await runOpenclawClawlingLogin({
|
|
238
|
+
cfg,
|
|
239
|
+
runtime: { log: vi.fn() },
|
|
240
|
+
readInviteCode: async () => "INV-ABC",
|
|
241
|
+
apiClientFactory: () => makeApiClient({ agentsConnect }),
|
|
242
|
+
persistConfig,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
|
|
246
|
+
expect(savedCfg.tools).toMatchObject({
|
|
247
|
+
profile: "coding",
|
|
248
|
+
allow: [],
|
|
249
|
+
deny: ["exec"],
|
|
250
|
+
alsoAllow: ["browser", "openclaw-clawchat"],
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
115
254
|
it("surfaces agents/connect API errors with the kind and message", async () => {
|
|
116
255
|
const cfg = buildCfg({ baseUrl: "https://api.example.com" });
|
|
117
256
|
const agentsConnect = vi.fn().mockRejectedValue(
|
|
@@ -219,7 +358,7 @@ describe("runOpenclawClawlingLogin", () => {
|
|
|
219
358
|
persistConfig: vi.fn(),
|
|
220
359
|
});
|
|
221
360
|
expect(agentsConnect).toHaveBeenCalledWith({
|
|
222
|
-
|
|
361
|
+
code: "INV-TRIM",
|
|
223
362
|
platform: "openclaw",
|
|
224
363
|
type: "clawbot",
|
|
225
364
|
});
|
|
@@ -243,7 +382,7 @@ describe("runOpenclawClawlingLogin (non-interactive via readInviteCode)", () =>
|
|
|
243
382
|
persistConfig,
|
|
244
383
|
});
|
|
245
384
|
expect(agentsConnect).toHaveBeenCalledWith({
|
|
246
|
-
|
|
385
|
+
code: "INV-PROGRAMMATIC",
|
|
247
386
|
platform: "openclaw",
|
|
248
387
|
type: "clawbot",
|
|
249
388
|
});
|
package/src/login.runtime.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { createInterface, type Interface as ReadlineInterface } from "node:readline/promises";
|
|
2
|
-
import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
|
|
3
2
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
4
3
|
import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
5
|
-
import { ClawlingApiError } from "./api-types.ts";
|
|
6
|
-
import {
|
|
4
|
+
import { ClawlingApiError, type AgentConnectResult } from "./api-types.ts";
|
|
5
|
+
import {
|
|
6
|
+
CHANNEL_ID,
|
|
7
|
+
mergeOpenclawClawchatToolAllow,
|
|
8
|
+
resolveOpenclawClawlingAccount,
|
|
9
|
+
} from "./config.ts";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Platform tag sent to `/v1/agents/connect`. Identifies the host of this
|
|
@@ -16,6 +19,14 @@ export const AGENTS_CONNECT_PLATFORM = "openclaw" as const;
|
|
|
16
19
|
*/
|
|
17
20
|
export const AGENTS_CONNECT_TYPE = "clawbot" as const;
|
|
18
21
|
|
|
22
|
+
export type OpenclawClawchatMutateConfigFile = <T = void>(params: {
|
|
23
|
+
afterWrite: { mode: "auto" } | { mode: "none" | "restart"; reason: string };
|
|
24
|
+
mutate: (
|
|
25
|
+
draft: OpenClawConfig,
|
|
26
|
+
context: { snapshot: unknown; previousHash: string | null },
|
|
27
|
+
) => Promise<T | void> | T | void;
|
|
28
|
+
}) => Promise<unknown>;
|
|
29
|
+
|
|
19
30
|
export interface LoginParams {
|
|
20
31
|
cfg: OpenClawConfig;
|
|
21
32
|
accountId?: string | null;
|
|
@@ -27,7 +38,9 @@ export interface LoginParams {
|
|
|
27
38
|
readInviteCode?: () => Promise<string>;
|
|
28
39
|
/** Override for the HTTP client — used by tests. */
|
|
29
40
|
apiClientFactory?: typeof createOpenclawClawlingApiClient;
|
|
30
|
-
/**
|
|
41
|
+
/** Official runtime config mutator. Production callers must provide this. */
|
|
42
|
+
mutateConfigFile?: OpenclawClawchatMutateConfigFile;
|
|
43
|
+
/** Test-only config persistence override. */
|
|
31
44
|
persistConfig?: (cfg: OpenClawConfig) => Promise<void> | void;
|
|
32
45
|
}
|
|
33
46
|
|
|
@@ -56,6 +69,46 @@ async function promptInviteCodeFromStdin(runtime: {
|
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
|
|
72
|
+
function buildLoginConfig(cfg: OpenClawConfig, result: AgentConnectResult): OpenClawConfig {
|
|
73
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
74
|
+
const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
|
|
75
|
+
const nextSection: Record<string, unknown> = {
|
|
76
|
+
...existing,
|
|
77
|
+
enabled: true,
|
|
78
|
+
token: result.access_token,
|
|
79
|
+
userId: result.agent.user_id,
|
|
80
|
+
};
|
|
81
|
+
if (result.refresh_token) {
|
|
82
|
+
nextSection.refreshToken = result.refresh_token;
|
|
83
|
+
}
|
|
84
|
+
return mergeOpenclawClawchatToolAllow({
|
|
85
|
+
...cfg,
|
|
86
|
+
channels: { ...channels, [CHANNEL_ID]: nextSection },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function persistLoginConfig(
|
|
91
|
+
params: LoginParams,
|
|
92
|
+
result: AgentConnectResult,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
if (params.mutateConfigFile) {
|
|
95
|
+
await params.mutateConfigFile({
|
|
96
|
+
afterWrite: { mode: "auto" },
|
|
97
|
+
mutate(draft) {
|
|
98
|
+
Object.assign(draft, buildLoginConfig(draft, result));
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (params.persistConfig) {
|
|
105
|
+
await params.persistConfig(buildLoginConfig(params.cfg, result));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw new Error("openclaw-clawchat: mutateConfigFile is required to persist login credentials");
|
|
110
|
+
}
|
|
111
|
+
|
|
59
112
|
/**
|
|
60
113
|
* Run the `openclaw channels login --channel openclaw-clawchat` flow:
|
|
61
114
|
* 1. Read the existing channel section; require `baseUrl` to be set so we
|
|
@@ -63,7 +116,7 @@ async function promptInviteCodeFromStdin(runtime: {
|
|
|
63
116
|
* 2. Prompt the user for an invite code on stdin.
|
|
64
117
|
* 3. POST it to `${baseUrl}/v1/agents/connect`.
|
|
65
118
|
* 4. Write the returned `websocket_url` / `token` / `user_id` back into
|
|
66
|
-
* the config so subsequent
|
|
119
|
+
* the config so subsequent Gateway runs pick them up.
|
|
67
120
|
*
|
|
68
121
|
* Errors surface with clear messages (missing baseUrl, empty invite,
|
|
69
122
|
* server-side rejection) so the caller can relay them to the operator.
|
|
@@ -111,33 +164,13 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
|
|
|
111
164
|
);
|
|
112
165
|
}
|
|
113
166
|
|
|
114
|
-
// Merge credentials into cfg.channels.openclaw-clawchat and persist
|
|
115
|
-
// immediately so a subsequent `openclaw gateway run` picks them up
|
|
116
|
-
// without any manual edit. `baseUrl` / `websocketUrl` stay untouched —
|
|
117
|
-
// the built-in defaults (or operator overrides) remain authoritative
|
|
118
|
-
// because `/v1/agents/connect` doesn't return them.
|
|
119
|
-
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
120
|
-
const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
|
|
121
|
-
const nextSection: Record<string, unknown> = {
|
|
122
|
-
...existing,
|
|
123
|
-
token: result.access_token,
|
|
124
|
-
userId: result.agent.user_id,
|
|
125
|
-
};
|
|
126
|
-
if (result.refresh_token) {
|
|
127
|
-
nextSection.refreshToken = result.refresh_token;
|
|
128
|
-
}
|
|
129
|
-
const nextCfg: OpenClawConfig = {
|
|
130
|
-
...cfg,
|
|
131
|
-
channels: { ...channels, [CHANNEL_ID]: nextSection },
|
|
132
|
-
};
|
|
133
|
-
|
|
134
167
|
const tokenPreview = redactToken(result.access_token);
|
|
135
168
|
runtime.log(
|
|
136
169
|
`Updating config: channels.${CHANNEL_ID}.token=${tokenPreview} userId=${result.agent.user_id}${
|
|
137
170
|
result.refresh_token ? " refreshToken=***" : ""
|
|
138
171
|
} …`,
|
|
139
172
|
);
|
|
140
|
-
await (params
|
|
173
|
+
await persistLoginConfig(params, result);
|
|
141
174
|
runtime.log(`Config file updated.`);
|
|
142
175
|
|
|
143
176
|
runtime.log(
|