opencode-copilot-account-switcher 0.13.6 → 0.14.0
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/common-settings-actions.d.ts +1 -1
- package/dist/common-settings-actions.js +36 -0
- package/dist/common-settings-store.d.ts +4 -0
- package/dist/common-settings-store.js +26 -0
- package/dist/menu-runtime.js +12 -1
- package/dist/plugin-hooks.d.ts +8 -0
- package/dist/plugin-hooks.js +96 -0
- package/dist/providers/codex-menu-adapter.js +8 -1
- package/dist/providers/copilot-menu-adapter.js +7 -0
- package/dist/store-paths.d.ts +1 -0
- package/dist/store-paths.js +3 -0
- package/dist/ui/menu.d.ts +33 -0
- package/dist/ui/menu.js +85 -0
- package/dist/wechat/bridge.d.ts +69 -0
- package/dist/wechat/bridge.js +180 -0
- package/dist/wechat/broker-client.d.ts +33 -0
- package/dist/wechat/broker-client.js +257 -0
- package/dist/wechat/broker-entry.d.ts +17 -0
- package/dist/wechat/broker-entry.js +182 -0
- package/dist/wechat/broker-launcher.d.ts +27 -0
- package/dist/wechat/broker-launcher.js +191 -0
- package/dist/wechat/broker-server.d.ts +25 -0
- package/dist/wechat/broker-server.js +540 -0
- package/dist/wechat/command-parser.d.ts +7 -0
- package/dist/wechat/command-parser.js +16 -0
- package/dist/wechat/compat/openclaw-guided-smoke.d.ts +178 -0
- package/dist/wechat/compat/openclaw-guided-smoke.js +1133 -0
- package/dist/wechat/compat/openclaw-public-helpers.d.ts +101 -0
- package/dist/wechat/compat/openclaw-public-helpers.js +207 -0
- package/dist/wechat/compat/openclaw-smoke.d.ts +48 -0
- package/dist/wechat/compat/openclaw-smoke.js +100 -0
- package/dist/wechat/compat/slash-guard.d.ts +11 -0
- package/dist/wechat/compat/slash-guard.js +24 -0
- package/dist/wechat/handle.d.ts +8 -0
- package/dist/wechat/handle.js +46 -0
- package/dist/wechat/ipc-auth.d.ts +6 -0
- package/dist/wechat/ipc-auth.js +39 -0
- package/dist/wechat/operator-store.d.ts +8 -0
- package/dist/wechat/operator-store.js +59 -0
- package/dist/wechat/protocol.d.ts +29 -0
- package/dist/wechat/protocol.js +75 -0
- package/dist/wechat/request-store.d.ts +41 -0
- package/dist/wechat/request-store.js +215 -0
- package/dist/wechat/session-digest.d.ts +41 -0
- package/dist/wechat/session-digest.js +134 -0
- package/dist/wechat/state-paths.d.ts +14 -0
- package/dist/wechat/state-paths.js +45 -0
- package/dist/wechat/status-format.d.ts +14 -0
- package/dist/wechat/status-format.js +174 -0
- package/dist/wechat/token-store.d.ts +18 -0
- package/dist/wechat/token-store.js +100 -0
- package/dist/wechat/wechat-status-runtime.d.ts +24 -0
- package/dist/wechat/wechat-status-runtime.js +238 -0
- package/package.json +8 -3
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { connect } from "./broker-client.js";
|
|
2
|
+
import { connectOrSpawnBroker } from "./broker-launcher.js";
|
|
3
|
+
import { buildSessionDigest, groupPermissionsBySession, groupQuestionsBySession, pickRecentSessions, } from "./session-digest.js";
|
|
4
|
+
const DEFAULT_LIVE_READ_TIMEOUT_MS = 2_000;
|
|
5
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
6
|
+
function toSafeInstanceID(input) {
|
|
7
|
+
const normalized = input.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
8
|
+
if (normalized.length === 0) {
|
|
9
|
+
return `wechat-${process.pid}`;
|
|
10
|
+
}
|
|
11
|
+
return normalized.slice(0, 64);
|
|
12
|
+
}
|
|
13
|
+
function toProjectName(project) {
|
|
14
|
+
if (typeof project?.name === "string" && project.name.trim().length > 0) {
|
|
15
|
+
return project.name.trim();
|
|
16
|
+
}
|
|
17
|
+
if (typeof project?.id === "string" && project.id.trim().length > 0) {
|
|
18
|
+
return project.id.trim();
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
function toDirectory(inputDirectory) {
|
|
23
|
+
if (typeof inputDirectory === "string" && inputDirectory.trim().length > 0) {
|
|
24
|
+
return inputDirectory;
|
|
25
|
+
}
|
|
26
|
+
return process.cwd();
|
|
27
|
+
}
|
|
28
|
+
function toInstanceName(projectName, directory) {
|
|
29
|
+
if (projectName) {
|
|
30
|
+
return projectName;
|
|
31
|
+
}
|
|
32
|
+
const parts = directory.split(/[\\/]+/).filter((part) => part.length > 0);
|
|
33
|
+
return parts.at(-1) ?? `wechat-${process.pid}`;
|
|
34
|
+
}
|
|
35
|
+
function toInstanceID(projectName, directory) {
|
|
36
|
+
const seed = projectName ?? directory;
|
|
37
|
+
return toSafeInstanceID(seed);
|
|
38
|
+
}
|
|
39
|
+
function withTimeout(task, timeoutMs, name) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
let settled = false;
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
if (settled) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
settled = true;
|
|
47
|
+
reject(new Error(`${name} timed out in ${timeoutMs}ms`));
|
|
48
|
+
}, timeoutMs);
|
|
49
|
+
void Promise.resolve()
|
|
50
|
+
.then(task)
|
|
51
|
+
.then((value) => {
|
|
52
|
+
if (settled) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
settled = true;
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
resolve(value);
|
|
58
|
+
})
|
|
59
|
+
.catch((error) => {
|
|
60
|
+
if (settled) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
settled = true;
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
reject(error);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function createWechatBridge(input) {
|
|
70
|
+
const collectStatusSnapshot = async () => {
|
|
71
|
+
const liveReadTimeoutMs = typeof input.liveReadTimeoutMs === "number" && Number.isFinite(input.liveReadTimeoutMs)
|
|
72
|
+
? Math.max(1, Math.floor(input.liveReadTimeoutMs))
|
|
73
|
+
: DEFAULT_LIVE_READ_TIMEOUT_MS;
|
|
74
|
+
const unavailable = new Set();
|
|
75
|
+
const [sessionListResult, statusResult, questionResult, permissionResult] = await Promise.allSettled([
|
|
76
|
+
withTimeout(() => input.client.session.list(), liveReadTimeoutMs, "session.list"),
|
|
77
|
+
withTimeout(() => input.client.session.status(), liveReadTimeoutMs, "session.status"),
|
|
78
|
+
withTimeout(() => input.client.question.list(), liveReadTimeoutMs, "question.list"),
|
|
79
|
+
withTimeout(() => input.client.permission.list(), liveReadTimeoutMs, "permission.list"),
|
|
80
|
+
]);
|
|
81
|
+
const sessions = sessionListResult.status === "fulfilled" ? sessionListResult.value : [];
|
|
82
|
+
const recentSessions = pickRecentSessions(sessions, 3);
|
|
83
|
+
if (sessionListResult.status === "rejected") {
|
|
84
|
+
unavailable.add("sessionStatus");
|
|
85
|
+
}
|
|
86
|
+
const statusBySession = statusResult.status === "fulfilled"
|
|
87
|
+
? statusResult.value
|
|
88
|
+
: (unavailable.add("sessionStatus"), {});
|
|
89
|
+
const questionsBySession = questionResult.status === "fulfilled"
|
|
90
|
+
? groupQuestionsBySession(questionResult.value)
|
|
91
|
+
: (unavailable.add("questionList"), groupQuestionsBySession([]));
|
|
92
|
+
const permissionsBySession = permissionResult.status === "fulfilled"
|
|
93
|
+
? groupPermissionsBySession(permissionResult.value)
|
|
94
|
+
: (unavailable.add("permissionList"), groupPermissionsBySession([]));
|
|
95
|
+
const sessionDigests = await Promise.all(recentSessions.map(async (session) => {
|
|
96
|
+
const [todoResult, messagesResult] = await Promise.allSettled([
|
|
97
|
+
withTimeout(() => input.client.session.todo(session.id), liveReadTimeoutMs, `session.todo:${session.id}`),
|
|
98
|
+
withTimeout(() => input.client.session.messages(session.id), liveReadTimeoutMs, `session.messages:${session.id}`),
|
|
99
|
+
]);
|
|
100
|
+
const sessionUnavailable = [];
|
|
101
|
+
const todos = todoResult.status === "fulfilled" ? todoResult.value : (sessionUnavailable.push("todo"), []);
|
|
102
|
+
const messages = messagesResult.status === "fulfilled"
|
|
103
|
+
? messagesResult.value
|
|
104
|
+
: (sessionUnavailable.push("messages"), []);
|
|
105
|
+
return buildSessionDigest({
|
|
106
|
+
session,
|
|
107
|
+
statusBySession,
|
|
108
|
+
questionsBySession,
|
|
109
|
+
permissionsBySession,
|
|
110
|
+
todos,
|
|
111
|
+
messages,
|
|
112
|
+
unavailable: sessionUnavailable,
|
|
113
|
+
});
|
|
114
|
+
}));
|
|
115
|
+
return {
|
|
116
|
+
instanceID: input.instanceID,
|
|
117
|
+
instanceName: input.instanceName,
|
|
118
|
+
pid: input.pid,
|
|
119
|
+
projectName: input.projectName,
|
|
120
|
+
directory: input.directory,
|
|
121
|
+
collectedAt: Date.now(),
|
|
122
|
+
sessions: sessionDigests,
|
|
123
|
+
unavailable: unavailable.size > 0 ? [...unavailable] : undefined,
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
collectStatusSnapshot,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export async function createWechatBridgeLifecycle(input, deps = {}) {
|
|
131
|
+
if (input.statusCollectionEnabled !== true) {
|
|
132
|
+
return {
|
|
133
|
+
close: async () => { },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const connectOrSpawnBrokerImpl = deps.connectOrSpawnBrokerImpl ?? connectOrSpawnBroker;
|
|
137
|
+
const connectImpl = deps.connectImpl ?? connect;
|
|
138
|
+
const setIntervalImpl = deps.setIntervalImpl ?? setInterval;
|
|
139
|
+
const clearIntervalImpl = deps.clearIntervalImpl ?? clearInterval;
|
|
140
|
+
const directory = toDirectory(input.directory);
|
|
141
|
+
const projectName = toProjectName(input.project);
|
|
142
|
+
const instanceID = toInstanceID(projectName, directory);
|
|
143
|
+
const bridge = createWechatBridge({
|
|
144
|
+
instanceID,
|
|
145
|
+
instanceName: toInstanceName(projectName, directory),
|
|
146
|
+
pid: process.pid,
|
|
147
|
+
projectName,
|
|
148
|
+
directory,
|
|
149
|
+
client: input.client,
|
|
150
|
+
});
|
|
151
|
+
const broker = await connectOrSpawnBrokerImpl();
|
|
152
|
+
const brokerClient = await connectImpl(broker.endpoint, { bridge });
|
|
153
|
+
try {
|
|
154
|
+
await brokerClient.registerInstance({
|
|
155
|
+
instanceID,
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
await brokerClient.close().catch(() => { });
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
const heartbeatIntervalMs = typeof input.heartbeatIntervalMs === "number" && Number.isFinite(input.heartbeatIntervalMs)
|
|
164
|
+
? Math.max(1_000, Math.floor(input.heartbeatIntervalMs))
|
|
165
|
+
: DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
166
|
+
const timer = setIntervalImpl(() => {
|
|
167
|
+
void brokerClient.heartbeat().catch(() => { });
|
|
168
|
+
}, heartbeatIntervalMs);
|
|
169
|
+
let closed = false;
|
|
170
|
+
return {
|
|
171
|
+
close: async () => {
|
|
172
|
+
if (closed) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
closed = true;
|
|
176
|
+
clearIntervalImpl(timer);
|
|
177
|
+
await brokerClient.close().catch(() => { });
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type BrokerEnvelope } from "./protocol.js";
|
|
2
|
+
import type { WechatBridge } from "./bridge.js";
|
|
3
|
+
type RegisterMeta = {
|
|
4
|
+
instanceID: string;
|
|
5
|
+
pid: number;
|
|
6
|
+
};
|
|
7
|
+
export type RegisterAck = {
|
|
8
|
+
sessionToken: string;
|
|
9
|
+
registeredAt: number;
|
|
10
|
+
brokerPid: number;
|
|
11
|
+
};
|
|
12
|
+
type SessionSnapshot = {
|
|
13
|
+
instanceID: string;
|
|
14
|
+
sessionToken: string;
|
|
15
|
+
registeredAt: number;
|
|
16
|
+
brokerPid: number;
|
|
17
|
+
};
|
|
18
|
+
type BrokerClient = {
|
|
19
|
+
ping: () => Promise<BrokerEnvelope>;
|
|
20
|
+
registerInstance: (meta: RegisterMeta) => Promise<RegisterAck>;
|
|
21
|
+
heartbeat: () => Promise<BrokerEnvelope>;
|
|
22
|
+
getSessionSnapshot: () => SessionSnapshot | null;
|
|
23
|
+
close: () => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
export type CollectStatusInput = {
|
|
26
|
+
requestId: string;
|
|
27
|
+
};
|
|
28
|
+
export type BrokerClientOptions = {
|
|
29
|
+
onCollectStatus?: (input: CollectStatusInput) => Promise<unknown> | unknown;
|
|
30
|
+
bridge?: WechatBridge;
|
|
31
|
+
};
|
|
32
|
+
export declare function connect(endpoint: string, options?: BrokerClientOptions): Promise<BrokerClient>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { parseEnvelopeLine, serializeEnvelope, } from "./protocol.js";
|
|
3
|
+
function isNonEmptyString(value) {
|
|
4
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
5
|
+
}
|
|
6
|
+
function isFiniteNumber(value) {
|
|
7
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
8
|
+
}
|
|
9
|
+
function isResponseForRequest(response, requestId) {
|
|
10
|
+
if (response.id === requestId) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (response.id.endsWith(`-${requestId}`)) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
if (response.type === "error") {
|
|
17
|
+
const payload = response.payload;
|
|
18
|
+
return payload.requestId === requestId;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
export async function connect(endpoint, options = {}) {
|
|
23
|
+
if (options.bridge && options.onCollectStatus) {
|
|
24
|
+
throw new Error("broker client options are ambiguous: provide either bridge or onCollectStatus");
|
|
25
|
+
}
|
|
26
|
+
const socket = net.createConnection(endpoint);
|
|
27
|
+
let sequence = 0;
|
|
28
|
+
let pendingResolve = null;
|
|
29
|
+
let pendingReject = null;
|
|
30
|
+
let pendingRequestId = null;
|
|
31
|
+
let buffer = "";
|
|
32
|
+
let connected = false;
|
|
33
|
+
let closed = false;
|
|
34
|
+
let session = null;
|
|
35
|
+
const connectedReady = new Promise((resolve, reject) => {
|
|
36
|
+
socket.once("connect", () => {
|
|
37
|
+
connected = true;
|
|
38
|
+
resolve();
|
|
39
|
+
});
|
|
40
|
+
socket.once("error", reject);
|
|
41
|
+
});
|
|
42
|
+
socket.on("data", (chunk) => {
|
|
43
|
+
buffer += chunk.toString("utf8");
|
|
44
|
+
while (true) {
|
|
45
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
46
|
+
if (newlineIndex === -1) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const frame = buffer.slice(0, newlineIndex + 1);
|
|
50
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
51
|
+
if (pendingResolve) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = parseEnvelopeLine(frame);
|
|
54
|
+
if (parsed.type === "collectStatus") {
|
|
55
|
+
handleCollectStatus(parsed);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (pendingRequestId && !isResponseForRequest(parsed, pendingRequestId)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const resolve = pendingResolve;
|
|
62
|
+
pendingResolve = null;
|
|
63
|
+
pendingReject = null;
|
|
64
|
+
pendingRequestId = null;
|
|
65
|
+
resolve(parsed);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const reject = pendingReject;
|
|
69
|
+
pendingResolve = null;
|
|
70
|
+
pendingReject = null;
|
|
71
|
+
pendingRequestId = null;
|
|
72
|
+
reject?.(error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
try {
|
|
77
|
+
const parsed = parseEnvelopeLine(frame);
|
|
78
|
+
if (parsed.type === "collectStatus") {
|
|
79
|
+
handleCollectStatus(parsed);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// ignore unsolicited invalid frames when no pending request exists
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
socket.on("error", (error) => {
|
|
89
|
+
if (pendingReject) {
|
|
90
|
+
const reject = pendingReject;
|
|
91
|
+
pendingResolve = null;
|
|
92
|
+
pendingReject = null;
|
|
93
|
+
pendingRequestId = null;
|
|
94
|
+
reject(error);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
socket.on("close", () => {
|
|
98
|
+
connected = false;
|
|
99
|
+
closed = true;
|
|
100
|
+
session = null;
|
|
101
|
+
if (pendingReject) {
|
|
102
|
+
const reject = pendingReject;
|
|
103
|
+
pendingResolve = null;
|
|
104
|
+
pendingReject = null;
|
|
105
|
+
pendingRequestId = null;
|
|
106
|
+
reject(new Error("broker connection closed"));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
await connectedReady;
|
|
110
|
+
function nextRequestId(prefix) {
|
|
111
|
+
sequence += 1;
|
|
112
|
+
return `${prefix}-${Date.now()}-${sequence}`;
|
|
113
|
+
}
|
|
114
|
+
function sendStatusSnapshot(requestId, snapshot) {
|
|
115
|
+
if (!session) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const envelope = {
|
|
119
|
+
id: nextRequestId("statusSnapshot"),
|
|
120
|
+
type: "statusSnapshot",
|
|
121
|
+
instanceID: session.instanceID,
|
|
122
|
+
sessionToken: session.sessionToken,
|
|
123
|
+
payload: {
|
|
124
|
+
requestId,
|
|
125
|
+
snapshot,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
socket.write(serializeEnvelope(envelope));
|
|
129
|
+
}
|
|
130
|
+
function handleCollectStatus(envelope) {
|
|
131
|
+
const payload = envelope.payload;
|
|
132
|
+
if (!isNonEmptyString(payload.requestId)) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const hasBridge = options.bridge !== undefined;
|
|
136
|
+
const hasHook = options.onCollectStatus !== undefined;
|
|
137
|
+
if (!hasBridge && !hasHook) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const collectPromise = hasBridge
|
|
141
|
+
? options.bridge.collectStatusSnapshot()
|
|
142
|
+
: options.onCollectStatus({ requestId: payload.requestId });
|
|
143
|
+
void Promise.resolve(collectPromise)
|
|
144
|
+
.then((snapshot) => {
|
|
145
|
+
sendStatusSnapshot(payload.requestId, snapshot);
|
|
146
|
+
})
|
|
147
|
+
.catch(() => {
|
|
148
|
+
// swallow collect handler errors to keep socket alive
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async function send(envelope) {
|
|
152
|
+
if (!connected || closed) {
|
|
153
|
+
throw new Error("broker connection closed");
|
|
154
|
+
}
|
|
155
|
+
if (pendingResolve) {
|
|
156
|
+
throw new Error("broker client has pending request");
|
|
157
|
+
}
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
pendingResolve = resolve;
|
|
160
|
+
pendingReject = reject;
|
|
161
|
+
pendingRequestId = envelope.id;
|
|
162
|
+
socket.write(serializeEnvelope(envelope));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
async ping() {
|
|
167
|
+
return send({
|
|
168
|
+
id: nextRequestId("ping"),
|
|
169
|
+
type: "ping",
|
|
170
|
+
payload: {},
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
async registerInstance(meta) {
|
|
174
|
+
const instanceID = meta.instanceID;
|
|
175
|
+
if (!isNonEmptyString(instanceID)) {
|
|
176
|
+
throw new Error("invalid instanceID");
|
|
177
|
+
}
|
|
178
|
+
if (!isFiniteNumber(meta.pid)) {
|
|
179
|
+
throw new Error("invalid pid");
|
|
180
|
+
}
|
|
181
|
+
const response = await send({
|
|
182
|
+
id: nextRequestId("register"),
|
|
183
|
+
type: "registerInstance",
|
|
184
|
+
instanceID,
|
|
185
|
+
payload: { pid: meta.pid },
|
|
186
|
+
});
|
|
187
|
+
if (response.type !== "registerAck") {
|
|
188
|
+
throw new Error("register failed");
|
|
189
|
+
}
|
|
190
|
+
const payload = response.payload;
|
|
191
|
+
if (!isNonEmptyString(payload.sessionToken)) {
|
|
192
|
+
throw new Error("registerAck missing sessionToken");
|
|
193
|
+
}
|
|
194
|
+
if (!isFiniteNumber(payload.registeredAt)) {
|
|
195
|
+
throw new Error("registerAck missing registeredAt");
|
|
196
|
+
}
|
|
197
|
+
if (!isFiniteNumber(payload.brokerPid)) {
|
|
198
|
+
throw new Error("registerAck missing brokerPid");
|
|
199
|
+
}
|
|
200
|
+
session = {
|
|
201
|
+
instanceID,
|
|
202
|
+
sessionToken: payload.sessionToken,
|
|
203
|
+
registeredAt: payload.registeredAt,
|
|
204
|
+
brokerPid: payload.brokerPid,
|
|
205
|
+
};
|
|
206
|
+
return {
|
|
207
|
+
sessionToken: session.sessionToken,
|
|
208
|
+
registeredAt: session.registeredAt,
|
|
209
|
+
brokerPid: session.brokerPid,
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
async heartbeat() {
|
|
213
|
+
if (!session) {
|
|
214
|
+
throw new Error("missing broker session");
|
|
215
|
+
}
|
|
216
|
+
return send({
|
|
217
|
+
id: nextRequestId("heartbeat"),
|
|
218
|
+
type: "heartbeat",
|
|
219
|
+
instanceID: session.instanceID,
|
|
220
|
+
sessionToken: session.sessionToken,
|
|
221
|
+
payload: {},
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
getSessionSnapshot() {
|
|
225
|
+
if (!session) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return { ...session };
|
|
229
|
+
},
|
|
230
|
+
async close() {
|
|
231
|
+
if (closed) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (socket.destroyed) {
|
|
235
|
+
closed = true;
|
|
236
|
+
connected = false;
|
|
237
|
+
session = null;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const closePromise = new Promise((resolve) => {
|
|
241
|
+
socket.once("close", () => resolve());
|
|
242
|
+
});
|
|
243
|
+
socket.end();
|
|
244
|
+
await Promise.race([
|
|
245
|
+
closePromise,
|
|
246
|
+
new Promise((resolve) => {
|
|
247
|
+
setTimeout(() => {
|
|
248
|
+
if (!socket.destroyed) {
|
|
249
|
+
socket.destroy();
|
|
250
|
+
}
|
|
251
|
+
resolve();
|
|
252
|
+
}, 200);
|
|
253
|
+
}),
|
|
254
|
+
]);
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type WechatStatusRuntime } from "./wechat-status-runtime.js";
|
|
2
|
+
type BrokerWechatStatusRuntimeLifecycle = {
|
|
3
|
+
start: () => Promise<void>;
|
|
4
|
+
close: () => Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
type BrokerWechatStatusRuntimeLifecycleDeps = {
|
|
7
|
+
createStatusRuntime?: (deps: {
|
|
8
|
+
onSlashCommand: (input: {
|
|
9
|
+
command: import("./command-parser.js").WechatSlashCommand;
|
|
10
|
+
}) => Promise<string>;
|
|
11
|
+
}) => WechatStatusRuntime;
|
|
12
|
+
handleWechatSlashCommand?: (command: import("./command-parser.js").WechatSlashCommand) => Promise<string>;
|
|
13
|
+
onRuntimeError?: (error: unknown) => void;
|
|
14
|
+
};
|
|
15
|
+
export declare function shouldEnableBrokerWechatStatusRuntime(env?: NodeJS.ProcessEnv): boolean;
|
|
16
|
+
export declare function createBrokerWechatStatusRuntimeLifecycle(deps?: BrokerWechatStatusRuntimeLifecycleDeps): BrokerWechatStatusRuntimeLifecycle;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { startBrokerServer } from "./broker-server.js";
|
|
7
|
+
import { WECHAT_FILE_MODE, wechatStateRoot } from "./state-paths.js";
|
|
8
|
+
import { createWechatStatusRuntime } from "./wechat-status-runtime.js";
|
|
9
|
+
async function readPackageVersion() {
|
|
10
|
+
const packageJsonPath = new URL("../../package.json", import.meta.url);
|
|
11
|
+
return readFile(packageJsonPath, "utf8")
|
|
12
|
+
.then((raw) => {
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
if (typeof parsed.version === "string" && parsed.version.trim().length > 0) {
|
|
15
|
+
return parsed.version;
|
|
16
|
+
}
|
|
17
|
+
return "unknown";
|
|
18
|
+
})
|
|
19
|
+
.catch(() => "unknown");
|
|
20
|
+
}
|
|
21
|
+
function parseEndpointArg(argv) {
|
|
22
|
+
const prefix = "--endpoint=";
|
|
23
|
+
const endpointArg = argv.find((item) => item.startsWith(prefix));
|
|
24
|
+
if (!endpointArg) {
|
|
25
|
+
throw new Error("missing --endpoint argument");
|
|
26
|
+
}
|
|
27
|
+
const endpoint = endpointArg.slice(prefix.length);
|
|
28
|
+
if (!endpoint) {
|
|
29
|
+
throw new Error("missing --endpoint argument");
|
|
30
|
+
}
|
|
31
|
+
return endpoint;
|
|
32
|
+
}
|
|
33
|
+
function parseStateRootArg(argv) {
|
|
34
|
+
const prefix = "--state-root=";
|
|
35
|
+
const arg = argv.find((item) => item.startsWith(prefix));
|
|
36
|
+
if (!arg) {
|
|
37
|
+
return wechatStateRoot();
|
|
38
|
+
}
|
|
39
|
+
const stateRoot = arg.slice(prefix.length);
|
|
40
|
+
if (!stateRoot) {
|
|
41
|
+
throw new Error("missing --state-root argument");
|
|
42
|
+
}
|
|
43
|
+
return stateRoot;
|
|
44
|
+
}
|
|
45
|
+
function brokerStatePathForRoot(stateRoot) {
|
|
46
|
+
return path.join(stateRoot, "broker.json");
|
|
47
|
+
}
|
|
48
|
+
async function writeBrokerState(state, stateRoot) {
|
|
49
|
+
await mkdir(stateRoot, { recursive: true, mode: 0o700 });
|
|
50
|
+
const filePath = brokerStatePathForRoot(stateRoot);
|
|
51
|
+
await writeFile(filePath, JSON.stringify(state, null, 2), { mode: WECHAT_FILE_MODE });
|
|
52
|
+
}
|
|
53
|
+
export function shouldEnableBrokerWechatStatusRuntime(env = process.env) {
|
|
54
|
+
return env.WECHAT_BROKER_ENABLE_STATUS_RUNTIME === "1";
|
|
55
|
+
}
|
|
56
|
+
export function createBrokerWechatStatusRuntimeLifecycle(deps = {}) {
|
|
57
|
+
const onRuntimeError = deps.onRuntimeError ?? ((error) => console.error(error));
|
|
58
|
+
const handleWechatSlashCommand = deps.handleWechatSlashCommand ??
|
|
59
|
+
(async (command) => {
|
|
60
|
+
if (command.type === "status") {
|
|
61
|
+
return "命令暂未实现:/status";
|
|
62
|
+
}
|
|
63
|
+
return `命令暂未实现:/${command.command}`;
|
|
64
|
+
});
|
|
65
|
+
const createStatusRuntime = deps.createStatusRuntime ??
|
|
66
|
+
((statusRuntimeDeps) => createWechatStatusRuntime({
|
|
67
|
+
onSlashCommand: async ({ command }) => statusRuntimeDeps.onSlashCommand({ command }),
|
|
68
|
+
onRuntimeError,
|
|
69
|
+
}));
|
|
70
|
+
let runtime = null;
|
|
71
|
+
return {
|
|
72
|
+
start: async () => {
|
|
73
|
+
if (runtime) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const created = createStatusRuntime({
|
|
77
|
+
onSlashCommand: async ({ command }) => handleWechatSlashCommand(command),
|
|
78
|
+
});
|
|
79
|
+
runtime = created;
|
|
80
|
+
try {
|
|
81
|
+
await created.start();
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
onRuntimeError(error);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
close: async () => {
|
|
88
|
+
if (!runtime) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const active = runtime;
|
|
92
|
+
runtime = null;
|
|
93
|
+
await active.close().catch((error) => {
|
|
94
|
+
onRuntimeError(error);
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function removeOwnedBrokerStateFileSync(ownership, stateRoot) {
|
|
100
|
+
try {
|
|
101
|
+
const filePath = brokerStatePathForRoot(stateRoot);
|
|
102
|
+
const raw = readFileSync(filePath, "utf8");
|
|
103
|
+
const parsed = JSON.parse(raw);
|
|
104
|
+
if (parsed.pid !== ownership.pid || parsed.startedAt !== ownership.startedAt) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
rmSync(filePath, { force: true });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// ignore cleanup errors on shutdown
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function run() {
|
|
114
|
+
const args = process.argv.slice(2);
|
|
115
|
+
const endpoint = parseEndpointArg(args);
|
|
116
|
+
const stateRoot = parseStateRootArg(args);
|
|
117
|
+
const server = await startBrokerServer(endpoint);
|
|
118
|
+
const version = await readPackageVersion();
|
|
119
|
+
const state = {
|
|
120
|
+
pid: process.pid,
|
|
121
|
+
endpoint: server.endpoint,
|
|
122
|
+
startedAt: server.startedAt,
|
|
123
|
+
version,
|
|
124
|
+
};
|
|
125
|
+
await writeBrokerState(state, stateRoot);
|
|
126
|
+
const wechatRuntimeLifecycle = createBrokerWechatStatusRuntimeLifecycle({
|
|
127
|
+
handleWechatSlashCommand: server.handleWechatSlashCommand,
|
|
128
|
+
});
|
|
129
|
+
if (shouldEnableBrokerWechatStatusRuntime()) {
|
|
130
|
+
await wechatRuntimeLifecycle.start();
|
|
131
|
+
}
|
|
132
|
+
const ownership = {
|
|
133
|
+
pid: state.pid,
|
|
134
|
+
startedAt: state.startedAt,
|
|
135
|
+
};
|
|
136
|
+
let shuttingDown = false;
|
|
137
|
+
const shutdown = async (exitCode = 0) => {
|
|
138
|
+
if (shuttingDown) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
shuttingDown = true;
|
|
142
|
+
removeOwnedBrokerStateFileSync(ownership, stateRoot);
|
|
143
|
+
await wechatRuntimeLifecycle.close();
|
|
144
|
+
await server.close();
|
|
145
|
+
process.exit(exitCode);
|
|
146
|
+
};
|
|
147
|
+
process.once("SIGINT", () => {
|
|
148
|
+
void shutdown(0);
|
|
149
|
+
});
|
|
150
|
+
process.once("SIGTERM", () => {
|
|
151
|
+
void shutdown(0);
|
|
152
|
+
});
|
|
153
|
+
if (process.env.WECHAT_BROKER_EXIT_ON_STDIN_EOF === "1") {
|
|
154
|
+
process.stdin.on("end", () => {
|
|
155
|
+
void shutdown(0);
|
|
156
|
+
});
|
|
157
|
+
process.stdin.resume();
|
|
158
|
+
}
|
|
159
|
+
process.once("uncaughtException", (error) => {
|
|
160
|
+
console.error(error);
|
|
161
|
+
void shutdown(1);
|
|
162
|
+
});
|
|
163
|
+
process.once("unhandledRejection", (error) => {
|
|
164
|
+
console.error(error);
|
|
165
|
+
void shutdown(1);
|
|
166
|
+
});
|
|
167
|
+
process.on("exit", () => {
|
|
168
|
+
removeOwnedBrokerStateFileSync(ownership, stateRoot);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function isDirectRun() {
|
|
172
|
+
if (!process.argv[1]) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
return path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
176
|
+
}
|
|
177
|
+
if (isDirectRun()) {
|
|
178
|
+
void run().catch((error) => {
|
|
179
|
+
console.error(error);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type BrokerMetadata = {
|
|
2
|
+
pid: number;
|
|
3
|
+
endpoint: string;
|
|
4
|
+
startedAt: number;
|
|
5
|
+
version: string;
|
|
6
|
+
};
|
|
7
|
+
type LaunchLockContent = {
|
|
8
|
+
pid: number;
|
|
9
|
+
acquiredAt: number;
|
|
10
|
+
lockId: string;
|
|
11
|
+
};
|
|
12
|
+
type LaunchOptions = {
|
|
13
|
+
stateRoot?: string;
|
|
14
|
+
brokerJsonPath?: string;
|
|
15
|
+
launchLockPath?: string;
|
|
16
|
+
backoffMs?: number;
|
|
17
|
+
maxAttempts?: number;
|
|
18
|
+
endpointFactory?: () => string;
|
|
19
|
+
spawnImpl?: (endpoint: string, stateRoot: string) => {
|
|
20
|
+
pid?: number | undefined;
|
|
21
|
+
unref?: (() => void) | undefined;
|
|
22
|
+
};
|
|
23
|
+
pingImpl?: (endpoint: string) => Promise<boolean>;
|
|
24
|
+
onLockAcquired?: (lock: LaunchLockContent) => void;
|
|
25
|
+
};
|
|
26
|
+
export declare function connectOrSpawnBroker(options?: LaunchOptions): Promise<BrokerMetadata>;
|
|
27
|
+
export {};
|