@decentchat/decentchat-plugin 0.1.9

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/channel.ts ADDED
@@ -0,0 +1,1059 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ChannelPlugin, ChannelSetupWizard, ChannelSetupInput, OpenClawConfig } from "openclaw/plugin-sdk";
5
+ import { createStandardChannelSetupStatus, patchTopLevelChannelConfigSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup";
6
+ import { z } from "zod";
7
+
8
+ import { assertCompanyBootstrapAgentInstallation, ensureCompanyBootstrapRuntime, resolveCompanyManifestPath } from "@decentchat/company-sim";
9
+ import { SeedPhraseManager } from "@decentchat/protocol";
10
+ import { startDecentChatPeer } from "./monitor.js";
11
+ import { getActivePeer, listActivePeerAccountIds } from "./peer-registry.js";
12
+ import { buildDecentChatRuntimeBootstrapKey, invalidateDecentChatBootstrapKey, runDecentChatBootstrapOnce } from "./runtime.js";
13
+ import type { DecentChatChannelConfig, OpenClawConfigShape, ResolvedDecentChatAccount } from "./types.js";
14
+
15
+ const DEFAULT_ACCOUNT_ID = "default";
16
+ const DECENTCHAT_STARTUP_STAGGER_MS = 6_000;
17
+
18
+ function sanitizeDecentChatAccountPathSegment(accountId: string): string {
19
+ const sanitized = accountId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-");
20
+ return sanitized || DEFAULT_ACCOUNT_ID;
21
+ }
22
+
23
+ function resolveDecentChatDataDir(cfg: any, accountId: string, configuredDataDir: unknown): string | undefined {
24
+ if (typeof configuredDataDir === "string" && configuredDataDir.trim()) {
25
+ return configuredDataDir.trim();
26
+ }
27
+
28
+ const accountIds = listDecentChatAccountIds(cfg);
29
+ if (accountIds.length === 1 && accountIds[0] === DEFAULT_ACCOUNT_ID && accountId === DEFAULT_ACCOUNT_ID) {
30
+ return undefined;
31
+ }
32
+
33
+ return join(homedir(), ".openclaw", "data", "decentchat", sanitizeDecentChatAccountPathSegment(accountId));
34
+ }
35
+
36
+ function formatPairingApproveHint(channelId: string): string {
37
+ return `Approve via: \`openclaw pairing list ${channelId}\` / \`openclaw pairing approve ${channelId} <code>\``;
38
+ }
39
+
40
+ function buildChannelConfigSchema(schema: z.ZodTypeAny): { schema: Record<string, unknown> } {
41
+ const schemaWithJson = schema as z.ZodTypeAny & {
42
+ toJSONSchema?: (opts?: { target?: string; unrepresentable?: string }) => Record<string, unknown>;
43
+ };
44
+ if (typeof schemaWithJson.toJSONSchema === "function") {
45
+ return {
46
+ schema: schemaWithJson.toJSONSchema({
47
+ target: "draft-07",
48
+ unrepresentable: "any",
49
+ }),
50
+ };
51
+ }
52
+ return {
53
+ schema: {
54
+ type: "object",
55
+ additionalProperties: true,
56
+ },
57
+ };
58
+ }
59
+
60
+ const DecentChatConfigSchema = z.object({
61
+ enabled: z.boolean().optional(),
62
+ seedPhrase: z.string().optional(),
63
+ signalingServer: z.string().optional(),
64
+ invites: z.array(z.string()).optional(),
65
+ alias: z.string().optional().default("DecentChat Bot"),
66
+ dataDir: z.string().optional(),
67
+ streamEnabled: z.boolean().optional().default(true),
68
+ dmPolicy: z.enum(["open", "pairing", "allowlist", "disabled"]).optional().default("open"),
69
+ defaultAccount: z.string().optional(),
70
+ replyToMode: z.enum(["off", "first", "all"]).optional().default("all"),
71
+ // Flattened from replyToModeByChatType object (Control UI can't render nested objects)
72
+ replyToModeDirect: z.enum(["off", "first", "all"]).optional(),
73
+ replyToModeGroup: z.enum(["off", "first", "all"]).optional(),
74
+ replyToModeChannel: z.enum(["off", "first", "all"]).optional(),
75
+ // Flattened from thread object
76
+ threadHistoryScope: z.enum(["thread", "channel"]).optional().default("thread"),
77
+ threadInheritParent: z.boolean().optional().default(false),
78
+ threadInitialHistoryLimit: z.number().int().min(0).optional().default(20),
79
+ companySimBootstrapEnabled: z.boolean().optional().default(false),
80
+ companySimBootstrapMode: z.enum(["runtime", "off"]).optional().default("runtime"),
81
+ companySimBootstrapManifestPath: z.string().optional(),
82
+ companySimBootstrapTargetWorkspaceId: z.string().optional(),
83
+ companySimBootstrapTargetInviteCode: z.string().optional(),
84
+ // Legacy nested forms still accepted at runtime via passthrough
85
+ // (resolveDecentChatAccount reads ch.replyToModeByChatType, ch.thread, ch.channels)
86
+ // but excluded from schema so Control UI can render all fields cleanly.
87
+ }).passthrough();
88
+
89
+ function isRecord(value: unknown): value is Record<string, any> {
90
+ return !!value && typeof value === "object" && !Array.isArray(value);
91
+ }
92
+
93
+ function getDecentChatChannelConfig(cfg: any): DecentChatChannelConfig {
94
+ const ch = cfg?.channels?.decentchat;
95
+ return isRecord(ch) ? (ch as DecentChatChannelConfig) : {};
96
+ }
97
+
98
+ export function listDecentChatAccountIds(cfg: any): string[] {
99
+ const channelCfg = getDecentChatChannelConfig(cfg);
100
+ const accounts = channelCfg.accounts;
101
+ if (!isRecord(accounts)) return [DEFAULT_ACCOUNT_ID];
102
+ const ids = Object.keys(accounts).map((id) => id.trim()).filter(Boolean);
103
+ if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
104
+ return [...new Set(ids)].sort((a, b) => a.localeCompare(b));
105
+ }
106
+
107
+ export function resolveDefaultDecentChatAccountId(cfg: any): string {
108
+ const channelCfg = getDecentChatChannelConfig(cfg);
109
+ const ids = listDecentChatAccountIds(cfg);
110
+ const preferred = typeof channelCfg.defaultAccount === "string" ? channelCfg.defaultAccount.trim() : "";
111
+ if (preferred && ids.includes(preferred)) return preferred;
112
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
113
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
114
+ }
115
+
116
+ export function listDecentChatStartupAccountIds(cfg: any): string[] {
117
+ const ids = listDecentChatAccountIds(cfg);
118
+ if (!ids.includes(DEFAULT_ACCOUNT_ID)) {
119
+ return ids;
120
+ }
121
+
122
+ return [
123
+ DEFAULT_ACCOUNT_ID,
124
+ ...ids.filter((accountId) => accountId !== DEFAULT_ACCOUNT_ID),
125
+ ];
126
+ }
127
+
128
+ export function resolveDecentChatStartupDelayMs(cfg: any, accountId?: string | null): number {
129
+ const resolvedAccountId = accountId?.trim() || resolveDefaultDecentChatAccountId(cfg);
130
+ const startupOrder = listDecentChatStartupAccountIds(cfg);
131
+ const startupIndex = startupOrder.indexOf(resolvedAccountId);
132
+ if (startupIndex <= 0) {
133
+ return 0;
134
+ }
135
+
136
+ return startupIndex * DECENTCHAT_STARTUP_STAGGER_MS;
137
+ }
138
+
139
+ async function waitForDecentChatStartupSlot(delayMs: number, abortSignal?: AbortSignal): Promise<void> {
140
+ if (delayMs <= 0) {
141
+ return;
142
+ }
143
+
144
+ await new Promise<void>((resolve, reject) => {
145
+ const timer = setTimeout(() => {
146
+ abortSignal?.removeEventListener("abort", onAbort);
147
+ resolve();
148
+ }, delayMs);
149
+
150
+ const onAbort = () => {
151
+ clearTimeout(timer);
152
+ abortSignal?.removeEventListener("abort", onAbort);
153
+ reject(new Error("DecentChat startup aborted"));
154
+ };
155
+
156
+ if (abortSignal?.aborted) {
157
+ onAbort();
158
+ return;
159
+ }
160
+
161
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
162
+ });
163
+ }
164
+
165
+ function normalizeStringList(values: string[]): string[] {
166
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
167
+ .sort((a, b) => a.localeCompare(b));
168
+ }
169
+
170
+ function buildCompanyBootstrapRuntimeScope(cfg: any, manifestPath: string): string {
171
+ const accounts = listDecentChatAccountIds(cfg).map((accountId) => {
172
+ const account = resolveDecentChatAccount(cfg, accountId);
173
+ const bootstrap = account.companySimBootstrap;
174
+ return {
175
+ accountId,
176
+ seedFingerprint: account.seedPhrase
177
+ ? createHash('sha256').update(account.seedPhrase).digest('hex').slice(0, 16)
178
+ : '',
179
+ dataDir: account.dataDir?.trim() ?? '',
180
+ companySimManifestPath: account.companySim?.manifestPath?.trim() ?? '',
181
+ invites: normalizeStringList(account.invites),
182
+ bootstrap: bootstrap ? {
183
+ enabled: bootstrap.enabled !== false,
184
+ mode: bootstrap.mode,
185
+ manifestPath: bootstrap.manifestPath?.trim() ?? '',
186
+ targetWorkspaceId: bootstrap.targetWorkspaceId?.trim() ?? '',
187
+ targetInviteCode: bootstrap.targetInviteCode?.trim() ?? '',
188
+ } : null,
189
+ };
190
+ });
191
+
192
+ return createHash('sha256')
193
+ .update(JSON.stringify({ manifestPath, accounts }))
194
+ .digest('hex')
195
+ .slice(0, 20);
196
+ }
197
+
198
+ function mergeObject<T extends Record<string, any> | undefined>(base: T, override: T): T | undefined {
199
+ if (!base && !override) return undefined;
200
+ return {
201
+ ...(base ?? {}),
202
+ ...(override ?? {}),
203
+ } as T;
204
+ }
205
+
206
+ function resolveRawDecentChatAccountConfig(cfg: any, accountId?: string | null): DecentChatChannelConfig {
207
+ const channelCfg = getDecentChatChannelConfig(cfg);
208
+ const resolvedAccountId = (accountId?.trim() || resolveDefaultDecentChatAccountId(cfg));
209
+ const accounts = isRecord(channelCfg.accounts) ? channelCfg.accounts : undefined;
210
+ const accountCfg = accounts && isRecord(accounts[resolvedAccountId])
211
+ ? accounts[resolvedAccountId] as DecentChatChannelConfig
212
+ : undefined;
213
+
214
+ const {
215
+ accounts: _accounts,
216
+ defaultAccount: _defaultAccount,
217
+ ...base
218
+ } = channelCfg;
219
+ const inheritRootStartupConfig = !accounts || resolvedAccountId === DEFAULT_ACCOUNT_ID;
220
+
221
+ return {
222
+ ...base,
223
+ ...(accountCfg ?? {}),
224
+ invites: inheritRootStartupConfig ? (accountCfg?.invites ?? base.invites) : accountCfg?.invites,
225
+ channels: mergeObject(base.channels, accountCfg?.channels),
226
+ replyToModeByChatType: mergeObject(base.replyToModeByChatType, accountCfg?.replyToModeByChatType),
227
+ thread: mergeObject(base.thread, accountCfg?.thread),
228
+ huddle: mergeObject(base.huddle, accountCfg?.huddle),
229
+ companySim: mergeObject(base.companySim, accountCfg?.companySim),
230
+ companySimBootstrap: mergeObject(
231
+ inheritRootStartupConfig ? base.companySimBootstrap : undefined,
232
+ accountCfg?.companySimBootstrap,
233
+ ),
234
+ companySimBootstrapEnabled: inheritRootStartupConfig
235
+ ? (accountCfg?.companySimBootstrapEnabled ?? base.companySimBootstrapEnabled)
236
+ : accountCfg?.companySimBootstrapEnabled,
237
+ companySimBootstrapMode: inheritRootStartupConfig
238
+ ? (accountCfg?.companySimBootstrapMode ?? base.companySimBootstrapMode)
239
+ : accountCfg?.companySimBootstrapMode,
240
+ companySimBootstrapManifestPath: inheritRootStartupConfig
241
+ ? (accountCfg?.companySimBootstrapManifestPath ?? base.companySimBootstrapManifestPath)
242
+ : accountCfg?.companySimBootstrapManifestPath,
243
+ companySimBootstrapTargetWorkspaceId: inheritRootStartupConfig
244
+ ? (accountCfg?.companySimBootstrapTargetWorkspaceId ?? base.companySimBootstrapTargetWorkspaceId)
245
+ : accountCfg?.companySimBootstrapTargetWorkspaceId,
246
+ companySimBootstrapTargetInviteCode: inheritRootStartupConfig
247
+ ? (accountCfg?.companySimBootstrapTargetInviteCode ?? base.companySimBootstrapTargetInviteCode)
248
+ : accountCfg?.companySimBootstrapTargetInviteCode,
249
+ };
250
+ }
251
+
252
+ export function normalizeDecentChatMessagingTarget(raw: string): string | undefined {
253
+ const value = raw.trim();
254
+ if (!value) return undefined;
255
+
256
+ if (value.startsWith("decentchat:channel:")) {
257
+ const channelId = value.slice("decentchat:channel:".length).trim();
258
+ return channelId ? `decentchat:channel:${channelId}` : undefined;
259
+ }
260
+
261
+ if (value.startsWith("channel:")) {
262
+ const channelId = value.slice("channel:".length).trim();
263
+ return channelId ? `decentchat:channel:${channelId}` : undefined;
264
+ }
265
+
266
+ if (value.startsWith("decentchat:")) {
267
+ const rest = value.slice("decentchat:".length).trim();
268
+ if (!rest) return undefined;
269
+ if (rest.startsWith("channel:")) {
270
+ const channelId = rest.slice("channel:".length).trim();
271
+ return channelId ? `decentchat:channel:${channelId}` : undefined;
272
+ }
273
+ return `decentchat:${rest}`;
274
+ }
275
+
276
+ return `decentchat:${value}`;
277
+ }
278
+
279
+ const DECENTCHAT_CHANNEL_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
280
+ const DECENTCHAT_PEER_ID_RE = /^[0-9a-f]{18}$/i;
281
+ const DECENTCHAT_TEST_PEER_ID_RE = /^peer-[a-z0-9-]+$/i;
282
+
283
+ type DecentChatTargetCandidate = {
284
+ kind: "user" | "group";
285
+ id: string;
286
+ name?: string;
287
+ handle?: string;
288
+ rank?: number;
289
+ };
290
+
291
+ type DecentChatResolvedTarget = {
292
+ to: string;
293
+ kind: "user" | "group" | "channel";
294
+ display?: string;
295
+ source: "normalized" | "directory";
296
+ };
297
+
298
+ function looksLikeBareDecentChatPeerId(value: string): boolean {
299
+ return DECENTCHAT_PEER_ID_RE.test(value) || DECENTCHAT_TEST_PEER_ID_RE.test(value);
300
+ }
301
+
302
+ function normalizeDecentChatLookupValue(value: string | undefined): string {
303
+ if (!value) return "";
304
+ return value.trim().toLowerCase()
305
+ .replace(/^decentchat:channel:/, "")
306
+ .replace(/^decentchat:/, "")
307
+ .replace(/^channel:/, "")
308
+ .replace(/^[@#]/, "")
309
+ .trim();
310
+ }
311
+
312
+ function scoreDecentChatTargetCandidate(candidate: DecentChatTargetCandidate, query: string): number {
313
+ const fields = [candidate.name, candidate.handle, candidate.id];
314
+ let best = -1;
315
+ for (const field of fields) {
316
+ const normalizedField = normalizeDecentChatLookupValue(field);
317
+ if (!normalizedField) continue;
318
+ if (normalizedField === query) return 300;
319
+ if (normalizedField.startsWith(query)) best = Math.max(best, 200);
320
+ else if (normalizedField.includes(query)) best = Math.max(best, 100);
321
+ }
322
+ return best;
323
+ }
324
+
325
+ function pickUniqueDecentChatCandidate(candidates: DecentChatTargetCandidate[], query: string): DecentChatTargetCandidate | null {
326
+ const scored = candidates
327
+ .map((candidate) => ({ candidate, score: scoreDecentChatTargetCandidate(candidate, query) }))
328
+ .filter((item) => item.score >= 0)
329
+ .sort((a, b) => b.score - a.score || (b.candidate.rank ?? 0) - (a.candidate.rank ?? 0) || (a.candidate.name ?? a.candidate.id).localeCompare(b.candidate.name ?? b.candidate.id));
330
+
331
+ if (scored.length === 0) return null;
332
+ const bestScore = scored[0]?.score ?? -1;
333
+ const best = scored.filter((item) => item.score == bestScore);
334
+ if (best.length !== 1) return null;
335
+ return best[0]?.candidate ?? null;
336
+ }
337
+
338
+ function buildDecentChatResolvedTarget(candidate: DecentChatTargetCandidate): DecentChatResolvedTarget {
339
+ return {
340
+ to: candidate.kind === "group"
341
+ ? (candidate.id.startsWith("decentchat:channel:") ? candidate.id : `decentchat:channel:${candidate.id}`)
342
+ : (candidate.id.startsWith("decentchat:") ? candidate.id : `decentchat:${candidate.id}`),
343
+ kind: candidate.kind,
344
+ display: candidate.name ?? candidate.handle ?? candidate.id,
345
+ source: "directory",
346
+ };
347
+ }
348
+
349
+ function resolveDecentChatTargetFromActivePeer(raw: string, accountId?: string | null, preferredKind?: "user" | "group" | "channel"): DecentChatResolvedTarget | null {
350
+ const query = normalizeDecentChatLookupValue(raw);
351
+ if (!query) return null;
352
+
353
+ const peer = getActivePeer(accountId?.trim() || DEFAULT_ACCOUNT_ID);
354
+ if (!peer) return null;
355
+
356
+ const peerMatches = peer.listDirectoryPeersLive({ query, limit: 50 }) as DecentChatTargetCandidate[];
357
+ const groupMatches = peer.listDirectoryGroupsLive({ query, limit: 50 }) as DecentChatTargetCandidate[];
358
+
359
+ const userCandidate = pickUniqueDecentChatCandidate(peerMatches, query);
360
+ const groupCandidate = pickUniqueDecentChatCandidate(groupMatches, query);
361
+
362
+ if (preferredKind === "user") return userCandidate ? buildDecentChatResolvedTarget(userCandidate) : null;
363
+ if (preferredKind === "group" || preferredKind === "channel") return groupCandidate ? buildDecentChatResolvedTarget(groupCandidate) : null;
364
+
365
+ if (userCandidate && !groupCandidate) return buildDecentChatResolvedTarget(userCandidate);
366
+ if (groupCandidate && !userCandidate) return buildDecentChatResolvedTarget(groupCandidate);
367
+ if (userCandidate && groupCandidate) return buildDecentChatResolvedTarget(userCandidate);
368
+ return null;
369
+ }
370
+
371
+ async function resolveDecentChatTarget(params: {
372
+ accountId?: string | null;
373
+ input: string;
374
+ normalized: string;
375
+ preferredKind?: "user" | "group" | "channel";
376
+ }): Promise<DecentChatResolvedTarget | null> {
377
+ const rawValue = params.input.trim();
378
+ if (!rawValue) return null;
379
+
380
+ if (rawValue.startsWith("channel:")) {
381
+ const channelId = rawValue.slice("channel:".length).trim();
382
+ return channelId ? { to: `decentchat:channel:${channelId}`, kind: "group", display: channelId, source: "normalized" } : null;
383
+ }
384
+
385
+ if (rawValue.startsWith("decentchat:channel:")) {
386
+ const channelId = rawValue.slice("decentchat:channel:".length).trim();
387
+ return channelId ? { to: `decentchat:channel:${channelId}`, kind: "group", display: channelId, source: "normalized" } : null;
388
+ }
389
+
390
+ if (rawValue.startsWith("decentchat:")) {
391
+ const rest = rawValue.slice("decentchat:".length).trim();
392
+ if (!rest) return null;
393
+ if (rest.startsWith("channel:")) {
394
+ const channelId = rest.slice("channel:".length).trim();
395
+ return channelId ? { to: `decentchat:channel:${channelId}`, kind: "group", display: channelId, source: "normalized" } : null;
396
+ }
397
+ return { to: `decentchat:${rest}`, kind: "user", display: rest, source: "normalized" };
398
+ }
399
+
400
+ if (DECENTCHAT_CHANNEL_ID_RE.test(rawValue)) {
401
+ return { to: `decentchat:channel:${rawValue}`, kind: "group", display: rawValue, source: "normalized" };
402
+ }
403
+
404
+ if (looksLikeBareDecentChatPeerId(rawValue)) {
405
+ return { to: `decentchat:${rawValue}`, kind: "user", display: rawValue, source: "normalized" };
406
+ }
407
+
408
+ return resolveDecentChatTargetFromActivePeer(rawValue, params.accountId, params.preferredKind);
409
+ }
410
+
411
+ export function looksLikeDecentChatTargetId(raw: string, normalized?: string): boolean {
412
+ const rawValue = raw.trim();
413
+ const normalizedValue = (normalized ?? raw).trim();
414
+ if (!rawValue && !normalizedValue) return false;
415
+
416
+ if (rawValue.startsWith("channel:")) return true;
417
+ if (rawValue.startsWith("decentchat:channel:")) return true;
418
+ if (rawValue.startsWith("decentchat:")) return true;
419
+
420
+ if (DECENTCHAT_CHANNEL_ID_RE.test(rawValue)) return true;
421
+ if (looksLikeBareDecentChatPeerId(rawValue)) return true;
422
+
423
+ const accountIds = listActivePeerAccountIds();
424
+ if (accountIds.length === 0) return false;
425
+ return accountIds.some((accountId) => !!resolveDecentChatTargetFromActivePeer(rawValue, accountId));
426
+ }
427
+
428
+ export function resolveDecentChatAccount(cfg: any, accountId?: string | null): ResolvedDecentChatAccount {
429
+ const ch = resolveRawDecentChatAccountConfig(cfg, accountId);
430
+ const resolvedAccountId = accountId?.trim() || resolveDefaultDecentChatAccountId(cfg);
431
+ const seedPhrase = typeof ch.seedPhrase === "string" ? ch.seedPhrase : undefined;
432
+ const bootstrapEnabledRaw = (ch as any).companySimBootstrapEnabled ?? ch.companySimBootstrap?.enabled;
433
+ const bootstrapModeRaw = (ch as any).companySimBootstrapMode ?? ch.companySimBootstrap?.mode;
434
+ const bootstrapManifestPathRaw = (ch as any).companySimBootstrapManifestPath ?? ch.companySimBootstrap?.manifestPath;
435
+ const bootstrapTargetWorkspaceIdRaw = (ch as any).companySimBootstrapTargetWorkspaceId ?? ch.companySimBootstrap?.targetWorkspaceId;
436
+ const bootstrapTargetInviteCodeRaw = (ch as any).companySimBootstrapTargetInviteCode ?? ch.companySimBootstrap?.targetInviteCode;
437
+ const hasBootstrapConfig = bootstrapEnabledRaw !== undefined
438
+ || bootstrapModeRaw !== undefined
439
+ || typeof bootstrapManifestPathRaw === "string"
440
+ || typeof bootstrapTargetWorkspaceIdRaw === "string"
441
+ || typeof bootstrapTargetInviteCodeRaw === "string";
442
+
443
+ return {
444
+ accountId: resolvedAccountId,
445
+ enabled: ch.enabled !== false,
446
+ dmPolicy: ch.dmPolicy ?? "open",
447
+ configured: !!seedPhrase?.trim(),
448
+ seedPhrase,
449
+ signalingServer: ch.signalingServer ?? "https://decentchat.app/peerjs",
450
+ invites: ch.invites ?? [],
451
+ alias: ch.alias ?? "DecentChat Bot",
452
+ dataDir: resolveDecentChatDataDir(cfg, resolvedAccountId, ch.dataDir),
453
+ streamEnabled: ch.streamEnabled !== false,
454
+ replyToMode: ch.replyToMode ?? "all",
455
+ replyToModeByChatType: {
456
+ direct: (ch as any).replyToModeDirect ?? ch.replyToModeByChatType?.direct,
457
+ group: (ch as any).replyToModeGroup ?? ch.replyToModeByChatType?.group,
458
+ channel: (ch as any).replyToModeChannel ?? ch.replyToModeByChatType?.channel,
459
+ },
460
+ thread: {
461
+ historyScope: (ch as any).threadHistoryScope ?? ch.thread?.historyScope ?? "thread",
462
+ inheritParent: (ch as any).threadInheritParent ?? ch.thread?.inheritParent ?? false,
463
+ initialHistoryLimit: (ch as any).threadInitialHistoryLimit ?? ch.thread?.initialHistoryLimit ?? 20,
464
+ },
465
+ huddle: ch.huddle ? {
466
+ enabled: ch.huddle.enabled,
467
+ autoJoin: ch.huddle.autoJoin,
468
+ sttEngine: ch.huddle.sttEngine,
469
+ whisperModel: ch.huddle.whisperModel,
470
+ sttLanguage: ch.huddle.sttLanguage,
471
+ sttApiKey: ch.huddle.sttApiKey,
472
+ ttsVoice: ch.huddle.ttsVoice,
473
+ vadSilenceMs: ch.huddle.vadSilenceMs,
474
+ vadThreshold: ch.huddle.vadThreshold,
475
+ } : undefined,
476
+ companySim: ch.companySim ? {
477
+ enabled: ch.companySim.enabled !== false,
478
+ manifestPath: ch.companySim.manifestPath,
479
+ companyId: ch.companySim.companyId,
480
+ employeeId: ch.companySim.employeeId,
481
+ roleFilesDir: ch.companySim.roleFilesDir,
482
+ silentChannelIds: Array.isArray(ch.companySim.silentChannelIds)
483
+ ? normalizeStringList(ch.companySim.silentChannelIds.filter((value): value is string => typeof value === 'string'))
484
+ : undefined,
485
+ } : undefined,
486
+ companySimBootstrap: hasBootstrapConfig ? {
487
+ enabled: bootstrapEnabledRaw !== false,
488
+ mode: bootstrapModeRaw === "off" ? "off" : "runtime",
489
+ manifestPath: typeof bootstrapManifestPathRaw === "string" ? bootstrapManifestPathRaw : undefined,
490
+ targetWorkspaceId: typeof bootstrapTargetWorkspaceIdRaw === "string" && bootstrapTargetWorkspaceIdRaw.trim()
491
+ ? bootstrapTargetWorkspaceIdRaw.trim()
492
+ : undefined,
493
+ targetInviteCode: typeof bootstrapTargetInviteCodeRaw === "string" && bootstrapTargetInviteCodeRaw.trim()
494
+ ? bootstrapTargetInviteCodeRaw.trim()
495
+ : undefined,
496
+ } : undefined,
497
+ };
498
+ }
499
+
500
+ function getPeerForContext(cfg: any, accountId?: string | null) {
501
+ const resolvedAccountId = accountId?.trim() || resolveDefaultDecentChatAccountId(cfg);
502
+ return getActivePeer(resolvedAccountId);
503
+ }
504
+
505
+ export async function bootstrapDecentChatCompanySimForStartup(params: {
506
+ cfg: any;
507
+ accountId: string;
508
+ account: ResolvedDecentChatAccount;
509
+ log?: { info?: (message: string) => void; warn?: (message: string) => void; error?: (message: string) => void };
510
+ }): Promise<void> {
511
+ const bootstrap = params.account.companySimBootstrap;
512
+ if (!bootstrap?.enabled || bootstrap.mode === "off") return;
513
+
514
+ const manifestPath = bootstrap.manifestPath?.trim();
515
+ if (!manifestPath) {
516
+ throw new Error(`Company bootstrap is enabled for account ${params.accountId} but companySimBootstrapManifestPath is missing`);
517
+ }
518
+
519
+ const resolvedManifestPath = resolveCompanyManifestPath(manifestPath);
520
+ const runtimeScope = buildCompanyBootstrapRuntimeScope(params.cfg, resolvedManifestPath);
521
+
522
+ await runDecentChatBootstrapOnce(buildDecentChatRuntimeBootstrapKey(resolvedManifestPath, runtimeScope), async () => {
523
+ assertCompanyBootstrapAgentInstallation({
524
+ manifestPath: resolvedManifestPath,
525
+ cfg: params.cfg as OpenClawConfigShape,
526
+ });
527
+
528
+ await ensureCompanyBootstrapRuntime({
529
+ manifestPath: resolvedManifestPath,
530
+ accountIds: listDecentChatAccountIds(params.cfg),
531
+ resolveAccount: (accountId) => resolveDecentChatAccount(params.cfg, accountId),
532
+ log: params.log,
533
+ });
534
+ });
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Setup wizard (powers `openclaw configure`)
539
+ // ---------------------------------------------------------------------------
540
+
541
+ const CHANNEL = "decentchat";
542
+
543
+ let _seedPhraseManager: InstanceType<typeof SeedPhraseManager> | undefined;
544
+ function getSeedPhraseManager() {
545
+ if (!_seedPhraseManager) _seedPhraseManager = new SeedPhraseManager();
546
+ return _seedPhraseManager;
547
+ }
548
+
549
+ function validateSeedPhrase(mnemonic: string): string | undefined {
550
+ const result = getSeedPhraseManager().validate(mnemonic);
551
+ if (!result.valid) return result.error ?? "Invalid seed phrase";
552
+ return undefined;
553
+ }
554
+
555
+ const decentChatSetupWizard: ChannelSetupWizard = {
556
+ channel: CHANNEL,
557
+
558
+ resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
559
+ resolveShouldPromptAccountIds: () => false,
560
+
561
+ status: createStandardChannelSetupStatus({
562
+ channelLabel: "DecentChat",
563
+ configuredLabel: "configured",
564
+ unconfiguredLabel: "needs seed phrase",
565
+ configuredHint: "configured",
566
+ unconfiguredHint: "needs seed phrase",
567
+ configuredScore: 1,
568
+ unconfiguredScore: 0,
569
+ includeStatusLine: true,
570
+ resolveConfigured: ({ cfg }) => resolveDecentChatAccount(cfg).configured,
571
+ resolveExtraStatusLines: ({ cfg }) => {
572
+ const account = resolveDecentChatAccount(cfg);
573
+ const lines: string[] = [];
574
+ if (account.alias) lines.push(`Alias: ${account.alias}`);
575
+ if (account.invites.length > 0) lines.push(`Invites: ${account.invites.length}`);
576
+ return lines;
577
+ },
578
+ }),
579
+
580
+ introNote: {
581
+ title: "DecentChat setup",
582
+ lines: [
583
+ "DecentChat is a P2P encrypted messaging network.",
584
+ "Your bot needs a 12-word BIP39 seed phrase to create its identity.",
585
+ "You can generate a new one here or paste an existing one.",
586
+ ],
587
+ },
588
+
589
+ stepOrder: "credentials-first",
590
+
591
+ prepare: async ({ cfg, accountId, prompter }) => {
592
+ const account = resolveDecentChatAccount(cfg, accountId);
593
+ if (account.configured) return;
594
+
595
+ const generateNew = await prompter.confirm({
596
+ message: "Generate a new DecentChat identity?",
597
+ initialValue: true,
598
+ });
599
+
600
+ if (generateNew) {
601
+ const { mnemonic } = getSeedPhraseManager().generate();
602
+ await prompter.note(
603
+ [
604
+ `Your new seed phrase:`,
605
+ ``,
606
+ ` ${mnemonic}`,
607
+ ``,
608
+ `Write this down and store it somewhere safe.`,
609
+ `This is the only way to recover your bot's identity.`,
610
+ ].join("\n"),
611
+ "New identity generated",
612
+ );
613
+ return {
614
+ credentialValues: { privateKey: mnemonic },
615
+ };
616
+ }
617
+
618
+ return;
619
+ },
620
+
621
+ credentials: [
622
+ {
623
+ inputKey: "privateKey" as keyof ChannelSetupInput,
624
+ providerHint: CHANNEL,
625
+ credentialLabel: "seed phrase",
626
+ helpTitle: "DecentChat seed phrase",
627
+ helpLines: [
628
+ "A 12-word BIP39 mnemonic that determines your bot's identity on the network.",
629
+ "All encryption keys are derived from this phrase.",
630
+ ],
631
+ envPrompt: "DECENTCHAT_SEED_PHRASE detected. Use env var?",
632
+ keepPrompt: "Seed phrase already configured. Keep it?",
633
+ inputPrompt: "DecentChat seed phrase (12 words)",
634
+ preferredEnvVar: "DECENTCHAT_SEED_PHRASE",
635
+
636
+ allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
637
+
638
+ inspect: ({ cfg, accountId }) => {
639
+ const account = resolveDecentChatAccount(cfg, accountId);
640
+ return {
641
+ accountConfigured: account.configured,
642
+ hasConfiguredValue: !!account.seedPhrase?.trim(),
643
+ resolvedValue: account.seedPhrase?.trim(),
644
+ envValue: process.env.DECENTCHAT_SEED_PHRASE?.trim(),
645
+ };
646
+ },
647
+
648
+ shouldPrompt: ({ credentialValues, state }) => {
649
+ // Skip the prompt if prepare() already generated a seed phrase
650
+ if (credentialValues.privateKey?.trim()) return false;
651
+ if (state.hasConfiguredValue) return false;
652
+ return true;
653
+ },
654
+
655
+ applyUseEnv: async ({ cfg }) =>
656
+ patchTopLevelChannelConfigSection({
657
+ cfg,
658
+ channel: CHANNEL,
659
+ enabled: true,
660
+ clearFields: ["seedPhrase"],
661
+ patch: {},
662
+ }),
663
+
664
+ applySet: async ({ cfg, resolvedValue }) =>
665
+ patchTopLevelChannelConfigSection({
666
+ cfg,
667
+ channel: CHANNEL,
668
+ enabled: true,
669
+ patch: { seedPhrase: resolvedValue },
670
+ }),
671
+ },
672
+ ],
673
+
674
+ textInputs: [
675
+ {
676
+ inputKey: "name" as keyof ChannelSetupInput,
677
+ message: "Bot display name",
678
+ placeholder: "DecentChat Bot",
679
+ required: false,
680
+ helpTitle: "Bot display name",
681
+ helpLines: ["The name other users see when your bot sends messages."],
682
+
683
+ currentValue: ({ cfg, accountId }) => {
684
+ const account = resolveDecentChatAccount(cfg, accountId);
685
+ return account.alias !== "DecentChat Bot" ? account.alias : undefined;
686
+ },
687
+
688
+ initialValue: () => "DecentChat Bot",
689
+
690
+ applySet: async ({ cfg, value }) =>
691
+ patchTopLevelChannelConfigSection({
692
+ cfg,
693
+ channel: CHANNEL,
694
+ enabled: true,
695
+ patch: { alias: value.trim() || "DecentChat Bot" },
696
+ }),
697
+ },
698
+ {
699
+ inputKey: "url" as keyof ChannelSetupInput,
700
+ message: "Invite URL to join a workspace (optional)",
701
+ placeholder: "decentchat://invite/...",
702
+ required: false,
703
+ applyEmptyValue: false,
704
+ helpTitle: "DecentChat invite URL",
705
+ helpLines: [
706
+ "Paste an invite link to automatically join a workspace on startup.",
707
+ "You can add more later in the config file.",
708
+ "Leave blank to skip.",
709
+ ],
710
+
711
+ currentValue: ({ cfg, accountId }) => {
712
+ const account = resolveDecentChatAccount(cfg, accountId);
713
+ return account.invites.length > 0 ? account.invites[0] : undefined;
714
+ },
715
+
716
+ keepPrompt: (value) => `Invite URL set (${value}). Keep it?`,
717
+
718
+ applySet: async ({ cfg, value }) => {
719
+ const trimmed = value.trim();
720
+ if (!trimmed) return cfg;
721
+ // Merge with existing invites, avoiding duplicates
722
+ const existing: string[] = (cfg as any)?.channels?.decentchat?.invites ?? [];
723
+ const merged = [...new Set([...existing, trimmed])];
724
+ return patchTopLevelChannelConfigSection({
725
+ cfg,
726
+ channel: CHANNEL,
727
+ enabled: true,
728
+ patch: { invites: merged },
729
+ });
730
+ },
731
+ },
732
+ ],
733
+
734
+ completionNote: {
735
+ title: "DecentChat ready",
736
+ lines: [
737
+ "Your bot will connect to the DecentChat P2P network on next startup.",
738
+ "Run `openclaw start` to bring it online.",
739
+ ],
740
+ },
741
+
742
+ dmPolicy: createTopLevelChannelDmPolicy({
743
+ label: "DecentChat",
744
+ channel: CHANNEL,
745
+ policyKey: `channels.${CHANNEL}.dmPolicy`,
746
+ allowFromKey: `channels.${CHANNEL}.allowFrom`,
747
+ getCurrent: (cfg) => (cfg as any)?.channels?.decentchat?.dmPolicy ?? "open",
748
+ }),
749
+
750
+ disable: (cfg) =>
751
+ patchTopLevelChannelConfigSection({
752
+ cfg,
753
+ channel: CHANNEL,
754
+ patch: { enabled: false },
755
+ }),
756
+ };
757
+
758
+ const decentChatSetupAdapter = {
759
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
760
+
761
+ validateInput: ({ input }: { cfg: OpenClawConfig; accountId: string; input: ChannelSetupInput }) => {
762
+ const typedInput = input as ChannelSetupInput & { privateKey?: string };
763
+ if (!typedInput.useEnv) {
764
+ const seedPhrase = typedInput.privateKey?.trim();
765
+ if (!seedPhrase) return "DecentChat requires a seed phrase.";
766
+ const error = validateSeedPhrase(seedPhrase);
767
+ if (error) return error;
768
+ }
769
+ return null;
770
+ },
771
+
772
+ applyAccountConfig: ({ cfg, input }: { cfg: OpenClawConfig; accountId: string; input: ChannelSetupInput }) => {
773
+ const typedInput = input as ChannelSetupInput & { privateKey?: string };
774
+ const patch: Record<string, unknown> = {};
775
+
776
+ if (typedInput.useEnv) {
777
+ // Clear stored seed phrase, will read from env at runtime
778
+ } else if (typedInput.privateKey?.trim()) {
779
+ patch.seedPhrase = typedInput.privateKey.trim();
780
+ }
781
+
782
+ if ((typedInput as any).name?.trim()) {
783
+ patch.alias = (typedInput as any).name.trim();
784
+ }
785
+
786
+ if ((typedInput as any).url?.trim()) {
787
+ const existing: string[] = (cfg as any)?.channels?.decentchat?.invites ?? [];
788
+ const invite = (typedInput as any).url.trim();
789
+ patch.invites = [...new Set([...existing, invite])];
790
+ }
791
+
792
+ return patchTopLevelChannelConfigSection({
793
+ cfg,
794
+ channel: CHANNEL,
795
+ enabled: true,
796
+ clearFields: typedInput.useEnv ? ["seedPhrase"] : undefined,
797
+ patch,
798
+ });
799
+ },
800
+ };
801
+
802
+ export const decentChatPlugin: ChannelPlugin<ResolvedDecentChatAccount> = {
803
+ id: "decentchat",
804
+ meta: {
805
+ id: "decentchat",
806
+ label: "DecentChat",
807
+ selectionLabel: "DecentChat (P2P)",
808
+ docsPath: "/channels/decentchat",
809
+ blurb: "P2P encrypted chat via DecentChat.",
810
+ aliases: ["decent", "decentchat"],
811
+ },
812
+ capabilities: { chatTypes: ["direct", "group", "thread"], threads: true, media: true },
813
+ reload: { configPrefixes: ["channels.decentchat"] },
814
+ configSchema: {
815
+ ...buildChannelConfigSchema(DecentChatConfigSchema),
816
+ uiHints: {
817
+ enabled: { label: "Enabled" },
818
+ seedPhrase: { label: "Seed Phrase (12 words)", sensitive: true, help: "BIP39 seed phrase — determines your bot's identity on the network" },
819
+ signalingServer: { label: "Signaling Server", placeholder: "https://decentchat.app/peerjs", advanced: true },
820
+ alias: { label: "Bot Display Name", placeholder: "DecentChat Bot" },
821
+ dataDir: { label: "Data Directory", advanced: true, help: "Path for persistent peer storage" },
822
+ streamEnabled: { label: "Enable streaming", help: "Stream token deltas to peers in real time" },
823
+ dmPolicy: { label: "DM Policy" },
824
+ defaultAccount: { label: "Default account", advanced: true, help: "Preferred DecentChat account id when multiple accounts are configured" },
825
+ replyToMode: { label: "Reply-to mode", help: "off|first|all — controls thread reply behavior" },
826
+ replyToModeDirect: { label: "Reply-to mode (DMs)", help: "Override for direct messages" },
827
+ replyToModeGroup: { label: "Reply-to mode (Groups)", help: "Override for group chats" },
828
+ replyToModeChannel: { label: "Reply-to mode (Channels)", help: "Override for channels" },
829
+ threadHistoryScope: { label: "Thread history scope", help: "thread = isolated, channel = shared context", advanced: true },
830
+ threadInheritParent: { label: "Thread inherit parent", help: "Thread sessions inherit parent channel context", advanced: true },
831
+ threadInitialHistoryLimit: { label: "Thread initial history limit", help: "Messages to bootstrap in new thread sessions", advanced: true },
832
+ companySimBootstrapEnabled: { label: "Company bootstrap enabled", advanced: true },
833
+ companySimBootstrapMode: { label: "Company bootstrap mode", advanced: true, help: "runtime = materialize company workspace on account startup" },
834
+ companySimBootstrapManifestPath: { label: "Company manifest path", advanced: true, help: "Path to company.yaml (supports relative paths from current working directory)" },
835
+ companySimBootstrapTargetWorkspaceId: { label: "Company target workspace id", advanced: true, help: "Pinned workspace id for runtime company bootstrap membership" },
836
+ companySimBootstrapTargetInviteCode: { label: "Company target invite code", advanced: true, help: "Pinned invite code for runtime company bootstrap membership" },
837
+ invites: { label: "Invite URLs", advanced: true, help: "DecentChat invite URIs for workspaces to join on startup" },
838
+ },
839
+ },
840
+
841
+ setup: decentChatSetupAdapter,
842
+ setupWizard: decentChatSetupWizard,
843
+
844
+ config: {
845
+ listAccountIds: (cfg) => listDecentChatAccountIds(cfg),
846
+ resolveAccount: (cfg, accountId) => resolveDecentChatAccount(cfg, accountId),
847
+ defaultAccountId: (cfg) => resolveDefaultDecentChatAccountId(cfg),
848
+ isConfigured: (account) => account.configured,
849
+ describeAccount: (account) => ({
850
+ accountId: account.accountId,
851
+ enabled: account.enabled,
852
+ configured: account.configured,
853
+ signalingServer: account.signalingServer,
854
+ companySim: account.companySim?.enabled ? {
855
+ companyId: account.companySim.companyId,
856
+ employeeId: account.companySim.employeeId,
857
+ } : undefined,
858
+ companySimBootstrap: account.companySimBootstrap?.enabled ? {
859
+ mode: account.companySimBootstrap.mode,
860
+ manifestPath: account.companySimBootstrap.manifestPath,
861
+ targetWorkspaceId: account.companySimBootstrap.targetWorkspaceId,
862
+ targetInviteCode: account.companySimBootstrap.targetInviteCode,
863
+ } : undefined,
864
+ }),
865
+ },
866
+
867
+ security: {
868
+ resolveDmPolicy: ({ account }) => ({
869
+ policy: account.dmPolicy ?? "open",
870
+ allowFrom: [],
871
+ policyPath: "channels.decentchat.dmPolicy",
872
+ allowFromPath: "channels.decentchat.allowFrom",
873
+ approveHint: formatPairingApproveHint("decentchat"),
874
+ normalizeEntry: (raw: string) => raw.trim(),
875
+ }),
876
+ },
877
+
878
+ threading: {
879
+ resolveReplyToMode: ({ cfg, accountId, chatType }) => {
880
+ const account = resolveDecentChatAccount(cfg, accountId);
881
+ if (chatType === "direct") {
882
+ return account.replyToModeByChatType.direct ?? account.replyToMode;
883
+ }
884
+ if (chatType === "group") {
885
+ return account.replyToModeByChatType.group ?? account.replyToModeByChatType.channel ?? account.replyToMode;
886
+ }
887
+ if (chatType === "channel") {
888
+ return account.replyToModeByChatType.channel ?? account.replyToModeByChatType.group ?? account.replyToMode;
889
+ }
890
+ return account.replyToMode;
891
+ },
892
+ allowExplicitReplyTagsWhenOff: true,
893
+ },
894
+
895
+ streaming: {
896
+ blockStreamingCoalesceDefaults: { minChars: 1, idleMs: 0 },
897
+ },
898
+
899
+ groups: {
900
+ resolveRequireMention: ({ cfg, groupId }) => {
901
+ const chCfg = resolveRawDecentChatAccountConfig(cfg);
902
+ const grpCfg = chCfg.channels?.[groupId] ?? chCfg.channels?.["*"];
903
+ return grpCfg?.requireMention ?? true;
904
+ },
905
+ },
906
+
907
+ messaging: {
908
+ normalizeTarget: normalizeDecentChatMessagingTarget,
909
+ targetResolver: {
910
+ looksLikeId: looksLikeDecentChatTargetId,
911
+ hint: "<peerId|channel:<id>|decentchat:channel:<id>|peer alias>",
912
+ resolveTarget: async ({ accountId, input, normalized, preferredKind }) => resolveDecentChatTarget({
913
+ accountId,
914
+ input,
915
+ normalized,
916
+ preferredKind: preferredKind as "user" | "group" | "channel" | undefined,
917
+ }),
918
+ },
919
+ },
920
+
921
+ outbound: {
922
+ deliveryMode: "direct",
923
+ sendText: async (ctx) => {
924
+ const peer = getPeerForContext(ctx.cfg, ctx.accountId);
925
+ if (!peer) return { ok: false, error: new Error("DecentChat peer not running") };
926
+
927
+ const { to, text, replyToId, threadId } = ctx;
928
+ const threadIdStr = threadId != null
929
+ ? String(threadId)
930
+ : (replyToId != null ? String(replyToId) : undefined);
931
+
932
+ try {
933
+ if (to.startsWith("decentchat:channel:")) {
934
+ const channelId = to.slice("decentchat:channel:".length);
935
+ await peer.sendToChannel(channelId, text, threadIdStr, replyToId ?? undefined);
936
+ } else {
937
+ const peerId = to.startsWith("decentchat:") ? to.slice("decentchat:".length) : to;
938
+ await peer.sendDirectToPeer(peerId, text, threadIdStr, replyToId ?? undefined);
939
+ }
940
+ return { ok: true };
941
+ } catch (err) {
942
+ return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
943
+ }
944
+ },
945
+ },
946
+
947
+ directory: {
948
+ self: async ({ cfg, accountId }) => {
949
+ const account = resolveDecentChatAccount(cfg, accountId);
950
+ const peer = getPeerForContext(cfg, accountId);
951
+ if (!peer?.peerId) return null;
952
+ return {
953
+ kind: "user" as const,
954
+ id: peer.peerId,
955
+ name: account.alias,
956
+ handle: `decentchat:${peer.peerId}`,
957
+ };
958
+ },
959
+ listPeers: async ({ cfg, accountId, query, limit }) => {
960
+ const peer = getPeerForContext(cfg, accountId);
961
+ if (!peer) return [];
962
+ return peer.listDirectoryPeersLive({ query, limit });
963
+ },
964
+ listPeersLive: async ({ cfg, accountId, query, limit }) => {
965
+ const peer = getPeerForContext(cfg, accountId);
966
+ if (!peer) return [];
967
+ return peer.listDirectoryPeersLive({ query, limit });
968
+ },
969
+ listGroups: async ({ cfg, accountId, query, limit }) => {
970
+ const peer = getPeerForContext(cfg, accountId);
971
+ if (!peer) return [];
972
+ return peer.listDirectoryGroupsLive({ query, limit });
973
+ },
974
+ listGroupsLive: async ({ cfg, accountId, query, limit }) => {
975
+ const peer = getPeerForContext(cfg, accountId);
976
+ if (!peer) return [];
977
+ return peer.listDirectoryGroupsLive({ query, limit });
978
+ },
979
+ },
980
+
981
+ status: {
982
+ defaultRuntime: {
983
+ accountId: DEFAULT_ACCOUNT_ID,
984
+ running: false,
985
+ lastError: null,
986
+ },
987
+ buildChannelSummary: ({ snapshot }) => ({
988
+ configured: snapshot.configured ?? false,
989
+ running: snapshot.running ?? false,
990
+ lastError: snapshot.lastError ?? null,
991
+ }),
992
+ buildAccountSnapshot: ({ account, runtime }) => ({
993
+ accountId: account.accountId,
994
+ enabled: account.enabled,
995
+ configured: account.configured,
996
+ running: runtime?.running ?? false,
997
+ lastError: runtime?.lastError ?? null,
998
+ companySim: account.companySim?.enabled ? {
999
+ companyId: account.companySim.companyId,
1000
+ employeeId: account.companySim.employeeId,
1001
+ } : undefined,
1002
+ companySimBootstrap: account.companySimBootstrap?.enabled ? {
1003
+ mode: account.companySimBootstrap.mode,
1004
+ manifestPath: account.companySimBootstrap.manifestPath,
1005
+ targetWorkspaceId: account.companySimBootstrap.targetWorkspaceId,
1006
+ targetInviteCode: account.companySimBootstrap.targetInviteCode,
1007
+ } : undefined,
1008
+ }),
1009
+ },
1010
+
1011
+ gateway: {
1012
+ startAccount: async (ctx) => {
1013
+ ctx.setStatus({
1014
+ accountId: ctx.accountId,
1015
+ running: false,
1016
+ configured: ctx.account.configured,
1017
+ });
1018
+ try {
1019
+ // Invalidate bootstrap guard so re-bootstrap runs on gateway restart.
1020
+ // Without this, the module-level `bootstrapCompleted` Set would skip
1021
+ // bootstrap on the second startAccount call within the same process.
1022
+ const bootstrap = ctx.account.companySimBootstrap;
1023
+ if (bootstrap?.enabled && bootstrap.mode !== "off") {
1024
+ const manifestPath = bootstrap.manifestPath?.trim();
1025
+ if (manifestPath) {
1026
+ const resolvedManifestPath = resolveCompanyManifestPath(manifestPath);
1027
+ const runtimeScope = buildCompanyBootstrapRuntimeScope(ctx.cfg, resolvedManifestPath);
1028
+ invalidateDecentChatBootstrapKey(buildDecentChatRuntimeBootstrapKey(resolvedManifestPath, runtimeScope));
1029
+ }
1030
+ }
1031
+
1032
+ await bootstrapDecentChatCompanySimForStartup({
1033
+ cfg: ctx.cfg,
1034
+ accountId: ctx.accountId,
1035
+ account: ctx.account,
1036
+ log: ctx.log,
1037
+ });
1038
+
1039
+ const startupDelayMs = resolveDecentChatStartupDelayMs(ctx.cfg, ctx.accountId);
1040
+ if (startupDelayMs > 0) {
1041
+ ctx.log?.info?.(`[${ctx.accountId}] startup stagger ${startupDelayMs}ms`);
1042
+ await waitForDecentChatStartupSlot(startupDelayMs, ctx.abortSignal);
1043
+ }
1044
+
1045
+ await startDecentChatPeer({
1046
+ account: ctx.account,
1047
+ accountId: ctx.accountId,
1048
+ log: ctx.log,
1049
+ setStatus: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
1050
+ abortSignal: ctx.abortSignal,
1051
+ });
1052
+ } catch (err) {
1053
+ const message = err instanceof Error ? err.message : String(err);
1054
+ ctx.setStatus({ accountId: ctx.accountId, running: false, lastError: message });
1055
+ throw err;
1056
+ }
1057
+ },
1058
+ },
1059
+ };