@insta-dev01/intclaw 1.0.11 → 1.1.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.
Files changed (49) hide show
  1. package/LICENSE +1 -1
  2. package/README.en.md +424 -0
  3. package/README.md +365 -164
  4. package/index.ts +28 -0
  5. package/openclaw.plugin.json +10 -39
  6. package/package.json +69 -40
  7. package/src/channel.ts +557 -0
  8. package/src/config/accounts.ts +230 -0
  9. package/src/config/schema.ts +144 -0
  10. package/src/core/connection.ts +733 -0
  11. package/src/core/message-handler.ts +1268 -0
  12. package/src/core/provider.ts +106 -0
  13. package/src/core/state.ts +54 -0
  14. package/src/directory.ts +95 -0
  15. package/src/gateway-methods.ts +237 -0
  16. package/src/onboarding.ts +387 -0
  17. package/src/policy.ts +19 -0
  18. package/src/probe.ts +213 -0
  19. package/src/reply-dispatcher.ts +674 -0
  20. package/src/runtime.ts +7 -0
  21. package/src/sdk/helpers.ts +317 -0
  22. package/src/sdk/types.ts +515 -0
  23. package/src/secret-input.ts +19 -0
  24. package/src/services/media/audio.ts +54 -0
  25. package/src/services/media/chunk-upload.ts +293 -0
  26. package/src/services/media/common.ts +154 -0
  27. package/src/services/media/file.ts +70 -0
  28. package/src/services/media/image.ts +67 -0
  29. package/src/services/media/index.ts +10 -0
  30. package/src/services/media/video.ts +162 -0
  31. package/src/services/media.ts +1134 -0
  32. package/src/services/messaging/index.ts +16 -0
  33. package/src/services/messaging/send.ts +137 -0
  34. package/src/services/messaging.ts +800 -0
  35. package/src/targets.ts +45 -0
  36. package/src/types/index.ts +52 -0
  37. package/src/utils/agent.ts +63 -0
  38. package/src/utils/async.ts +51 -0
  39. package/src/utils/constants.ts +9 -0
  40. package/src/utils/http-client.ts +84 -0
  41. package/src/utils/index.ts +8 -0
  42. package/src/utils/logger.ts +78 -0
  43. package/src/utils/session.ts +118 -0
  44. package/src/utils/token.ts +94 -0
  45. package/src/utils/utils-legacy.ts +506 -0
  46. package/.env.example +0 -11
  47. package/skills/intclaw_matrix/SKILL.md +0 -20
  48. package/src/channel/intclaw_channel.js +0 -155
  49. package/src/index.js +0 -23
@@ -0,0 +1,387 @@
1
+ import type {
2
+ ChannelOnboardingAdapter,
3
+ ChannelOnboardingDmPolicy,
4
+ ClawdbotConfig,
5
+ DmPolicy,
6
+ SecretInput,
7
+ WizardPrompter,
8
+ } from "openclaw/plugin-sdk";
9
+ import {
10
+ addWildcardAllowFrom,
11
+ DEFAULT_ACCOUNT_ID,
12
+ formatDocsLink,
13
+ hasConfiguredSecretInput,
14
+ } from "./sdk/helpers.ts";
15
+ import { promptSingleChannelSecretInput } from "openclaw/plugin-sdk";
16
+ import { resolveIntclawCredentials } from "./config/accounts.ts";
17
+ import { probeIntclaw } from "./probe.ts";
18
+ import type { IntclawConfig } from "./types/index.ts";
19
+
20
+ const channel = "intclaw-connector" as const;
21
+
22
+ function normalizeString(value: unknown): string | undefined {
23
+ if (typeof value === "number") {
24
+ return String(value);
25
+ }
26
+ if (typeof value !== "string") {
27
+ return undefined;
28
+ }
29
+ const trimmed = value.trim();
30
+ return trimmed || undefined;
31
+ }
32
+
33
+ function setIntclawDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
34
+ const allowFrom =
35
+ dmPolicy === "open"
36
+ ? addWildcardAllowFrom(cfg.channels?.["intclaw-connector"]?.allowFrom)?.map((entry) => String(entry))
37
+ : undefined;
38
+ return {
39
+ ...cfg,
40
+ channels: {
41
+ ...cfg.channels,
42
+ "intclaw-connector": {
43
+ ...cfg.channels?.["intclaw-connector"],
44
+ dmPolicy,
45
+ ...(allowFrom ? { allowFrom } : {}),
46
+ },
47
+ },
48
+ };
49
+ }
50
+
51
+ function setIntclawAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
52
+ return {
53
+ ...cfg,
54
+ channels: {
55
+ ...cfg.channels,
56
+ "intclaw-connector": {
57
+ ...cfg.channels?.["intclaw-connector"],
58
+ allowFrom,
59
+ },
60
+ },
61
+ };
62
+ }
63
+
64
+ function parseAllowFromInput(raw: string): string[] {
65
+ return raw
66
+ .split(/[\n,;]+/g)
67
+ .map((entry) => entry.trim())
68
+ .filter(Boolean);
69
+ }
70
+
71
+ async function promptIntclawAllowFrom(params: {
72
+ cfg: ClawdbotConfig;
73
+ prompter: WizardPrompter;
74
+ }): Promise<ClawdbotConfig> {
75
+ const existing = params.cfg.channels?.["intclaw-connector"]?.allowFrom ?? [];
76
+ await params.prompter.note(
77
+ [
78
+ "Allowlist IntClaw DMs by user ID.",
79
+ "You can find user ID in IntClaw admin console or via API.",
80
+ "Examples:",
81
+ "- user123456",
82
+ "- user789012",
83
+ ].join("\n"),
84
+ "IntClaw allowlist",
85
+ );
86
+
87
+ while (true) {
88
+ const entry = await params.prompter.text({
89
+ message: "IntClaw allowFrom (user IDs)",
90
+ placeholder: "user123456, user789012",
91
+ initialValue: existing[0] ? String(existing[0]) : undefined,
92
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
93
+ });
94
+ const parts = parseAllowFromInput(String(entry));
95
+ if (parts.length === 0) {
96
+ await params.prompter.note("Enter at least one user.", "IntClaw allowlist");
97
+ continue;
98
+ }
99
+
100
+ const unique = [
101
+ ...new Set([
102
+ ...existing.map((v: string | number) => String(v).trim()).filter(Boolean),
103
+ ...parts,
104
+ ]),
105
+ ];
106
+ return setIntclawAllowFrom(params.cfg, unique);
107
+ }
108
+ }
109
+
110
+ async function noteIntclawCredentialHelp(prompter: WizardPrompter): Promise<void> {
111
+ await prompter.note(
112
+ [
113
+ "1) Go to IntClaw Open Platform (open-dev.intclaw.com)",
114
+ "2) Create an enterprise internal app",
115
+ "3) Get App Key (Client ID) and App Secret (Client Secret) from Credentials page",
116
+ "4) Enable required permissions: im:message, im:chat",
117
+ "5) Publish the app or add it to a test group",
118
+ "Tip: you can also set INTCLAW_CLIENT_ID / INTCLAW_CLIENT_SECRET env vars.",
119
+ `Docs: ${formatDocsLink("/channels/intclaw-connector", "intclaw-connector")}`,
120
+ ].join("\n"),
121
+ "IntClaw credentials",
122
+ );
123
+ }
124
+
125
+ async function promptIntclawClientId(params: {
126
+ prompter: WizardPrompter;
127
+ initialValue?: string;
128
+ }): Promise<string> {
129
+ const clientId = String(
130
+ await params.prompter.text({
131
+ message: "Enter IntClaw App Key (Client ID)",
132
+ initialValue: params.initialValue,
133
+ validate: (value) => (value?.trim() ? undefined : "Required"),
134
+ }),
135
+ ).trim();
136
+ return clientId;
137
+ }
138
+
139
+ function setIntclawGroupPolicy(
140
+ cfg: ClawdbotConfig,
141
+ groupPolicy: "open" | "allowlist" | "disabled",
142
+ ): ClawdbotConfig {
143
+ return {
144
+ ...cfg,
145
+ channels: {
146
+ ...cfg.channels,
147
+ "intclaw-connector": {
148
+ ...cfg.channels?.["intclaw-connector"],
149
+ enabled: true,
150
+ groupPolicy,
151
+ },
152
+ },
153
+ };
154
+ }
155
+
156
+ function setIntclawGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
157
+ return {
158
+ ...cfg,
159
+ channels: {
160
+ ...cfg.channels,
161
+ "intclaw-connector": {
162
+ ...cfg.channels?.["intclaw-connector"],
163
+ groupAllowFrom,
164
+ },
165
+ },
166
+ };
167
+ }
168
+
169
+ const dmPolicy: ChannelOnboardingDmPolicy = {
170
+ label: "IntClaw",
171
+ channel,
172
+ policyKey: "channels.intclaw-connector.dmPolicy",
173
+ allowFromKey: "channels.intclaw-connector.allowFrom",
174
+ getCurrent: (cfg) => (cfg.channels?.["intclaw-connector"] as IntclawConfig | undefined)?.dmPolicy ?? "open",
175
+ setPolicy: (cfg, policy) => setIntclawDmPolicy(cfg, policy),
176
+ promptAllowFrom: promptIntclawAllowFrom,
177
+ };
178
+
179
+ export const intclawOnboardingAdapter: ChannelOnboardingAdapter = {
180
+ channel,
181
+ getStatus: async ({ cfg }) => {
182
+ const intclawCfg = cfg.channels?.["intclaw-connector"] as IntclawConfig | undefined;
183
+
184
+ const isClientIdConfigured = (value: unknown): boolean => {
185
+ const asString = normalizeString(value);
186
+ if (asString) {
187
+ return true;
188
+ }
189
+ if (!value || typeof value !== "object") {
190
+ return false;
191
+ }
192
+ const rec = value as Record<string, unknown>;
193
+ const source = normalizeString(rec.source)?.toLowerCase();
194
+ const id = normalizeString(rec.id);
195
+ if (source === "env" && id) {
196
+ return Boolean(normalizeString(process.env[id]));
197
+ }
198
+ return hasConfiguredSecretInput(value);
199
+ };
200
+
201
+ const topLevelConfigured = Boolean(
202
+ isClientIdConfigured(intclawCfg?.clientId) && hasConfiguredSecretInput(intclawCfg?.clientSecret),
203
+ );
204
+
205
+ const accountConfigured = Object.values(intclawCfg?.accounts ?? {}).some((account) => {
206
+ if (!account || typeof account !== "object") {
207
+ return false;
208
+ }
209
+ const hasOwnClientId = Object.prototype.hasOwnProperty.call(account, "clientId");
210
+ const hasOwnClientSecret = Object.prototype.hasOwnProperty.call(account, "clientSecret");
211
+ const accountClientIdConfigured = hasOwnClientId
212
+ ? isClientIdConfigured((account as Record<string, unknown>).clientId)
213
+ : isClientIdConfigured(intclawCfg?.clientId);
214
+ const accountSecretConfigured = hasOwnClientSecret
215
+ ? hasConfiguredSecretInput((account as Record<string, unknown>).clientSecret)
216
+ : hasConfiguredSecretInput(intclawCfg?.clientSecret);
217
+ return Boolean(accountClientIdConfigured && accountSecretConfigured);
218
+ });
219
+
220
+ const configured = topLevelConfigured || accountConfigured;
221
+ const resolvedCredentials = resolveIntclawCredentials(intclawCfg, {
222
+ allowUnresolvedSecretRef: true,
223
+ });
224
+
225
+ // Try to probe if configured
226
+ let probeResult = null;
227
+ if (configured && resolvedCredentials) {
228
+ try {
229
+ probeResult = await probeIntclaw(resolvedCredentials);
230
+ } catch {
231
+ // Ignore probe errors
232
+ }
233
+ }
234
+
235
+ const statusLines: string[] = [];
236
+ if (!configured) {
237
+ statusLines.push("IntClaw: needs app credentials");
238
+ } else if (probeResult?.ok) {
239
+ statusLines.push(
240
+ `IntClaw: connected as ${probeResult.botName ?? "bot"}`,
241
+ );
242
+ } else {
243
+ statusLines.push("IntClaw: configured (connection not verified)");
244
+ }
245
+
246
+ return {
247
+ channel,
248
+ configured,
249
+ statusLines,
250
+ selectionHint: configured ? "configured" : "needs app creds",
251
+ quickstartScore: configured ? 2 : 0,
252
+ };
253
+ },
254
+
255
+ configure: async ({ cfg, prompter }) => {
256
+ const intclawCfg = cfg.channels?.["intclaw-connector"] as IntclawConfig | undefined;
257
+ const resolved = resolveIntclawCredentials(intclawCfg, {
258
+ allowUnresolvedSecretRef: true,
259
+ });
260
+ const hasConfigSecret = hasConfiguredSecretInput(intclawCfg?.clientSecret);
261
+ const hasConfigCreds = Boolean(
262
+ typeof intclawCfg?.clientId === "string" && intclawCfg.clientId.trim() && hasConfigSecret,
263
+ );
264
+ const canUseEnv = Boolean(
265
+ !hasConfigCreds && process.env.INTCLAW_CLIENT_ID?.trim() && process.env.INTCLAW_CLIENT_SECRET?.trim(),
266
+ );
267
+
268
+ let next = cfg;
269
+ let clientId: string | null = null;
270
+ let clientSecret: SecretInput | null = null;
271
+ let clientSecretProbeValue: string | null = null;
272
+
273
+ if (!resolved) {
274
+ await noteIntclawCredentialHelp(prompter);
275
+ }
276
+
277
+ const clientSecretResult = await promptSingleChannelSecretInput({
278
+ cfg: next,
279
+ prompter,
280
+ providerHint: "intclaw",
281
+ credentialLabel: "App Secret (Client Secret)",
282
+ accountConfigured: Boolean(resolved),
283
+ canUseEnv,
284
+ hasConfigToken: hasConfigSecret,
285
+ envPrompt: "INTCLAW_CLIENT_ID + INTCLAW_CLIENT_SECRET detected. Use env vars?",
286
+ keepPrompt: "IntClaw App Secret already configured. Keep it?",
287
+ inputPrompt: "Enter IntClaw App Secret (Client Secret)",
288
+ preferredEnvVar: "INTCLAW_CLIENT_SECRET",
289
+ });
290
+
291
+ if (clientSecretResult.action === "use-env") {
292
+ next = {
293
+ ...next,
294
+ channels: {
295
+ ...next.channels,
296
+ "intclaw-connector": { ...next.channels?.["intclaw-connector"], enabled: true },
297
+ },
298
+ };
299
+ } else if (clientSecretResult.action === "set") {
300
+ clientSecret = clientSecretResult.value;
301
+ clientSecretProbeValue = clientSecretResult.resolvedValue;
302
+ clientId = await promptIntclawClientId({
303
+ prompter,
304
+ initialValue:
305
+ normalizeString(intclawCfg?.clientId) ?? normalizeString(process.env.INTCLAW_CLIENT_ID),
306
+ });
307
+ }
308
+
309
+ if (clientId && clientSecret) {
310
+ next = {
311
+ ...next,
312
+ channels: {
313
+ ...next.channels,
314
+ "intclaw-connector": {
315
+ ...next.channels?.["intclaw-connector"],
316
+ enabled: true,
317
+ clientId,
318
+ clientSecret,
319
+ },
320
+ },
321
+ };
322
+
323
+ // Test connection
324
+ try {
325
+ const probe = await probeIntclaw({
326
+ clientId,
327
+ clientSecret: clientSecretProbeValue ?? undefined,
328
+ });
329
+ if (probe.ok) {
330
+ await prompter.note(
331
+ `Connected as ${probe.botName ?? "bot"}`,
332
+ "IntClaw connection test",
333
+ );
334
+ } else {
335
+ await prompter.note(
336
+ `Connection failed: ${probe.error ?? "unknown error"}`,
337
+ "IntClaw connection test",
338
+ );
339
+ }
340
+ } catch (err) {
341
+ await prompter.note(`Connection test failed: ${String(err)}`, "IntClaw connection test");
342
+ }
343
+ }
344
+
345
+ // Group policy
346
+ const groupPolicy = await prompter.select({
347
+ message: "Group chat policy",
348
+ options: [
349
+ { value: "allowlist", label: "Allowlist - only respond in specific groups" },
350
+ { value: "open", label: "Open - respond in all groups (requires mention)" },
351
+ { value: "disabled", label: "Disabled - don't respond in groups" },
352
+ ],
353
+ initialValue: (next.channels?.["intclaw-connector"] as IntclawConfig | undefined)?.groupPolicy ?? "open",
354
+ });
355
+ if (groupPolicy) {
356
+ next = setIntclawGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
357
+ }
358
+
359
+ // Group allowlist if needed
360
+ if (groupPolicy === "allowlist") {
361
+ const existing = (next.channels?.["intclaw-connector"] as IntclawConfig | undefined)?.groupAllowFrom ?? [];
362
+ const entry = await prompter.text({
363
+ message: "Group chat allowlist (conversation IDs)",
364
+ placeholder: "cidxxxx, cidyyyy",
365
+ initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
366
+ });
367
+ if (entry) {
368
+ const parts = parseAllowFromInput(String(entry));
369
+ if (parts.length > 0) {
370
+ next = setIntclawGroupAllowFrom(next, parts);
371
+ }
372
+ }
373
+ }
374
+
375
+ return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
376
+ },
377
+
378
+ dmPolicy,
379
+
380
+ disable: (cfg) => ({
381
+ ...cfg,
382
+ channels: {
383
+ ...cfg.channels,
384
+ "intclaw-connector": { ...cfg.channels?.["intclaw-connector"], enabled: false },
385
+ },
386
+ }),
387
+ };
package/src/policy.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { ToolPolicy } from "openclaw/plugin-sdk";
2
+ import type { ResolvedIntclawAccount } from "./types/index.ts";
3
+
4
+ export function resolveIntclawGroupToolPolicy(params: {
5
+ account: ResolvedIntclawAccount;
6
+ groupId: string;
7
+ }): ToolPolicy | undefined {
8
+ const { account, groupId } = params;
9
+ const intclawCfg = account.config;
10
+
11
+ // Check group-specific policy first
12
+ const groupConfig = intclawCfg?.groups?.[groupId];
13
+ if (groupConfig?.tools) {
14
+ return groupConfig.tools;
15
+ }
16
+
17
+ // Fall back to account-level default (allow all)
18
+ return { allow: ["*"] };
19
+ }
package/src/probe.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { raceWithTimeoutAndAbort } from "./utils/async.ts";
2
+ import type { IntclawProbeResult } from "./types/index.ts";
3
+ import { INTCLAW_CONFIG } from "../config.ts";
4
+
5
+ /** LRU Cache for probe results to reduce repeated health-check calls. */
6
+ class LRUCache<K, V> {
7
+ private cache = new Map<K, V>();
8
+ private maxSize: number;
9
+
10
+ constructor(maxSize: number) {
11
+ this.maxSize = maxSize;
12
+ }
13
+
14
+ get(key: K): V | undefined {
15
+ const value = this.cache.get(key);
16
+ if (value !== undefined) {
17
+ // 重新插入以更新访问顺序
18
+ this.cache.delete(key);
19
+ this.cache.set(key, value);
20
+ }
21
+ return value;
22
+ }
23
+
24
+ set(key: K, value: V): void {
25
+ // 如果已存在,先删除(更新顺序)
26
+ if (this.cache.has(key)) {
27
+ this.cache.delete(key);
28
+ }
29
+
30
+ this.cache.set(key, value);
31
+
32
+ // 超过大小限制时删除最旧的(最少使用的)
33
+ if (this.cache.size > this.maxSize) {
34
+ const oldest = this.cache.keys().next().value;
35
+ if (oldest !== undefined) {
36
+ this.cache.delete(oldest);
37
+ }
38
+ }
39
+ }
40
+
41
+ clear(): void {
42
+ this.cache.clear();
43
+ }
44
+ }
45
+
46
+ const probeCache = new LRUCache<string, { result: IntclawProbeResult; expiresAt: number }>(64);
47
+ const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
48
+ const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
49
+ export const INTCLAW_PROBE_REQUEST_TIMEOUT_MS = 10_000;
50
+ export type ProbeIntclawOptions = {
51
+ timeoutMs?: number;
52
+ abortSignal?: AbortSignal;
53
+ };
54
+
55
+ type IntclawBotInfoResponse = {
56
+ errcode?: number;
57
+ errmsg?: string;
58
+ nick?: string;
59
+ unionid?: string;
60
+ };
61
+
62
+ function setCachedProbeResult(
63
+ cacheKey: string,
64
+ result: IntclawProbeResult,
65
+ ttlMs: number,
66
+ ): IntclawProbeResult {
67
+ probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
68
+ return result;
69
+ }
70
+
71
+ export async function probeIntclaw(
72
+ creds?: { clientId: string; clientSecret: string; accountId?: string },
73
+ options: ProbeIntclawOptions = {},
74
+ ): Promise<IntclawProbeResult> {
75
+ if (!creds?.clientId || !creds?.clientSecret) {
76
+ return {
77
+ ok: false,
78
+ error: "missing credentials (clientId, clientSecret)",
79
+ };
80
+ }
81
+ if (options.abortSignal?.aborted) {
82
+ return {
83
+ ok: false,
84
+ clientId: creds.clientId,
85
+ error: "probe aborted",
86
+ };
87
+ }
88
+
89
+ const timeoutMs = options.timeoutMs ?? INTCLAW_PROBE_REQUEST_TIMEOUT_MS;
90
+
91
+ // Return cached result if still valid.
92
+ const cacheKey = creds.accountId ?? `${creds.clientId}:${creds.clientSecret.slice(0, 8)}`;
93
+ const cached = probeCache.get(cacheKey);
94
+ if (cached && cached.expiresAt > Date.now()) {
95
+ return cached.result;
96
+ }
97
+
98
+ try {
99
+ // Get access token
100
+ const tokenResponse = await raceWithTimeoutAndAbort(
101
+ fetch(`${INTCLAW_CONFIG.API_BASE_URL}/v1.0/oauth2/accessToken`, {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify({
105
+ appKey: creds.clientId,
106
+ appSecret: creds.clientSecret,
107
+ }),
108
+ }),
109
+ { timeoutMs, abortSignal: options.abortSignal },
110
+ );
111
+
112
+ if (tokenResponse.status === "aborted") {
113
+ return {
114
+ ok: false,
115
+ clientId: creds.clientId,
116
+ error: "probe aborted",
117
+ };
118
+ }
119
+ if (tokenResponse.status === "timeout") {
120
+ return setCachedProbeResult(
121
+ cacheKey,
122
+ {
123
+ ok: false,
124
+ clientId: creds.clientId,
125
+ error: `probe timed out after ${timeoutMs}ms`,
126
+ },
127
+ PROBE_ERROR_TTL_MS,
128
+ );
129
+ }
130
+
131
+ const tokenData = await tokenResponse.value.json() as { accessToken?: string };
132
+ if (!tokenData.accessToken) {
133
+ return setCachedProbeResult(
134
+ cacheKey,
135
+ {
136
+ ok: false,
137
+ clientId: creds.clientId,
138
+ error: "failed to get access token",
139
+ },
140
+ PROBE_ERROR_TTL_MS,
141
+ );
142
+ }
143
+
144
+ // Get bot info
145
+ const botResponse = await raceWithTimeoutAndAbort(
146
+ fetch(`${INTCLAW_CONFIG.API_BASE_URL}/v1.0/contact/users/me`, {
147
+ method: "GET",
148
+ headers: {
149
+ "x-acs-intclaw-access-token": tokenData.accessToken,
150
+ "Content-Type": "application/json",
151
+ },
152
+ }),
153
+ { timeoutMs, abortSignal: options.abortSignal },
154
+ );
155
+
156
+ if (botResponse.status === "aborted") {
157
+ return {
158
+ ok: false,
159
+ clientId: creds.clientId,
160
+ error: "probe aborted",
161
+ };
162
+ }
163
+ if (botResponse.status === "timeout") {
164
+ return setCachedProbeResult(
165
+ cacheKey,
166
+ {
167
+ ok: false,
168
+ clientId: creds.clientId,
169
+ error: `probe timed out after ${timeoutMs}ms`,
170
+ },
171
+ PROBE_ERROR_TTL_MS,
172
+ );
173
+ }
174
+
175
+ const botData = await botResponse.value.json() as IntclawBotInfoResponse;
176
+ if (botData.errcode && botData.errcode !== 0) {
177
+ return setCachedProbeResult(
178
+ cacheKey,
179
+ {
180
+ ok: false,
181
+ clientId: creds.clientId,
182
+ error: `API error: ${botData.errmsg || `code ${botData.errcode}`}`,
183
+ },
184
+ PROBE_ERROR_TTL_MS,
185
+ );
186
+ }
187
+
188
+ return setCachedProbeResult(
189
+ cacheKey,
190
+ {
191
+ ok: true,
192
+ clientId: creds.clientId,
193
+ botName: botData.nick,
194
+ },
195
+ PROBE_SUCCESS_TTL_MS,
196
+ );
197
+ } catch (err) {
198
+ return setCachedProbeResult(
199
+ cacheKey,
200
+ {
201
+ ok: false,
202
+ clientId: creds.clientId,
203
+ error: err instanceof Error ? err.message : String(err),
204
+ },
205
+ PROBE_ERROR_TTL_MS,
206
+ );
207
+ }
208
+ }
209
+
210
+ /** Clear the probe cache (for testing). */
211
+ export function clearProbeCache(): void {
212
+ probeCache.clear();
213
+ }