@botcord/daemon 0.2.36 → 0.2.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +29 -0
- package/dist/config.js +27 -0
- package/dist/daemon-config-map.d.ts +3 -0
- package/dist/daemon-config-map.js +30 -0
- package/dist/daemon.d.ts +15 -1
- package/dist/daemon.js +56 -11
- package/dist/gateway/channels/botcord.js +44 -0
- package/dist/gateway/channels/http-types.d.ts +19 -0
- package/dist/gateway/channels/http-types.js +1 -0
- package/dist/gateway/channels/index.d.ts +5 -0
- package/dist/gateway/channels/index.js +5 -0
- package/dist/gateway/channels/login-session.d.ts +83 -0
- package/dist/gateway/channels/login-session.js +99 -0
- package/dist/gateway/channels/secret-store.d.ts +21 -0
- package/dist/gateway/channels/secret-store.js +75 -0
- package/dist/gateway/channels/state-store.d.ts +60 -0
- package/dist/gateway/channels/state-store.js +173 -0
- package/dist/gateway/channels/telegram.d.ts +31 -0
- package/dist/gateway/channels/telegram.js +371 -0
- package/dist/gateway/channels/text-split.d.ts +13 -0
- package/dist/gateway/channels/text-split.js +33 -0
- package/dist/gateway/channels/url-guard.d.ts +18 -0
- package/dist/gateway/channels/url-guard.js +53 -0
- package/dist/gateway/channels/wechat-http.d.ts +18 -0
- package/dist/gateway/channels/wechat-http.js +28 -0
- package/dist/gateway/channels/wechat-login.d.ts +36 -0
- package/dist/gateway/channels/wechat-login.js +62 -0
- package/dist/gateway/channels/wechat.d.ts +40 -0
- package/dist/gateway/channels/wechat.js +472 -0
- package/dist/gateway/runtimes/openclaw-acp.js +211 -6
- package/dist/gateway/types.d.ts +10 -0
- package/dist/gateway-control.d.ts +53 -0
- package/dist/gateway-control.js +638 -0
- package/dist/provision.d.ts +7 -0
- package/dist/provision.js +255 -5
- package/package.json +1 -1
- package/src/__tests__/gateway-control.test.ts +499 -0
- package/src/__tests__/openclaw-acp.test.ts +63 -0
- package/src/__tests__/provision.test.ts +179 -0
- package/src/__tests__/secret-store.test.ts +70 -0
- package/src/__tests__/state-store.test.ts +119 -0
- package/src/__tests__/third-party-gateway.test.ts +126 -0
- package/src/__tests__/url-guard.test.ts +85 -0
- package/src/__tests__/wechat-channel.test.ts +1134 -0
- package/src/config.ts +71 -0
- package/src/daemon-config-map.ts +24 -0
- package/src/daemon.ts +70 -11
- package/src/gateway/__tests__/botcord-channel.test.ts +1 -1
- package/src/gateway/__tests__/telegram-channel.test.ts +555 -0
- package/src/gateway/channels/botcord.ts +39 -0
- package/src/gateway/channels/http-types.ts +22 -0
- package/src/gateway/channels/index.ts +22 -0
- package/src/gateway/channels/login-session.ts +135 -0
- package/src/gateway/channels/secret-store.ts +100 -0
- package/src/gateway/channels/state-store.ts +213 -0
- package/src/gateway/channels/telegram.ts +469 -0
- package/src/gateway/channels/text-split.ts +29 -0
- package/src/gateway/channels/url-guard.ts +55 -0
- package/src/gateway/channels/wechat-http.ts +35 -0
- package/src/gateway/channels/wechat-login.ts +90 -0
- package/src/gateway/channels/wechat.ts +572 -0
- package/src/gateway/runtimes/openclaw-acp.ts +211 -7
- package/src/gateway/types.ts +10 -0
- package/src/gateway-control.ts +709 -0
- package/src/provision.ts +336 -5
package/dist/config.d.ts
CHANGED
|
@@ -84,6 +84,29 @@ export interface OpenclawDiscoveryConfig {
|
|
|
84
84
|
/** Defaults to false. When false, discovery only persists gateways. */
|
|
85
85
|
autoProvision?: boolean;
|
|
86
86
|
}
|
|
87
|
+
/** Third-party messaging provider supported by the daemon's channel factory. */
|
|
88
|
+
export type ThirdPartyGatewayType = "telegram" | "wechat";
|
|
89
|
+
/**
|
|
90
|
+
* One third-party gateway profile bound to a BotCord agent. `id` is the
|
|
91
|
+
* channel id (typically `gw_...` minted by the Hub); `accountId` is the
|
|
92
|
+
* BotCord agent the inbound traffic should be attributed to. Secrets and
|
|
93
|
+
* provider cursors live outside this struct — see `secretFile` and
|
|
94
|
+
* `stateFile`. When omitted, the daemon derives them as
|
|
95
|
+
* `~/.botcord/daemon/gateways/{id}.json` and `{id}.state.json`.
|
|
96
|
+
*/
|
|
97
|
+
export interface ThirdPartyGatewayProfile {
|
|
98
|
+
id: string;
|
|
99
|
+
type: ThirdPartyGatewayType;
|
|
100
|
+
accountId: string;
|
|
101
|
+
label?: string;
|
|
102
|
+
enabled?: boolean;
|
|
103
|
+
secretFile?: string;
|
|
104
|
+
stateFile?: string;
|
|
105
|
+
allowedSenderIds?: string[];
|
|
106
|
+
allowedChatIds?: string[];
|
|
107
|
+
splitAt?: number;
|
|
108
|
+
baseUrl?: string;
|
|
109
|
+
}
|
|
87
110
|
export interface DaemonConfig {
|
|
88
111
|
/**
|
|
89
112
|
* @deprecated Kept for backward compatibility with pre-multi-agent configs.
|
|
@@ -128,6 +151,12 @@ export interface DaemonConfig {
|
|
|
128
151
|
* search paths/ports and automatic adoption of discovered agents.
|
|
129
152
|
*/
|
|
130
153
|
openclawDiscovery?: OpenclawDiscoveryConfig;
|
|
154
|
+
/**
|
|
155
|
+
* Third-party messaging gateways (Telegram, WeChat, …) bound to BotCord
|
|
156
|
+
* agents on this daemon. Each entry becomes one channel in the gateway
|
|
157
|
+
* runtime; `enabled === false` entries are filtered out at boot.
|
|
158
|
+
*/
|
|
159
|
+
thirdPartyGateways?: ThirdPartyGatewayProfile[];
|
|
131
160
|
}
|
|
132
161
|
/**
|
|
133
162
|
* Persistent transcript settings (design §6). Default-off — `botcord-daemon
|
package/dist/config.js
CHANGED
|
@@ -202,6 +202,33 @@ export function loadConfig() {
|
|
|
202
202
|
}
|
|
203
203
|
out.openclawDiscovery = copy;
|
|
204
204
|
}
|
|
205
|
+
const tpg = parsed.thirdPartyGateways;
|
|
206
|
+
if (tpg !== undefined) {
|
|
207
|
+
if (!Array.isArray(tpg)) {
|
|
208
|
+
throw new Error(`daemon config "thirdPartyGateways" must be an array (${CONFIG_PATH})`);
|
|
209
|
+
}
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
for (const [i, g] of tpg.entries()) {
|
|
212
|
+
if (!g || typeof g !== "object") {
|
|
213
|
+
throw new Error(`daemon config thirdPartyGateways[${i}] is not an object (${CONFIG_PATH})`);
|
|
214
|
+
}
|
|
215
|
+
const gg = g;
|
|
216
|
+
if (typeof gg.id !== "string" || gg.id.length === 0) {
|
|
217
|
+
throw new Error(`daemon config thirdPartyGateways[${i}].id must be a non-empty string (${CONFIG_PATH})`);
|
|
218
|
+
}
|
|
219
|
+
if (gg.type !== "telegram" && gg.type !== "wechat") {
|
|
220
|
+
throw new Error(`daemon config thirdPartyGateways[${i}].type must be "telegram" or "wechat" (${CONFIG_PATH})`);
|
|
221
|
+
}
|
|
222
|
+
if (typeof gg.accountId !== "string" || gg.accountId.length === 0) {
|
|
223
|
+
throw new Error(`daemon config thirdPartyGateways[${i}].accountId must be a non-empty string (${CONFIG_PATH})`);
|
|
224
|
+
}
|
|
225
|
+
if (seen.has(gg.id)) {
|
|
226
|
+
throw new Error(`daemon config thirdPartyGateways[${i}].id "${gg.id}" duplicated (${CONFIG_PATH})`);
|
|
227
|
+
}
|
|
228
|
+
seen.add(gg.id);
|
|
229
|
+
}
|
|
230
|
+
out.thirdPartyGateways = tpg.map((g) => ({ ...g }));
|
|
231
|
+
}
|
|
205
232
|
return out;
|
|
206
233
|
}
|
|
207
234
|
function validateAdapter(id, field) {
|
|
@@ -52,6 +52,9 @@ export interface ToGatewayConfigOptions {
|
|
|
52
52
|
export declare const DEFAULT_BOTCORD_CHANNEL_ID = "botcord-main";
|
|
53
53
|
/** Channel `type` tag used by `createBotCordChannel`. */
|
|
54
54
|
export declare const BOTCORD_CHANNEL_TYPE = "botcord";
|
|
55
|
+
/** Channel `type` tags for built-in third-party providers. */
|
|
56
|
+
export declare const TELEGRAM_CHANNEL_TYPE = "telegram";
|
|
57
|
+
export declare const WECHAT_CHANNEL_TYPE = "wechat";
|
|
55
58
|
/**
|
|
56
59
|
* Convert the daemon's on-disk config into a gateway runtime config. Only
|
|
57
60
|
* used in-process at daemon boot; the daemon config file itself is the
|
|
@@ -74,6 +74,9 @@ function resolveGateway(profiles, gatewayName, agentOverride, where) {
|
|
|
74
74
|
export const DEFAULT_BOTCORD_CHANNEL_ID = "botcord-main";
|
|
75
75
|
/** Channel `type` tag used by `createBotCordChannel`. */
|
|
76
76
|
export const BOTCORD_CHANNEL_TYPE = "botcord";
|
|
77
|
+
/** Channel `type` tags for built-in third-party providers. */
|
|
78
|
+
export const TELEGRAM_CHANNEL_TYPE = "telegram";
|
|
79
|
+
export const WECHAT_CHANNEL_TYPE = "wechat";
|
|
77
80
|
/**
|
|
78
81
|
* Map daemon's historical narrower TrustLevel ("owner" | "untrusted") onto
|
|
79
82
|
* gateway's ("owner" | "trusted" | "public"). Matches the adapter-level
|
|
@@ -165,6 +168,33 @@ export function toGatewayConfig(cfg, opts = {}) {
|
|
|
165
168
|
accountId: agentId,
|
|
166
169
|
agentId,
|
|
167
170
|
}));
|
|
171
|
+
// Append one channel per enabled third-party gateway. Disabled entries are
|
|
172
|
+
// dropped here so the gateway runtime never sees them; re-enabling requires
|
|
173
|
+
// an `upsert_gateway` (Phase B) or a config reload.
|
|
174
|
+
for (const g of cfg.thirdPartyGateways ?? []) {
|
|
175
|
+
if (g.enabled === false)
|
|
176
|
+
continue;
|
|
177
|
+
const ch = {
|
|
178
|
+
id: g.id,
|
|
179
|
+
type: g.type,
|
|
180
|
+
accountId: g.accountId,
|
|
181
|
+
};
|
|
182
|
+
if (g.label !== undefined)
|
|
183
|
+
ch.label = g.label;
|
|
184
|
+
if (g.secretFile !== undefined)
|
|
185
|
+
ch.secretFile = g.secretFile;
|
|
186
|
+
if (g.stateFile !== undefined)
|
|
187
|
+
ch.stateFile = g.stateFile;
|
|
188
|
+
if (g.allowedSenderIds !== undefined)
|
|
189
|
+
ch.allowedSenderIds = g.allowedSenderIds;
|
|
190
|
+
if (g.allowedChatIds !== undefined)
|
|
191
|
+
ch.allowedChatIds = g.allowedChatIds;
|
|
192
|
+
if (g.splitAt !== undefined)
|
|
193
|
+
ch.splitAt = g.splitAt;
|
|
194
|
+
if (g.baseUrl !== undefined)
|
|
195
|
+
ch.baseUrl = g.baseUrl;
|
|
196
|
+
channels.push(ch);
|
|
197
|
+
}
|
|
168
198
|
// DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
|
|
169
199
|
// defensively so future config extensions can propagate without a shape bump.
|
|
170
200
|
const profiles = prepareGatewayProfiles(cfg.openclawGateways);
|
package/dist/daemon.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GatewayInboundMessage, type GatewayLogger, type GatewayRuntimeSnapshot } from "./gateway/index.js";
|
|
1
|
+
import { type ChannelAdapter, type GatewayChannelConfig, type GatewayInboundMessage, type GatewayLogger, type GatewayRuntimeSnapshot } from "./gateway/index.js";
|
|
2
2
|
import type { DaemonConfig } from "./config.js";
|
|
3
3
|
import { type BootAgentsResult } from "./agent-discovery.js";
|
|
4
4
|
import { ensureAgentWorkspace } from "./agent-workspace.js";
|
|
@@ -31,6 +31,20 @@ export declare function createActivityRecorder(opts: {
|
|
|
31
31
|
activityTracker: ActivityRecorderTarget;
|
|
32
32
|
fallbackAgentId?: string;
|
|
33
33
|
}): (msg: GatewayInboundMessage) => void;
|
|
34
|
+
/** Per-call dependencies for {@link createDaemonChannel}. */
|
|
35
|
+
export interface CreateDaemonChannelDeps {
|
|
36
|
+
credentialPathByAgentId: Map<string, string>;
|
|
37
|
+
defaultCredentialsPath?: string;
|
|
38
|
+
hubBaseUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Dispatch a `GatewayChannelConfig` to the right adapter constructor based on
|
|
42
|
+
* `chCfg.type`. Phase A wires up the BotCord adapter and stub constructors
|
|
43
|
+
* for telegram/wechat (which throw "not implemented"); Phase B will fill the
|
|
44
|
+
* latter in. Unknown types throw so misconfigured channels fail loudly at
|
|
45
|
+
* boot rather than silently dropping inbound traffic.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createDaemonChannel(chCfg: GatewayChannelConfig, deps: CreateDaemonChannelDeps): ChannelAdapter;
|
|
34
48
|
/**
|
|
35
49
|
* Minimal send-capable surface used by {@link pushRuntimeSnapshot}.
|
|
36
50
|
* Exists so the helper is trivially mockable from unit tests without needing
|
package/dist/daemon.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CONTROL_FRAME_TYPES, shouldWake, } from "@botcord/protocol-core";
|
|
2
|
-
import { Gateway, createBotCordChannel, resolveTranscriptEnabled, sanitizeUntrustedContent, } from "./gateway/index.js";
|
|
2
|
+
import { Gateway, createBotCordChannel, createTelegramChannel, createWechatChannel, resolveTranscriptEnabled, sanitizeUntrustedContent, } from "./gateway/index.js";
|
|
3
3
|
import { ActivityTracker } from "./activity-tracker.js";
|
|
4
4
|
import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
|
|
5
5
|
import { resolveBootAgents } from "./agent-discovery.js";
|
|
@@ -70,6 +70,56 @@ export function createActivityRecorder(opts) {
|
|
|
70
70
|
});
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Dispatch a `GatewayChannelConfig` to the right adapter constructor based on
|
|
75
|
+
* `chCfg.type`. Phase A wires up the BotCord adapter and stub constructors
|
|
76
|
+
* for telegram/wechat (which throw "not implemented"); Phase B will fill the
|
|
77
|
+
* latter in. Unknown types throw so misconfigured channels fail loudly at
|
|
78
|
+
* boot rather than silently dropping inbound traffic.
|
|
79
|
+
*/
|
|
80
|
+
export function createDaemonChannel(chCfg, deps) {
|
|
81
|
+
switch (chCfg.type) {
|
|
82
|
+
case "botcord": {
|
|
83
|
+
const agentId = typeof chCfg.agentId === "string" ? chCfg.agentId : chCfg.accountId;
|
|
84
|
+
return createBotCordChannel({
|
|
85
|
+
id: chCfg.id,
|
|
86
|
+
accountId: chCfg.accountId,
|
|
87
|
+
agentId,
|
|
88
|
+
credentialsPath: deps.credentialPathByAgentId.get(agentId) ?? deps.defaultCredentialsPath,
|
|
89
|
+
hubBaseUrl: deps.hubBaseUrl,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
case "telegram":
|
|
93
|
+
return createTelegramChannel({
|
|
94
|
+
id: chCfg.id,
|
|
95
|
+
accountId: chCfg.accountId,
|
|
96
|
+
...(typeof chCfg.baseUrl === "string" ? { baseUrl: chCfg.baseUrl } : {}),
|
|
97
|
+
...(Array.isArray(chCfg.allowedSenderIds)
|
|
98
|
+
? { allowedSenderIds: chCfg.allowedSenderIds }
|
|
99
|
+
: {}),
|
|
100
|
+
...(Array.isArray(chCfg.allowedChatIds)
|
|
101
|
+
? { allowedChatIds: chCfg.allowedChatIds }
|
|
102
|
+
: {}),
|
|
103
|
+
...(typeof chCfg.splitAt === "number" ? { splitAt: chCfg.splitAt } : {}),
|
|
104
|
+
...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
|
|
105
|
+
...(typeof chCfg.stateFile === "string" ? { stateFile: chCfg.stateFile } : {}),
|
|
106
|
+
});
|
|
107
|
+
case "wechat":
|
|
108
|
+
return createWechatChannel({
|
|
109
|
+
id: chCfg.id,
|
|
110
|
+
accountId: chCfg.accountId,
|
|
111
|
+
...(typeof chCfg.baseUrl === "string" ? { baseUrl: chCfg.baseUrl } : {}),
|
|
112
|
+
...(Array.isArray(chCfg.allowedSenderIds)
|
|
113
|
+
? { allowedSenderIds: chCfg.allowedSenderIds }
|
|
114
|
+
: {}),
|
|
115
|
+
...(typeof chCfg.splitAt === "number" ? { splitAt: chCfg.splitAt } : {}),
|
|
116
|
+
...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
|
|
117
|
+
...(typeof chCfg.stateFile === "string" ? { stateFile: chCfg.stateFile } : {}),
|
|
118
|
+
});
|
|
119
|
+
default:
|
|
120
|
+
throw new Error(`unknown channel type "${chCfg.type}"`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
73
123
|
/**
|
|
74
124
|
* Emit one `runtime_snapshot` event frame on the control channel. Plan §8.5
|
|
75
125
|
* P0: first-connect push only — reconnect-push and diffing are P1. A send
|
|
@@ -282,16 +332,11 @@ export async function startDaemon(opts) {
|
|
|
282
332
|
const gateway = new Gateway({
|
|
283
333
|
config: gwConfig,
|
|
284
334
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
285
|
-
createChannel: (chCfg) => {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
agentId,
|
|
291
|
-
credentialsPath: credentialPathByAgentId.get(agentId) ?? opts.credentialsPath,
|
|
292
|
-
hubBaseUrl: opts.hubBaseUrl,
|
|
293
|
-
});
|
|
294
|
-
},
|
|
335
|
+
createChannel: (chCfg) => createDaemonChannel(chCfg, {
|
|
336
|
+
credentialPathByAgentId,
|
|
337
|
+
defaultCredentialsPath: opts.credentialsPath,
|
|
338
|
+
hubBaseUrl: opts.hubBaseUrl,
|
|
339
|
+
}),
|
|
295
340
|
log: logger,
|
|
296
341
|
turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
|
|
297
342
|
buildSystemContext,
|
|
@@ -823,6 +823,7 @@ function normalizeBlockForHub(block, seq) {
|
|
|
823
823
|
payload.session_id = raw.session_id;
|
|
824
824
|
if (typeof raw?.model === "string")
|
|
825
825
|
payload.model = raw.model;
|
|
826
|
+
payload.details = formatBlockDetails(raw);
|
|
826
827
|
return { kind: "system", seq, payload };
|
|
827
828
|
}
|
|
828
829
|
if (kind === "thinking") {
|
|
@@ -836,6 +837,7 @@ function normalizeBlockForHub(block, seq) {
|
|
|
836
837
|
payload.label = raw.label;
|
|
837
838
|
if (typeof raw?.source === "string")
|
|
838
839
|
payload.source = raw.source;
|
|
840
|
+
payload.details = formatBlockDetails(raw);
|
|
839
841
|
return { kind: "thinking", seq, payload };
|
|
840
842
|
}
|
|
841
843
|
// "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
|
|
@@ -849,3 +851,45 @@ function normalizeBlockForHub(block, seq) {
|
|
|
849
851
|
}
|
|
850
852
|
return { kind: "other", seq, payload };
|
|
851
853
|
}
|
|
854
|
+
function formatBlockDetails(raw) {
|
|
855
|
+
if (!raw || typeof raw !== "object")
|
|
856
|
+
return "";
|
|
857
|
+
const r = raw;
|
|
858
|
+
const direct = typeof r.text === "string" ? r.text
|
|
859
|
+
: typeof r.message === "string" ? r.message
|
|
860
|
+
: typeof r.summary === "string" ? r.summary
|
|
861
|
+
: typeof r.label === "string" ? r.label
|
|
862
|
+
: "";
|
|
863
|
+
if (direct)
|
|
864
|
+
return direct;
|
|
865
|
+
const contentText = extractContentText(r.content ?? r.message?.content ?? r.params?.update?.content);
|
|
866
|
+
if (contentText)
|
|
867
|
+
return contentText;
|
|
868
|
+
try {
|
|
869
|
+
return JSON.stringify(raw, null, 2);
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return String(raw);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function extractContentText(content) {
|
|
876
|
+
if (!content)
|
|
877
|
+
return "";
|
|
878
|
+
if (typeof content === "string")
|
|
879
|
+
return content;
|
|
880
|
+
if (Array.isArray(content)) {
|
|
881
|
+
return content.map(extractContentText).filter(Boolean).join("\n");
|
|
882
|
+
}
|
|
883
|
+
if (typeof content === "object") {
|
|
884
|
+
const c = content;
|
|
885
|
+
if (typeof c.text === "string")
|
|
886
|
+
return c.text;
|
|
887
|
+
if (typeof c.thinking === "string")
|
|
888
|
+
return c.thinking;
|
|
889
|
+
if (typeof c.content === "string")
|
|
890
|
+
return c.content;
|
|
891
|
+
if (Array.isArray(c.content))
|
|
892
|
+
return extractContentText(c.content);
|
|
893
|
+
}
|
|
894
|
+
return "";
|
|
895
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical `fetch`-compatible signature shared by gateway-control.ts and
|
|
3
|
+
* the WeChat HTTP helpers. Lets tests inject a stub without depending on
|
|
4
|
+
* undici's full type surface.
|
|
5
|
+
*
|
|
6
|
+
* Kept structurally compatible with both `globalThis.fetch` and the
|
|
7
|
+
* narrower wechat-http test stubs — `body` is optional so callers that
|
|
8
|
+
* only issue GETs (e.g. Telegram `getMe` test probe) can omit it.
|
|
9
|
+
*/
|
|
10
|
+
export type FetchLike = (input: string, init?: {
|
|
11
|
+
method?: string;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
body?: string;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}) => Promise<{
|
|
16
|
+
status?: number;
|
|
17
|
+
ok?: boolean;
|
|
18
|
+
text(): Promise<string>;
|
|
19
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
export { createBotCordChannel } from "./botcord.js";
|
|
2
2
|
export type { BotCordChannelClient, BotCordChannelOptions, BotCordClientFactory, } from "./botcord.js";
|
|
3
|
+
export { createTelegramChannel, type TelegramChannelOptions } from "./telegram.js";
|
|
4
|
+
export { createWechatChannel, type WechatChannelOptions } from "./wechat.js";
|
|
5
|
+
export { getBotQrcode, getQrcodeStatus, DEFAULT_WECHAT_BASE_URL, type WechatQrcode, type WechatQrcodeStatus, type WechatLoginOptions, } from "./wechat-login.js";
|
|
6
|
+
export { defaultGatewaySecretPath, loadGatewaySecret, saveGatewaySecret, deleteGatewaySecret, } from "./secret-store.js";
|
|
7
|
+
export { GatewayStateStore, defaultGatewayStatePath, type GatewayStateStoreOptions, type ThirdPartyGatewayState, } from "./state-store.js";
|
|
@@ -1 +1,6 @@
|
|
|
1
1
|
export { createBotCordChannel } from "./botcord.js";
|
|
2
|
+
export { createTelegramChannel } from "./telegram.js";
|
|
3
|
+
export { createWechatChannel } from "./wechat.js";
|
|
4
|
+
export { getBotQrcode, getQrcodeStatus, DEFAULT_WECHAT_BASE_URL, } from "./wechat-login.js";
|
|
5
|
+
export { defaultGatewaySecretPath, loadGatewaySecret, saveGatewaySecret, deleteGatewaySecret, } from "./secret-store.js";
|
|
6
|
+
export { GatewayStateStore, defaultGatewayStatePath, } from "./state-store.js";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory login-session store used by the daemon's third-party gateway
|
|
3
|
+
* control frames. Today only WeChat consumes it (qrcode → bot token), but
|
|
4
|
+
* the shape is provider-generic so future LINE/Discord OAuth callbacks can
|
|
5
|
+
* reuse the same store without a control-frame churn.
|
|
6
|
+
*
|
|
7
|
+
* The store is intentionally NOT persisted — bot tokens never live anywhere
|
|
8
|
+
* outside the daemon process or the per-gateway secret file. A daemon
|
|
9
|
+
* restart drops in-flight logins; the user just rescans.
|
|
10
|
+
*/
|
|
11
|
+
export type LoginProvider = "wechat" | "telegram";
|
|
12
|
+
export interface LoginSession {
|
|
13
|
+
loginId: string;
|
|
14
|
+
accountId: string;
|
|
15
|
+
gatewayId?: string;
|
|
16
|
+
provider: LoginProvider;
|
|
17
|
+
/** WeChat: opaque qrcode string returned by `get_bot_qrcode`. */
|
|
18
|
+
qrcode?: string;
|
|
19
|
+
/** Optional renderable URL for the qrcode. */
|
|
20
|
+
qrcodeUrl?: string;
|
|
21
|
+
/** WeChat iLink base URL the bot token will be used against. */
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
/** Stored only after the user confirms the qrcode. Never returned to Hub. */
|
|
24
|
+
botToken?: string;
|
|
25
|
+
/** Masked preview safe for Hub/dashboard display. */
|
|
26
|
+
tokenPreview?: string;
|
|
27
|
+
/** Unix millis. */
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
}
|
|
30
|
+
/** Default session TTL: 5 minutes per the design doc. */
|
|
31
|
+
export declare const LOGIN_SESSION_TTL_MS: number;
|
|
32
|
+
export interface LoginSessionStoreOptions {
|
|
33
|
+
/** Override the wall clock — used by tests. */
|
|
34
|
+
now?: () => number;
|
|
35
|
+
/** Override the TTL applied at `create` time. */
|
|
36
|
+
ttlMs?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Lazy-evicting login session map. Eviction runs inline on every read/write
|
|
40
|
+
* so no background timer is required and tests can scrub state by advancing
|
|
41
|
+
* a fake clock.
|
|
42
|
+
*/
|
|
43
|
+
export declare class LoginSessionStore {
|
|
44
|
+
private readonly sessions;
|
|
45
|
+
private readonly now;
|
|
46
|
+
private readonly ttlMs;
|
|
47
|
+
constructor(opts?: LoginSessionStoreOptions);
|
|
48
|
+
/**
|
|
49
|
+
* Insert a fresh session. `expiresAt` is computed as `now() + ttlMs`
|
|
50
|
+
* unless the caller pre-populated it. Returns the persisted record.
|
|
51
|
+
*/
|
|
52
|
+
create(input: Omit<LoginSession, "expiresAt"> & {
|
|
53
|
+
expiresAt?: number;
|
|
54
|
+
}): LoginSession;
|
|
55
|
+
/** Get a non-expired session by id, or `null` when missing/expired. */
|
|
56
|
+
get(loginId: string): LoginSession | null;
|
|
57
|
+
/**
|
|
58
|
+
* Apply a partial patch to the session in place. No-op when the session
|
|
59
|
+
* is missing or expired. Returns the updated record (or `null`).
|
|
60
|
+
*/
|
|
61
|
+
update(loginId: string, patch: Partial<LoginSession>): LoginSession | null;
|
|
62
|
+
delete(loginId: string): boolean;
|
|
63
|
+
/** Drop every entry whose `expiresAt` is in the past. */
|
|
64
|
+
sweep(): void;
|
|
65
|
+
/** Test helper: number of live sessions after sweep. */
|
|
66
|
+
size(): number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build a masked preview suitable for dashboard display. Returns the raw
|
|
70
|
+
* value untouched when shorter than 8 chars (no point masking) or `""` when
|
|
71
|
+
* empty. Default format: `"abcd...wxyz"` with a single ellipsis, never
|
|
72
|
+
* leaking the middle of the secret.
|
|
73
|
+
*/
|
|
74
|
+
export declare function maskTokenPreview(token: string | undefined | null): string;
|
|
75
|
+
/**
|
|
76
|
+
* Allocate a new login id. Format `wxl_<base36ts>_<rand>` so it sorts by
|
|
77
|
+
* creation time and is trivially distinguishable from BotCord agent ids.
|
|
78
|
+
*
|
|
79
|
+
* W8: the random tail uses `crypto.randomBytes` (cryptographically secure)
|
|
80
|
+
* instead of `Math.random()` so an attacker cannot predict in-flight login
|
|
81
|
+
* ids and racefully claim someone else's session.
|
|
82
|
+
*/
|
|
83
|
+
export declare function mintLoginId(provider: LoginProvider): string;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory login-session store used by the daemon's third-party gateway
|
|
3
|
+
* control frames. Today only WeChat consumes it (qrcode → bot token), but
|
|
4
|
+
* the shape is provider-generic so future LINE/Discord OAuth callbacks can
|
|
5
|
+
* reuse the same store without a control-frame churn.
|
|
6
|
+
*
|
|
7
|
+
* The store is intentionally NOT persisted — bot tokens never live anywhere
|
|
8
|
+
* outside the daemon process or the per-gateway secret file. A daemon
|
|
9
|
+
* restart drops in-flight logins; the user just rescans.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
/** Default session TTL: 5 minutes per the design doc. */
|
|
13
|
+
export const LOGIN_SESSION_TTL_MS = 5 * 60 * 1000;
|
|
14
|
+
/**
|
|
15
|
+
* Lazy-evicting login session map. Eviction runs inline on every read/write
|
|
16
|
+
* so no background timer is required and tests can scrub state by advancing
|
|
17
|
+
* a fake clock.
|
|
18
|
+
*/
|
|
19
|
+
export class LoginSessionStore {
|
|
20
|
+
sessions = new Map();
|
|
21
|
+
now;
|
|
22
|
+
ttlMs;
|
|
23
|
+
constructor(opts = {}) {
|
|
24
|
+
this.now = opts.now ?? (() => Date.now());
|
|
25
|
+
this.ttlMs = opts.ttlMs ?? LOGIN_SESSION_TTL_MS;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Insert a fresh session. `expiresAt` is computed as `now() + ttlMs`
|
|
29
|
+
* unless the caller pre-populated it. Returns the persisted record.
|
|
30
|
+
*/
|
|
31
|
+
create(input) {
|
|
32
|
+
this.sweep();
|
|
33
|
+
const expiresAt = typeof input.expiresAt === "number" ? input.expiresAt : this.now() + this.ttlMs;
|
|
34
|
+
const session = { ...input, expiresAt };
|
|
35
|
+
this.sessions.set(session.loginId, session);
|
|
36
|
+
return session;
|
|
37
|
+
}
|
|
38
|
+
/** Get a non-expired session by id, or `null` when missing/expired. */
|
|
39
|
+
get(loginId) {
|
|
40
|
+
this.sweep();
|
|
41
|
+
return this.sessions.get(loginId) ?? null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Apply a partial patch to the session in place. No-op when the session
|
|
45
|
+
* is missing or expired. Returns the updated record (or `null`).
|
|
46
|
+
*/
|
|
47
|
+
update(loginId, patch) {
|
|
48
|
+
const cur = this.get(loginId);
|
|
49
|
+
if (!cur)
|
|
50
|
+
return null;
|
|
51
|
+
const next = { ...cur, ...patch };
|
|
52
|
+
this.sessions.set(loginId, next);
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
delete(loginId) {
|
|
56
|
+
return this.sessions.delete(loginId);
|
|
57
|
+
}
|
|
58
|
+
/** Drop every entry whose `expiresAt` is in the past. */
|
|
59
|
+
sweep() {
|
|
60
|
+
const t = this.now();
|
|
61
|
+
for (const [id, s] of this.sessions) {
|
|
62
|
+
if (s.expiresAt <= t)
|
|
63
|
+
this.sessions.delete(id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Test helper: number of live sessions after sweep. */
|
|
67
|
+
size() {
|
|
68
|
+
this.sweep();
|
|
69
|
+
return this.sessions.size;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build a masked preview suitable for dashboard display. Returns the raw
|
|
74
|
+
* value untouched when shorter than 8 chars (no point masking) or `""` when
|
|
75
|
+
* empty. Default format: `"abcd...wxyz"` with a single ellipsis, never
|
|
76
|
+
* leaking the middle of the secret.
|
|
77
|
+
*/
|
|
78
|
+
export function maskTokenPreview(token) {
|
|
79
|
+
if (!token)
|
|
80
|
+
return "";
|
|
81
|
+
if (token.length <= 8)
|
|
82
|
+
return token;
|
|
83
|
+
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Allocate a new login id. Format `wxl_<base36ts>_<rand>` so it sorts by
|
|
87
|
+
* creation time and is trivially distinguishable from BotCord agent ids.
|
|
88
|
+
*
|
|
89
|
+
* W8: the random tail uses `crypto.randomBytes` (cryptographically secure)
|
|
90
|
+
* instead of `Math.random()` so an attacker cannot predict in-flight login
|
|
91
|
+
* ids and racefully claim someone else's session.
|
|
92
|
+
*/
|
|
93
|
+
export function mintLoginId(provider) {
|
|
94
|
+
const prefix = provider === "wechat" ? "wxl" : "tgl";
|
|
95
|
+
const ts = Date.now().toString(36);
|
|
96
|
+
// 32 hex chars = 128 bits of entropy — W5 regression fix from round 2.
|
|
97
|
+
const rand = randomBytes(16).toString("hex");
|
|
98
|
+
return `${prefix}_${ts}_${rand}`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the on-disk secret-file path for a third-party gateway. Honors an
|
|
3
|
+
* explicit override when provided; otherwise falls back to
|
|
4
|
+
* `~/.botcord/daemon/gateways/{id}.json` (mode 0600 inside a 0700 dir).
|
|
5
|
+
*/
|
|
6
|
+
export declare function defaultGatewaySecretPath(gatewayId: string, override?: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Load a previously-written secret blob. Returns `null` when the file is
|
|
9
|
+
* absent — callers treat that as "not yet authorized" rather than an error.
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadGatewaySecret<T = Record<string, unknown>>(gatewayId: string, override?: string): T | null;
|
|
12
|
+
/**
|
|
13
|
+
* Persist a secret blob with mode `0600`, ensuring the parent directory
|
|
14
|
+
* exists with mode `0700`. Writes go through a `.tmp` rename for atomicity.
|
|
15
|
+
*
|
|
16
|
+
* The parent directory mode is re-applied on every write so a permission
|
|
17
|
+
* drift (e.g. operator chmod) is corrected the next time the daemon writes.
|
|
18
|
+
*/
|
|
19
|
+
export declare function saveGatewaySecret(gatewayId: string, secret: Record<string, unknown>, override?: string): string;
|
|
20
|
+
/** Remove a previously-saved secret. No-op when the file is missing. */
|
|
21
|
+
export declare function deleteGatewaySecret(gatewayId: string, override?: string): void;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// W3: logger for corrupt-file warnings. Using console so no circular dep on log.ts.
|
|
5
|
+
const _warn = (msg) => console.warn(`[secret-store] ${msg}`);
|
|
6
|
+
const DEFAULT_GATEWAYS_DIR = path.join(homedir(), ".botcord", "daemon", "gateways");
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the on-disk secret-file path for a third-party gateway. Honors an
|
|
9
|
+
* explicit override when provided; otherwise falls back to
|
|
10
|
+
* `~/.botcord/daemon/gateways/{id}.json` (mode 0600 inside a 0700 dir).
|
|
11
|
+
*/
|
|
12
|
+
export function defaultGatewaySecretPath(gatewayId, override) {
|
|
13
|
+
if (override && override.length > 0)
|
|
14
|
+
return override;
|
|
15
|
+
return path.join(DEFAULT_GATEWAYS_DIR, `${gatewayId}.json`);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Load a previously-written secret blob. Returns `null` when the file is
|
|
19
|
+
* absent — callers treat that as "not yet authorized" rather than an error.
|
|
20
|
+
*/
|
|
21
|
+
export function loadGatewaySecret(gatewayId, override) {
|
|
22
|
+
const file = defaultGatewaySecretPath(gatewayId, override);
|
|
23
|
+
if (!existsSync(file))
|
|
24
|
+
return null;
|
|
25
|
+
const raw = readFileSync(file, "utf8");
|
|
26
|
+
// W3: guard against corrupt files — JSON.parse throws on malformed input.
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
_warn(`corrupt secret file at ${file} — ignoring`);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Persist a secret blob with mode `0600`, ensuring the parent directory
|
|
37
|
+
* exists with mode `0700`. Writes go through a `.tmp` rename for atomicity.
|
|
38
|
+
*
|
|
39
|
+
* The parent directory mode is re-applied on every write so a permission
|
|
40
|
+
* drift (e.g. operator chmod) is corrected the next time the daemon writes.
|
|
41
|
+
*/
|
|
42
|
+
export function saveGatewaySecret(gatewayId, secret, override) {
|
|
43
|
+
const file = defaultGatewaySecretPath(gatewayId, override);
|
|
44
|
+
const dir = path.dirname(file);
|
|
45
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
try {
|
|
47
|
+
chmodSync(dir, 0o700);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// best-effort
|
|
51
|
+
}
|
|
52
|
+
const tmp = `${file}.tmp`;
|
|
53
|
+
writeFileSync(tmp, JSON.stringify(secret, null, 2), { mode: 0o600 });
|
|
54
|
+
try {
|
|
55
|
+
chmodSync(tmp, 0o600);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// best-effort
|
|
59
|
+
}
|
|
60
|
+
renameSync(tmp, file);
|
|
61
|
+
try {
|
|
62
|
+
chmodSync(file, 0o600);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// best-effort
|
|
66
|
+
}
|
|
67
|
+
return file;
|
|
68
|
+
}
|
|
69
|
+
/** Remove a previously-saved secret. No-op when the file is missing. */
|
|
70
|
+
export function deleteGatewaySecret(gatewayId, override) {
|
|
71
|
+
const file = defaultGatewaySecretPath(gatewayId, override);
|
|
72
|
+
if (!existsSync(file))
|
|
73
|
+
return;
|
|
74
|
+
unlinkSync(file);
|
|
75
|
+
}
|