ddchat 0.1.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/README.md +14 -0
- package/index.ts +13 -0
- package/openclaw.plugin.json +15 -0
- package/package.json +36 -0
- package/setup-entry.ts +4 -0
- package/src/channel.ts +101 -0
- package/src/constants.ts +5 -0
- package/src/dedupe.ts +31 -0
- package/src/gateway.ts +237 -0
- package/src/inbound.ts +379 -0
- package/src/outbound.ts +95 -0
- package/src/pairing.ts +9 -0
- package/src/runtime.ts +24 -0
- package/src/session.ts +19 -0
- package/src/types.ts +126 -0
- package/task/BLOCKERS.md +3 -0
- package/task/DOING.md +3 -0
- package/task/DONE.md +8 -0
- package/task/README.md +17 -0
- package/task/TODO.md +10 -0
- package/test/README.md +48 -0
- package/test/chat.html +304 -0
- package/test/server.mjs +143 -0
package/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# 本地安装ddchat插件
|
|
2
|
+
```shell
|
|
3
|
+
openclaw plugins install "安装包路径"
|
|
4
|
+
```
|
|
5
|
+
|
|
6
|
+
# 默认default账号
|
|
7
|
+
```shell
|
|
8
|
+
openclaw channels add --channel ddchat --token "appId:appSecret"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
# 多个账号时指定账户名 避免覆盖
|
|
12
|
+
```shell
|
|
13
|
+
openclaw channels add --channel ddchat --account xxx --token "appId:appSecret"
|
|
14
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { ddchatPlugin } from "./src/channel.js";
|
|
3
|
+
import { registerDdchatWebhook } from "./src/inbound.js";
|
|
4
|
+
|
|
5
|
+
export { ddchatPlugin } from "./src/channel.js";
|
|
6
|
+
|
|
7
|
+
export default defineChannelPluginEntry({
|
|
8
|
+
id: "ddchat",
|
|
9
|
+
name: "DDChat",
|
|
10
|
+
description: "DDChat channel plugin",
|
|
11
|
+
plugin: ddchatPlugin,
|
|
12
|
+
registerFull: registerDdchatWebhook,
|
|
13
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ddchat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DDChat channel plugin for OpenClaw",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"openclaw": "workspace:*"
|
|
8
|
+
},
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"openclaw": ">=2026.2.0"
|
|
11
|
+
},
|
|
12
|
+
"peerDependenciesMeta": {
|
|
13
|
+
"openclaw": {
|
|
14
|
+
"optional": true
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"openclaw": {
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./index.ts"
|
|
20
|
+
],
|
|
21
|
+
"setupEntry": "./setup-entry.ts",
|
|
22
|
+
"channel": {
|
|
23
|
+
"id": "ddchat",
|
|
24
|
+
"label": "DDChat",
|
|
25
|
+
"selectionLabel": "DDChat (IM)",
|
|
26
|
+
"detailLabel": "DDChat IM",
|
|
27
|
+
"blurb": "DDChat internal IM integration.",
|
|
28
|
+
"order": 90
|
|
29
|
+
},
|
|
30
|
+
"install": {
|
|
31
|
+
"npmSpec": "ddchat",
|
|
32
|
+
"localPath": "ddchat",
|
|
33
|
+
"defaultChoice": "local"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
package/setup-entry.ts
ADDED
package/src/channel.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createChatChannelPlugin, type OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { patchScopedAccountConfig, prepareScopedSetupConfig } from "openclaw/plugin-sdk/setup";
|
|
3
|
+
import { DDCHAT_CHANNEL_ID } from "./constants.js";
|
|
4
|
+
import { ddchatGateway } from "./gateway.js";
|
|
5
|
+
import { ddchatOutbound } from "./outbound.js";
|
|
6
|
+
import { ddchatPairing } from "./pairing.js";
|
|
7
|
+
import { listDdchatAccountIds, resolveDdchatAccount, type DdchatResolvedAccount } from "./types.js";
|
|
8
|
+
|
|
9
|
+
function inspectDdchatAccount(cfg: OpenClawConfig, accountId?: string | null) {
|
|
10
|
+
const account = resolveDdchatAccount(cfg, accountId);
|
|
11
|
+
return {
|
|
12
|
+
enabled: account.enabled,
|
|
13
|
+
configured: account.configured,
|
|
14
|
+
tokenStatus: account.token ? "available" : "missing",
|
|
15
|
+
connectionMode: account.connectionMode,
|
|
16
|
+
dmPolicy: account.dmPolicy,
|
|
17
|
+
groupPolicy: account.groupPolicy,
|
|
18
|
+
streaming: account.streaming,
|
|
19
|
+
streamingMode: account.streamingMode,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ddchatPlugin = createChatChannelPlugin<DdchatResolvedAccount>({
|
|
24
|
+
base: {
|
|
25
|
+
id: DDCHAT_CHANNEL_ID,
|
|
26
|
+
meta: {
|
|
27
|
+
id: DDCHAT_CHANNEL_ID,
|
|
28
|
+
label: "DDChat",
|
|
29
|
+
selectionLabel: "DDChat (IM)",
|
|
30
|
+
blurb: "DDChat internal IM integration.",
|
|
31
|
+
order: 90,
|
|
32
|
+
},
|
|
33
|
+
capabilities: {
|
|
34
|
+
chatTypes: ["direct", "channel"],
|
|
35
|
+
media: true,
|
|
36
|
+
threads: false,
|
|
37
|
+
polls: false,
|
|
38
|
+
},
|
|
39
|
+
config: {
|
|
40
|
+
listAccountIds: (cfg) => listDdchatAccountIds(cfg),
|
|
41
|
+
resolveAccount: (cfg, accountId) => resolveDdchatAccount(cfg, accountId),
|
|
42
|
+
inspectAccount: inspectDdchatAccount,
|
|
43
|
+
isEnabled: (account) => account.enabled,
|
|
44
|
+
isConfigured: (account) => account.configured,
|
|
45
|
+
},
|
|
46
|
+
setup: {
|
|
47
|
+
resolveAccountId: ({ accountId }) => accountId ?? "default",
|
|
48
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
49
|
+
prepareScopedSetupConfig({
|
|
50
|
+
cfg,
|
|
51
|
+
channelKey: DDCHAT_CHANNEL_ID,
|
|
52
|
+
accountId,
|
|
53
|
+
name,
|
|
54
|
+
alwaysUseAccounts: true,
|
|
55
|
+
}),
|
|
56
|
+
validateInput: ({ input }) => {
|
|
57
|
+
const token = typeof input.token === "string" ? input.token.trim() : "";
|
|
58
|
+
return token ? null : "ddchat requires --token";
|
|
59
|
+
},
|
|
60
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
61
|
+
const token = typeof input.token === "string" ? input.token.trim() : "";
|
|
62
|
+
const next = prepareScopedSetupConfig({
|
|
63
|
+
cfg,
|
|
64
|
+
channelKey: DDCHAT_CHANNEL_ID,
|
|
65
|
+
accountId,
|
|
66
|
+
name: input.name,
|
|
67
|
+
alwaysUseAccounts: true,
|
|
68
|
+
});
|
|
69
|
+
const patch: Record<string, unknown> = {};
|
|
70
|
+
if (token) {
|
|
71
|
+
patch.token = token;
|
|
72
|
+
}
|
|
73
|
+
return patchScopedAccountConfig({
|
|
74
|
+
cfg: next,
|
|
75
|
+
channelKey: DDCHAT_CHANNEL_ID,
|
|
76
|
+
accountId,
|
|
77
|
+
patch,
|
|
78
|
+
accountPatch: patch,
|
|
79
|
+
ensureChannelEnabled: true,
|
|
80
|
+
ensureAccountEnabled: true,
|
|
81
|
+
scopeDefaultToAccounts: true,
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
gateway: ddchatGateway,
|
|
86
|
+
},
|
|
87
|
+
pairing: ddchatPairing,
|
|
88
|
+
security: {
|
|
89
|
+
dm: {
|
|
90
|
+
channelKey: DDCHAT_CHANNEL_ID,
|
|
91
|
+
resolvePolicy: (account) => account.dmPolicy,
|
|
92
|
+
resolveAllowFrom: (account) => account.allowFrom,
|
|
93
|
+
defaultPolicy: "pairing",
|
|
94
|
+
normalizeEntry: (raw) => raw.replace(/^ddchat:/i, "").trim(),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
outbound: ddchatOutbound,
|
|
98
|
+
threading: {
|
|
99
|
+
topLevelReplyToMode: "off",
|
|
100
|
+
},
|
|
101
|
+
});
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const DDCHAT_CHANNEL_ID = "ddchat";
|
|
2
|
+
export const DDCHAT_DEFAULT_ACCOUNT_ID = "default";
|
|
3
|
+
|
|
4
|
+
/** Default WebSocket endpoint when `channels.ddchat.wsUrl` / per-account `wsUrl` is unset (`token` query appended at connect). */
|
|
5
|
+
export const DDCHAT_PLUGIN_WS_BASE_URL = "wss://chat.ddjf.info/socket/ai/plugin";
|
package/src/dedupe.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const DEFAULT_TTL_MS = 48 * 60 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
export class DdchatDedupeStore {
|
|
4
|
+
private readonly seen = new Map<string, number>();
|
|
5
|
+
private readonly ttlMs: number;
|
|
6
|
+
|
|
7
|
+
constructor(ttlMs = DEFAULT_TTL_MS) {
|
|
8
|
+
this.ttlMs = ttlMs;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
isDuplicate(accountId: string, messageId: string): boolean {
|
|
12
|
+
this.gc();
|
|
13
|
+
const key = `${accountId}:${messageId}`;
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
const expiresAt = this.seen.get(key);
|
|
16
|
+
if (expiresAt && expiresAt > now) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
this.seen.set(key, now + this.ttlMs);
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private gc(): void {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const [key, expiresAt] of this.seen.entries()) {
|
|
26
|
+
if (expiresAt <= now) {
|
|
27
|
+
this.seen.delete(key);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { DDCHAT_PLUGIN_WS_BASE_URL } from "./constants.js";
|
|
2
|
+
import { processDdchatInboundWithChannelRuntime } from "./inbound.js";
|
|
3
|
+
import { setDdchatWsRuntime } from "./runtime.js";
|
|
4
|
+
import type { DdchatResolvedAccount } from "./types.js";
|
|
5
|
+
|
|
6
|
+
function createReconnectDelayMs(attempt: number): number {
|
|
7
|
+
const base = [1000, 2000, 5000, 10000, 30000][Math.min(attempt, 4)] ?? 30000;
|
|
8
|
+
const jitter = Math.floor(Math.random() * 300);
|
|
9
|
+
return base + jitter;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const ddchatGateway = {
|
|
13
|
+
startAccount: async (ctx) => {
|
|
14
|
+
ctx.log?.info?.(
|
|
15
|
+
`[ddchat:${ctx.accountId}] start mode=${ctx.account.connectionMode} heartbeat=${ctx.account.heartbeatSec}s stream=${ctx.account.streamingMode}`,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (ctx.account.connectionMode === "webhook") {
|
|
19
|
+
setDdchatWsRuntime({ connected: false, send: undefined });
|
|
20
|
+
ctx.setStatus({
|
|
21
|
+
accountId: ctx.accountId,
|
|
22
|
+
running: true,
|
|
23
|
+
connected: true,
|
|
24
|
+
reconnectAttempts: 0,
|
|
25
|
+
heartbeatSec: ctx.account.heartbeatSec,
|
|
26
|
+
inboundMode: "webhook",
|
|
27
|
+
});
|
|
28
|
+
await new Promise<void>((resolve) => {
|
|
29
|
+
const timer = setInterval(() => {
|
|
30
|
+
if (ctx.abortSignal.aborted) {
|
|
31
|
+
clearInterval(timer);
|
|
32
|
+
resolve();
|
|
33
|
+
}
|
|
34
|
+
}, 1000);
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const wsUrl = ctx.account.wsUrl?.trim() || DDCHAT_PLUGIN_WS_BASE_URL;
|
|
40
|
+
|
|
41
|
+
const token = ctx.account.token?.trim();
|
|
42
|
+
if (!token) {
|
|
43
|
+
throw new Error(`ddchat[${ctx.accountId}] missing token (set via channels add --token or config)`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const resolvedWsUrl = appendDdchatAuthQuery(wsUrl, token);
|
|
47
|
+
|
|
48
|
+
let attempts = 0;
|
|
49
|
+
while (!ctx.abortSignal.aborted) {
|
|
50
|
+
attempts += 1;
|
|
51
|
+
const reconnectDelay = createReconnectDelayMs(attempts);
|
|
52
|
+
try {
|
|
53
|
+
await runWsSession({ ctx, wsUrl: resolvedWsUrl, wsUrlForLog: redactDdchatWsUrl(resolvedWsUrl), attempts });
|
|
54
|
+
if (ctx.abortSignal.aborted) {
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws session failed: ${String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
if (ctx.abortSignal.aborted) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
ctx.log?.warn?.(`[ddchat:${ctx.accountId}] reconnecting in ${reconnectDelay}ms`);
|
|
64
|
+
await new Promise<void>((resolve) => setTimeout(resolve, reconnectDelay));
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
stopAccount: async (ctx) => {
|
|
68
|
+
setDdchatWsRuntime({ connected: false, send: undefined });
|
|
69
|
+
ctx.setStatus({
|
|
70
|
+
accountId: ctx.accountId,
|
|
71
|
+
running: false,
|
|
72
|
+
connected: false,
|
|
73
|
+
});
|
|
74
|
+
ctx.log?.info?.(`[ddchat:${ctx.accountId}] stopped`);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function appendDdchatAuthQuery(wsUrl: string, token: string): string {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(wsUrl);
|
|
81
|
+
url.searchParams.set("token", token);
|
|
82
|
+
return url.toString();
|
|
83
|
+
} catch {
|
|
84
|
+
const hasQuery = wsUrl.includes("?");
|
|
85
|
+
const sep = hasQuery ? "&" : "?";
|
|
86
|
+
return `${wsUrl}${sep}token=${encodeURIComponent(token)}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function redactDdchatWsUrl(wsUrl: string): string {
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(wsUrl);
|
|
93
|
+
if (url.searchParams.has("token")) {
|
|
94
|
+
url.searchParams.set("token", "(redacted)");
|
|
95
|
+
}
|
|
96
|
+
return url.toString();
|
|
97
|
+
} catch {
|
|
98
|
+
return wsUrl.replace(/([?&]token=)[^&]*/i, "$1(redacted)");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function runWsSession(params: {
|
|
103
|
+
ctx: {
|
|
104
|
+
accountId: string;
|
|
105
|
+
account: DdchatResolvedAccount;
|
|
106
|
+
cfg: Record<string, unknown>;
|
|
107
|
+
channelRuntime?: {
|
|
108
|
+
reply: Record<string, unknown>;
|
|
109
|
+
routing: Record<string, unknown>;
|
|
110
|
+
media: Record<string, unknown>;
|
|
111
|
+
};
|
|
112
|
+
abortSignal: AbortSignal;
|
|
113
|
+
setStatus: (next: Record<string, unknown>) => void;
|
|
114
|
+
log?: { info?: (m: string) => void; warn?: (m: string) => void; error?: (m: string) => void };
|
|
115
|
+
};
|
|
116
|
+
wsUrl: string;
|
|
117
|
+
wsUrlForLog: string;
|
|
118
|
+
attempts: number;
|
|
119
|
+
}): Promise<void> {
|
|
120
|
+
const { ctx, wsUrl, wsUrlForLog, attempts } = params;
|
|
121
|
+
await new Promise<void>((resolve, reject) => {
|
|
122
|
+
const WebSocketCtor = (
|
|
123
|
+
globalThis as unknown as {
|
|
124
|
+
WebSocket?: new (url: string) => {
|
|
125
|
+
readyState: number;
|
|
126
|
+
send: (data: string) => void;
|
|
127
|
+
close: () => void;
|
|
128
|
+
addEventListener: (name: string, fn: (...args: never[]) => void) => void;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
).WebSocket;
|
|
132
|
+
if (!WebSocketCtor) {
|
|
133
|
+
reject(new Error("WebSocket is not available in this runtime"));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const ws = new WebSocketCtor(wsUrl);
|
|
137
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
138
|
+
let settled = false;
|
|
139
|
+
const finish = (fn: () => void) => {
|
|
140
|
+
if (settled) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
settled = true;
|
|
144
|
+
if (heartbeatTimer) {
|
|
145
|
+
clearInterval(heartbeatTimer);
|
|
146
|
+
}
|
|
147
|
+
setDdchatWsRuntime({ connected: false, send: undefined });
|
|
148
|
+
fn();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
ws.addEventListener("open", () => {
|
|
152
|
+
ctx.log?.info?.(`[ddchat:${ctx.accountId}] ws connected -> ${wsUrlForLog}`);
|
|
153
|
+
setDdchatWsRuntime({
|
|
154
|
+
connected: true,
|
|
155
|
+
send: (payload) => {
|
|
156
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
ws.send(JSON.stringify(payload));
|
|
160
|
+
return true;
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
ctx.setStatus({
|
|
164
|
+
accountId: ctx.accountId,
|
|
165
|
+
running: true,
|
|
166
|
+
connected: true,
|
|
167
|
+
reconnectAttempts: attempts - 1,
|
|
168
|
+
heartbeatSec: ctx.account.heartbeatSec,
|
|
169
|
+
inboundMode: "ws",
|
|
170
|
+
wsUrl: wsUrlForLog,
|
|
171
|
+
});
|
|
172
|
+
heartbeatTimer = setInterval(() => {
|
|
173
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
174
|
+
ws.send(JSON.stringify({ type: "ping", ts: Date.now(), from: "plugin" }));
|
|
175
|
+
}
|
|
176
|
+
}, Math.max(1000, ctx.account.heartbeatSec * 1000));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
ws.addEventListener("message", async (event) => {
|
|
180
|
+
try {
|
|
181
|
+
const raw = typeof event.data === "string" ? event.data : String(event.data);
|
|
182
|
+
const message = JSON.parse(raw) as Record<string, unknown>;
|
|
183
|
+
if (message.type === "ping") {
|
|
184
|
+
ws.send(JSON.stringify({ type: "pong", ts: Date.now(), from: "plugin" }));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (message.type !== "inbound_message") {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!ctx.channelRuntime) {
|
|
191
|
+
throw new Error("channelRuntime unavailable in gateway context");
|
|
192
|
+
}
|
|
193
|
+
const result = await processDdchatInboundWithChannelRuntime({
|
|
194
|
+
channelRuntime: ctx.channelRuntime as never,
|
|
195
|
+
cfg: ctx.cfg as never,
|
|
196
|
+
body: message,
|
|
197
|
+
fallbackAccountId: ctx.accountId,
|
|
198
|
+
source: "ws",
|
|
199
|
+
logInfo: (msg) => ctx.log?.info?.(msg),
|
|
200
|
+
});
|
|
201
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
202
|
+
ws.send(JSON.stringify({ type: "ack", ok: true, from: "plugin", result }));
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws message handling failed: ${String(error)}`);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
ws.addEventListener("error", (event) => {
|
|
210
|
+
ctx.log?.warn?.(`[ddchat:${ctx.accountId}] ws error: ${String((event as Event).type)}`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
ws.addEventListener("close", () => {
|
|
214
|
+
finish(resolve);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (ctx.abortSignal.aborted) {
|
|
218
|
+
try {
|
|
219
|
+
ws.close();
|
|
220
|
+
} catch {}
|
|
221
|
+
finish(resolve);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
ctx.abortSignal.addEventListener(
|
|
225
|
+
"abort",
|
|
226
|
+
() => {
|
|
227
|
+
try {
|
|
228
|
+
ws.close();
|
|
229
|
+
} catch (error) {
|
|
230
|
+
reject(error);
|
|
231
|
+
}
|
|
232
|
+
finish(resolve);
|
|
233
|
+
},
|
|
234
|
+
{ once: true },
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
}
|