@honeybbq/teamspeak 0.0.1 → 1.0.0-beta.7
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/LICENSE +21 -0
- package/README.md +726 -2
- package/dist/client/abort.d.ts +5 -0
- package/dist/client/abort.d.ts.map +1 -0
- package/dist/client/abort.js +23 -0
- package/dist/client/abort.js.map +1 -0
- package/dist/client/address.d.ts +17 -0
- package/dist/client/address.d.ts.map +1 -0
- package/dist/client/address.js +66 -0
- package/dist/client/address.js.map +1 -0
- package/dist/client/api/voice.d.ts +24 -0
- package/dist/client/api/voice.d.ts.map +1 -0
- package/dist/client/api/voice.js +33 -0
- package/dist/client/api/voice.js.map +1 -0
- package/dist/client/capabilities.d.ts +68 -0
- package/dist/client/capabilities.d.ts.map +1 -0
- package/dist/client/capabilities.js +59 -0
- package/dist/client/capabilities.js.map +1 -0
- package/dist/client/client.d.ts +196 -0
- package/dist/client/client.d.ts.map +1 -0
- package/dist/client/client.js +1523 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/commands.d.ts +66 -0
- package/dist/client/commands.d.ts.map +1 -0
- package/dist/client/commands.js +162 -0
- package/dist/client/commands.js.map +1 -0
- package/dist/client/crypto/crypt-ops.d.ts +7 -0
- package/dist/client/crypto/crypt-ops.d.ts.map +1 -0
- package/dist/client/crypto/crypt-ops.js +2 -0
- package/dist/client/crypto/crypt-ops.js.map +1 -0
- package/dist/client/crypto/crypt.d.ts +37 -0
- package/dist/client/crypto/crypt.d.ts.map +1 -0
- package/dist/client/crypto/crypt.js +203 -0
- package/dist/client/crypto/crypt.js.map +1 -0
- package/dist/client/crypto/eax.d.ts +12 -0
- package/dist/client/crypto/eax.d.ts.map +1 -0
- package/dist/client/crypto/eax.js +118 -0
- package/dist/client/crypto/eax.js.map +1 -0
- package/dist/client/crypto/identity.d.ts +97 -0
- package/dist/client/crypto/identity.d.ts.map +1 -0
- package/dist/client/crypto/identity.js +412 -0
- package/dist/client/crypto/identity.js.map +1 -0
- package/dist/client/crypto/index.d.ts +7 -0
- package/dist/client/crypto/index.d.ts.map +1 -0
- package/dist/client/crypto/index.js +5 -0
- package/dist/client/crypto/index.js.map +1 -0
- package/dist/client/crypto/primitives.d.ts +43 -0
- package/dist/client/crypto/primitives.d.ts.map +1 -0
- package/dist/client/crypto/primitives.js +116 -0
- package/dist/client/crypto/primitives.js.map +1 -0
- package/dist/client/diagnostics.d.ts +45 -0
- package/dist/client/diagnostics.d.ts.map +1 -0
- package/dist/client/diagnostics.js +30 -0
- package/dist/client/diagnostics.js.map +1 -0
- package/dist/client/discovery/index.d.ts +2 -0
- package/dist/client/discovery/index.d.ts.map +1 -0
- package/dist/client/discovery/index.js +2 -0
- package/dist/client/discovery/index.js.map +1 -0
- package/dist/client/discovery/resolver.d.ts +26 -0
- package/dist/client/discovery/resolver.d.ts.map +1 -0
- package/dist/client/discovery/resolver.js +275 -0
- package/dist/client/discovery/resolver.js.map +1 -0
- package/dist/client/errors.d.ts +82 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +201 -0
- package/dist/client/errors.js.map +1 -0
- package/dist/client/events.d.ts +3 -0
- package/dist/client/events.d.ts.map +1 -0
- package/dist/client/events.js +8 -0
- package/dist/client/events.js.map +1 -0
- package/dist/client/handshake/crypt-handshake.d.ts +17 -0
- package/dist/client/handshake/crypt-handshake.d.ts.map +1 -0
- package/dist/client/handshake/crypt-handshake.js +128 -0
- package/dist/client/handshake/crypt-handshake.js.map +1 -0
- package/dist/client/handshake/crypt-init2.d.ts +7 -0
- package/dist/client/handshake/crypt-init2.d.ts.map +1 -0
- package/dist/client/handshake/crypt-init2.js +37 -0
- package/dist/client/handshake/crypt-init2.js.map +1 -0
- package/dist/client/handshake/index.d.ts +4 -0
- package/dist/client/handshake/index.d.ts.map +1 -0
- package/dist/client/handshake/index.js +4 -0
- package/dist/client/handshake/index.js.map +1 -0
- package/dist/client/handshake/license.d.ts +35 -0
- package/dist/client/handshake/license.d.ts.map +1 -0
- package/dist/client/handshake/license.js +169 -0
- package/dist/client/handshake/license.js.map +1 -0
- package/dist/client/handshake.d.ts +27 -0
- package/dist/client/handshake.d.ts.map +1 -0
- package/dist/client/handshake.js +126 -0
- package/dist/client/handshake.js.map +1 -0
- package/dist/client/helpers.d.ts +6 -0
- package/dist/client/helpers.d.ts.map +1 -0
- package/dist/client/helpers.js +13 -0
- package/dist/client/helpers.js.map +1 -0
- package/dist/client/hooks.d.ts +7 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +28 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/index.d.ts +26 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +28 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/reconnect.d.ts +12 -0
- package/dist/client/reconnect.d.ts.map +1 -0
- package/dist/client/reconnect.js +44 -0
- package/dist/client/reconnect.js.map +1 -0
- package/dist/client/throttle.d.ts +20 -0
- package/dist/client/throttle.d.ts.map +1 -0
- package/dist/client/throttle.js +83 -0
- package/dist/client/throttle.js.map +1 -0
- package/dist/client/trace.d.ts +223 -0
- package/dist/client/trace.d.ts.map +1 -0
- package/dist/client/trace.js +13 -0
- package/dist/client/trace.js.map +1 -0
- package/dist/client/transfer.d.ts +111 -0
- package/dist/client/transfer.d.ts.map +1 -0
- package/dist/client/transfer.js +243 -0
- package/dist/client/transfer.js.map +1 -0
- package/dist/client/transport/generation-window.d.ts +30 -0
- package/dist/client/transport/generation-window.d.ts.map +1 -0
- package/dist/client/transport/generation-window.js +97 -0
- package/dist/client/transport/generation-window.js.map +1 -0
- package/dist/client/transport/handler.d.ts +61 -0
- package/dist/client/transport/handler.d.ts.map +1 -0
- package/dist/client/transport/handler.js +793 -0
- package/dist/client/transport/handler.js.map +1 -0
- package/dist/client/transport/index.d.ts +6 -0
- package/dist/client/transport/index.d.ts.map +1 -0
- package/dist/client/transport/index.js +5 -0
- package/dist/client/transport/index.js.map +1 -0
- package/dist/client/transport/packet.d.ts +36 -0
- package/dist/client/transport/packet.d.ts.map +1 -0
- package/dist/client/transport/packet.js +55 -0
- package/dist/client/transport/packet.js.map +1 -0
- package/dist/client/transport/quicklz.d.ts +24 -0
- package/dist/client/transport/quicklz.d.ts.map +1 -0
- package/dist/client/transport/quicklz.js +179 -0
- package/dist/client/transport/quicklz.js.map +1 -0
- package/dist/client/types.d.ts +226 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +39 -0
- package/dist/client/types.js.map +1 -0
- package/dist/core/api/channels.d.ts +182 -0
- package/dist/core/api/channels.d.ts.map +1 -0
- package/dist/core/api/channels.js +155 -0
- package/dist/core/api/channels.js.map +1 -0
- package/dist/core/api/clients.d.ts +304 -0
- package/dist/core/api/clients.d.ts.map +1 -0
- package/dist/core/api/clients.js +260 -0
- package/dist/core/api/clients.js.map +1 -0
- package/dist/core/api/command.d.ts +305 -0
- package/dist/core/api/command.d.ts.map +1 -0
- package/dist/core/api/command.js +363 -0
- package/dist/core/api/command.js.map +1 -0
- package/dist/core/api/files.d.ts +153 -0
- package/dist/core/api/files.d.ts.map +1 -0
- package/dist/core/api/files.js +181 -0
- package/dist/core/api/files.js.map +1 -0
- package/dist/core/api/groups.d.ts +248 -0
- package/dist/core/api/groups.d.ts.map +1 -0
- package/dist/core/api/groups.js +271 -0
- package/dist/core/api/groups.js.map +1 -0
- package/dist/core/api/moderation.d.ts +205 -0
- package/dist/core/api/moderation.d.ts.map +1 -0
- package/dist/core/api/moderation.js +209 -0
- package/dist/core/api/moderation.js.map +1 -0
- package/dist/core/api/parse.d.ts +16 -0
- package/dist/core/api/parse.d.ts.map +1 -0
- package/dist/core/api/parse.js +40 -0
- package/dist/core/api/parse.js.map +1 -0
- package/dist/core/api/server.d.ts +310 -0
- package/dist/core/api/server.d.ts.map +1 -0
- package/dist/core/api/server.js +290 -0
- package/dist/core/api/server.js.map +1 -0
- package/dist/core/command/command.d.ts +20 -0
- package/dist/core/command/command.d.ts.map +1 -0
- package/dist/core/command/command.js +112 -0
- package/dist/core/command/command.js.map +1 -0
- package/dist/core/command/ids.d.ts +125 -0
- package/dist/core/command/ids.d.ts.map +1 -0
- package/dist/core/command/ids.js +85 -0
- package/dist/core/command/ids.js.map +1 -0
- package/dist/core/command/parser.d.ts +17 -0
- package/dist/core/command/parser.d.ts.map +1 -0
- package/dist/core/command/parser.js +65 -0
- package/dist/core/command/parser.js.map +1 -0
- package/dist/core/command/request.d.ts +61 -0
- package/dist/core/command/request.d.ts.map +1 -0
- package/dist/core/command/request.js +64 -0
- package/dist/core/command/request.js.map +1 -0
- package/dist/core/command/responses.d.ts +59 -0
- package/dist/core/command/responses.d.ts.map +1 -0
- package/dist/core/command/responses.js +74 -0
- package/dist/core/command/responses.js.map +1 -0
- package/dist/core/command-name.d.ts +333 -0
- package/dist/core/command-name.d.ts.map +1 -0
- package/dist/core/command-name.js +331 -0
- package/dist/core/command-name.js.map +1 -0
- package/dist/core/enums.d.ts +146 -0
- package/dist/core/enums.d.ts.map +1 -0
- package/dist/core/enums.js +132 -0
- package/dist/core/enums.js.map +1 -0
- package/dist/core/errorcode.d.ts +413 -0
- package/dist/core/errorcode.d.ts.map +1 -0
- package/dist/core/errorcode.js +410 -0
- package/dist/core/errorcode.js.map +1 -0
- package/dist/core/errors.d.ts +111 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +233 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/events.d.ts +207 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +6 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/helpers.d.ts +42 -0
- package/dist/core/helpers.d.ts.map +1 -0
- package/dist/core/helpers.js +156 -0
- package/dist/core/helpers.js.map +1 -0
- package/dist/core/index.d.ts +35 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +34 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/internal.d.ts +23 -0
- package/dist/core/internal.d.ts.map +1 -0
- package/dist/core/internal.js +20 -0
- package/dist/core/internal.js.map +1 -0
- package/dist/core/middleware.d.ts +14 -0
- package/dist/core/middleware.d.ts.map +1 -0
- package/dist/core/middleware.js +18 -0
- package/dist/core/middleware.js.map +1 -0
- package/dist/core/notification-name.d.ts +154 -0
- package/dist/core/notification-name.d.ts.map +1 -0
- package/dist/core/notification-name.js +152 -0
- package/dist/core/notification-name.js.map +1 -0
- package/dist/core/notifications.d.ts +43 -0
- package/dist/core/notifications.d.ts.map +1 -0
- package/dist/core/notifications.js +116 -0
- package/dist/core/notifications.js.map +1 -0
- package/dist/core/permission-name.d.ts +533 -0
- package/dist/core/permission-name.d.ts.map +1 -0
- package/dist/core/permission-name.js +531 -0
- package/dist/core/permission-name.js.map +1 -0
- package/dist/core/permission.d.ts +31 -0
- package/dist/core/permission.d.ts.map +1 -0
- package/dist/core/permission.js +20 -0
- package/dist/core/permission.js.map +1 -0
- package/dist/core/property-name.d.ts +403 -0
- package/dist/core/property-name.d.ts.map +1 -0
- package/dist/core/property-name.js +405 -0
- package/dist/core/property-name.js.map +1 -0
- package/dist/core/reason.d.ts +39 -0
- package/dist/core/reason.d.ts.map +1 -0
- package/dist/core/reason.js +36 -0
- package/dist/core/reason.js.map +1 -0
- package/dist/core/reconnect.d.ts +59 -0
- package/dist/core/reconnect.d.ts.map +1 -0
- package/dist/core/reconnect.js +55 -0
- package/dist/core/reconnect.js.map +1 -0
- package/dist/core/types.d.ts +110 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +27 -0
- package/dist/core/types.js.map +1 -0
- package/dist/query/client.d.ts +194 -0
- package/dist/query/client.d.ts.map +1 -0
- package/dist/query/client.js +967 -0
- package/dist/query/client.js.map +1 -0
- package/dist/query/errors.d.ts +61 -0
- package/dist/query/errors.d.ts.map +1 -0
- package/dist/query/errors.js +100 -0
- package/dist/query/errors.js.map +1 -0
- package/dist/query/index.d.ts +36 -0
- package/dist/query/index.d.ts.map +1 -0
- package/dist/query/index.js +34 -0
- package/dist/query/index.js.map +1 -0
- package/dist/query/knownhosts.d.ts +61 -0
- package/dist/query/knownhosts.d.ts.map +1 -0
- package/dist/query/knownhosts.js +223 -0
- package/dist/query/knownhosts.js.map +1 -0
- package/dist/query/lines.d.ts +14 -0
- package/dist/query/lines.d.ts.map +1 -0
- package/dist/query/lines.js +29 -0
- package/dist/query/lines.js.map +1 -0
- package/dist/query/options.d.ts +187 -0
- package/dist/query/options.d.ts.map +1 -0
- package/dist/query/options.js +213 -0
- package/dist/query/options.js.map +1 -0
- package/dist/query/reconnect.d.ts +29 -0
- package/dist/query/reconnect.d.ts.map +1 -0
- package/dist/query/reconnect.js +87 -0
- package/dist/query/reconnect.js.map +1 -0
- package/dist/query/ssh.d.ts +345 -0
- package/dist/query/ssh.d.ts.map +1 -0
- package/dist/query/ssh.js +241 -0
- package/dist/query/ssh.js.map +1 -0
- package/dist/query/stream.d.ts +38 -0
- package/dist/query/stream.d.ts.map +1 -0
- package/dist/query/stream.js +70 -0
- package/dist/query/stream.js.map +1 -0
- package/dist/query/tcp.d.ts +38 -0
- package/dist/query/tcp.d.ts.map +1 -0
- package/dist/query/tcp.js +53 -0
- package/dist/query/tcp.js.map +1 -0
- package/dist/query/transport.d.ts +36 -0
- package/dist/query/transport.d.ts.map +1 -0
- package/dist/query/transport.js +2 -0
- package/dist/query/transport.js.map +1 -0
- package/dist/query/web.d.ts +54 -0
- package/dist/query/web.d.ts.map +1 -0
- package/dist/query/web.js +146 -0
- package/dist/query/web.js.map +1 -0
- package/dist/versions/index.d.ts +3094 -0
- package/dist/versions/index.d.ts.map +1 -0
- package/dist/versions/index.js +9237 -0
- package/dist/versions/index.js.map +1 -0
- package/package.json +118 -4
- package/src/client/abort.ts +24 -0
- package/src/client/address.ts +74 -0
- package/src/client/api/voice.ts +61 -0
- package/src/client/capabilities.ts +106 -0
- package/src/client/client.ts +1809 -0
- package/src/client/commands.ts +193 -0
- package/src/client/crypto/crypt-ops.ts +6 -0
- package/src/client/crypto/crypt.ts +275 -0
- package/src/client/crypto/eax.ts +152 -0
- package/src/client/crypto/identity.ts +484 -0
- package/src/client/crypto/index.ts +21 -0
- package/src/client/crypto/primitives.ts +141 -0
- package/src/client/diagnostics.ts +58 -0
- package/src/client/discovery/index.ts +1 -0
- package/src/client/discovery/resolver.ts +325 -0
- package/src/client/errors.ts +217 -0
- package/src/client/events.ts +12 -0
- package/src/client/handshake/crypt-handshake.ts +157 -0
- package/src/client/handshake/crypt-init2.ts +48 -0
- package/src/client/handshake/index.ts +3 -0
- package/src/client/handshake/license.ts +208 -0
- package/src/client/handshake.ts +150 -0
- package/src/client/helpers.ts +10 -0
- package/src/client/hooks.ts +30 -0
- package/src/client/index.ts +93 -0
- package/src/client/reconnect.ts +43 -0
- package/src/client/throttle.ts +92 -0
- package/src/client/trace.ts +237 -0
- package/src/client/transfer.ts +436 -0
- package/src/client/transport/generation-window.ts +108 -0
- package/src/client/transport/handler.ts +926 -0
- package/src/client/transport/index.ts +14 -0
- package/src/client/transport/packet.ts +69 -0
- package/src/client/transport/quicklz.ts +209 -0
- package/src/client/types.ts +268 -0
- package/src/core/api/channels.ts +396 -0
- package/src/core/api/clients.ts +654 -0
- package/src/core/api/command.ts +563 -0
- package/src/core/api/files.ts +405 -0
- package/src/core/api/groups.ts +622 -0
- package/src/core/api/moderation.ts +525 -0
- package/src/core/api/parse.ts +52 -0
- package/src/core/api/server.ts +709 -0
- package/src/core/command/command.ts +125 -0
- package/src/core/command/ids.ts +149 -0
- package/src/core/command/parser.ts +68 -0
- package/src/core/command/request.ts +119 -0
- package/src/core/command/responses.ts +114 -0
- package/src/core/command-name.ts +334 -0
- package/src/core/enums.ts +167 -0
- package/src/core/errorcode.ts +412 -0
- package/src/core/errors.ts +258 -0
- package/src/core/events.ts +219 -0
- package/src/core/helpers.ts +161 -0
- package/src/core/index.ts +126 -0
- package/src/core/internal.ts +23 -0
- package/src/core/middleware.ts +24 -0
- package/src/core/notification-name.ts +155 -0
- package/src/core/notifications.ts +157 -0
- package/src/core/permission-name.ts +538 -0
- package/src/core/permission.ts +34 -0
- package/src/core/property-name.ts +411 -0
- package/src/core/reason.ts +38 -0
- package/src/core/reconnect.ts +98 -0
- package/src/core/types.ts +138 -0
- package/src/query/client.ts +1132 -0
- package/src/query/errors.ts +108 -0
- package/src/query/index.ts +65 -0
- package/src/query/knownhosts.ts +257 -0
- package/src/query/lines.ts +28 -0
- package/src/query/options.ts +388 -0
- package/src/query/reconnect.ts +84 -0
- package/src/query/ssh.ts +614 -0
- package/src/query/stream.ts +91 -0
- package/src/query/tcp.ts +85 -0
- package/src/query/transport.ts +35 -0
- package/src/query/web.ts +195 -0
- package/src/versions/index.ts +10774 -0
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AlreadyConnectedError,
|
|
3
|
+
ClientStatus,
|
|
4
|
+
ClientType,
|
|
5
|
+
CommandTimeoutError,
|
|
6
|
+
NotConnectedError,
|
|
7
|
+
Reason,
|
|
8
|
+
ServerError,
|
|
9
|
+
TeamspeakError,
|
|
10
|
+
backoffDelay,
|
|
11
|
+
noopLogger,
|
|
12
|
+
type ClientEnterEvent,
|
|
13
|
+
type CommandExecutor,
|
|
14
|
+
type CommandMiddleware,
|
|
15
|
+
type CommandOptions,
|
|
16
|
+
type CommandRequest,
|
|
17
|
+
type KickedEvent,
|
|
18
|
+
type Listener,
|
|
19
|
+
type Logger,
|
|
20
|
+
type ReconnectOptions,
|
|
21
|
+
type ServerEventMap,
|
|
22
|
+
type ServerFamily,
|
|
23
|
+
type ServerID,
|
|
24
|
+
type SessionEventMap,
|
|
25
|
+
type Unsubscribe,
|
|
26
|
+
} from "@honeybbq/teamspeak";
|
|
27
|
+
import {
|
|
28
|
+
buildCommandChain,
|
|
29
|
+
buildCommandOrdered,
|
|
30
|
+
encodeRequest,
|
|
31
|
+
handleNotification,
|
|
32
|
+
parseCommand,
|
|
33
|
+
parseServerError,
|
|
34
|
+
resolveBackoffPolicy,
|
|
35
|
+
} from "../core/internal.js";
|
|
36
|
+
import type { QueryTransport } from "./transport.js";
|
|
37
|
+
import { connectTcp } from "./tcp.js";
|
|
38
|
+
import { connectSsh } from "./ssh.js";
|
|
39
|
+
import { WebQueryTransport } from "./web.js";
|
|
40
|
+
import {
|
|
41
|
+
assertEventsSupported,
|
|
42
|
+
normalizeQueryOptions,
|
|
43
|
+
type QueryClientOptions,
|
|
44
|
+
type QueryEventCategory,
|
|
45
|
+
} from "./options.js";
|
|
46
|
+
import type { ResolvedQueryOptions, ResolvedTransport } from "./options.js";
|
|
47
|
+
import { defaultShouldRetry } from "./reconnect.js";
|
|
48
|
+
|
|
49
|
+
export type {
|
|
50
|
+
QueryClientOptions,
|
|
51
|
+
QueryEventsOption,
|
|
52
|
+
QueryEventCategory,
|
|
53
|
+
QueryTransportOptions,
|
|
54
|
+
} from "./options.js";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The events `QueryClient.on()` takes, and what each delivers.
|
|
58
|
+
*
|
|
59
|
+
* The server's own events are the core's, unchanged: ServerQuery and the voice
|
|
60
|
+
* protocol carry the same notifications with the same fields, so a listener
|
|
61
|
+
* written for one client reads the other. `connected` and `disconnected` come
|
|
62
|
+
* from this client's own lifecycle.
|
|
63
|
+
*
|
|
64
|
+
* A notification reaches this client on two layers at once, as it does the
|
|
65
|
+
* voice client: the typed event for it, if it has one, and then `notification`,
|
|
66
|
+
* which carries every notification with its wire name and unescaped parameters.
|
|
67
|
+
* So a typed event added here is additive — the notification it names stays on
|
|
68
|
+
* the catch-all — and none of these keys is removed in a minor release.
|
|
69
|
+
*
|
|
70
|
+
* @experimental Part of the `@honeybbq/teamspeak/query` entry, which may
|
|
71
|
+
* change in a minor release.
|
|
72
|
+
*/
|
|
73
|
+
export interface QueryEventMap extends ServerEventMap, SessionEventMap {}
|
|
74
|
+
|
|
75
|
+
/** How long a command waits for its answer unless told otherwise. */
|
|
76
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
|
|
77
|
+
|
|
78
|
+
/** How long each reconnection attempt may take. */
|
|
79
|
+
const RECONNECT_ATTEMPT_TIMEOUT_MS = 30_000;
|
|
80
|
+
|
|
81
|
+
/** The commands `connect()` runs on the session's own path, so a middleware never sees them. */
|
|
82
|
+
interface Subscription {
|
|
83
|
+
readonly listener: (payload: unknown) => void;
|
|
84
|
+
removed: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Pending {
|
|
88
|
+
readonly command: string;
|
|
89
|
+
readonly rows: Array<Record<string, string>>;
|
|
90
|
+
resolve(rows: Array<Record<string, string>>): void;
|
|
91
|
+
reject(error: unknown): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A ServerQuery client: runs the typed commands from `@honeybbq/teamspeak` over
|
|
96
|
+
* one of the three ServerQuery transports, and delivers the notifications the
|
|
97
|
+
* server sends as the core's typed events.
|
|
98
|
+
*
|
|
99
|
+
* ServerQuery answers one command at a time, in order: data lines, then an
|
|
100
|
+
* `error` line that ends the answer. So commands are queued and sent one after
|
|
101
|
+
* another, which also keeps the server's flood protection from seeing a burst.
|
|
102
|
+
* Notifications (`notify…`) can arrive at any point and are not part of an
|
|
103
|
+
* answer.
|
|
104
|
+
*
|
|
105
|
+
* The transport is given as a {@link QueryClientOptions.transport} description
|
|
106
|
+
* rather than an open connection, so the client can build it again when it
|
|
107
|
+
* reconnects.
|
|
108
|
+
*
|
|
109
|
+
* @experimental New in 1.x and may change in a minor release: the options, the
|
|
110
|
+
* transport union and the event map are all still settling.
|
|
111
|
+
*/
|
|
112
|
+
export class QueryClient implements CommandExecutor, AsyncDisposable {
|
|
113
|
+
#options: ResolvedQueryOptions;
|
|
114
|
+
#logger: Logger;
|
|
115
|
+
#transport: QueryTransport | null = null;
|
|
116
|
+
#status: ClientStatus = ClientStatus.Disconnected;
|
|
117
|
+
#serverFamily: ServerFamily = "unknown";
|
|
118
|
+
#version = "";
|
|
119
|
+
|
|
120
|
+
#pending: Pending | null = null;
|
|
121
|
+
/**
|
|
122
|
+
* Answers still to come for commands that gave up waiting. ServerQuery has
|
|
123
|
+
* no return_code, only order, so each is read and discarded when it arrives
|
|
124
|
+
* rather than taken for the next command's.
|
|
125
|
+
*/
|
|
126
|
+
#abandoned = 0;
|
|
127
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
128
|
+
#closed = true;
|
|
129
|
+
/**
|
|
130
|
+
* Bumped by `disconnect()`. A connection attempt that was in flight compares
|
|
131
|
+
* it after every await, so one that was given up on cannot install itself as
|
|
132
|
+
* the live connection.
|
|
133
|
+
*/
|
|
134
|
+
#generation = 0;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* This connection's own client id, learned from the client list after
|
|
138
|
+
* registering, so that a kick of this client is told from a move of another.
|
|
139
|
+
* 0 until it is known — which is the case when no `nickname` option was
|
|
140
|
+
* given: the server names an unnamed query client itself, and matching that
|
|
141
|
+
* guess against the list could name a different client.
|
|
142
|
+
*/
|
|
143
|
+
#clid = 0;
|
|
144
|
+
#clients = new Map<number, ClientEnterEvent>();
|
|
145
|
+
|
|
146
|
+
#reconnectOptions: ReconnectOptions | undefined;
|
|
147
|
+
#reconnecting = false;
|
|
148
|
+
#loopAttempt = 0;
|
|
149
|
+
#stopped = false;
|
|
150
|
+
#stopping: Promise<void> | undefined;
|
|
151
|
+
#stopReconnecting: AbortController | undefined;
|
|
152
|
+
/** The last answer of `shouldRetry`, so that one failure asks it once. */
|
|
153
|
+
#retryAnswer:
|
|
154
|
+
| {
|
|
155
|
+
readonly err: Error;
|
|
156
|
+
readonly attempt: number;
|
|
157
|
+
readonly retry: boolean;
|
|
158
|
+
readonly threw: boolean;
|
|
159
|
+
}
|
|
160
|
+
| undefined;
|
|
161
|
+
/**
|
|
162
|
+
* Why the last established session dropped: a reconnect loop whose own
|
|
163
|
+
* attempt connected and then dropped before the loop resumed retries for it.
|
|
164
|
+
*/
|
|
165
|
+
#lastDrop: Error | undefined;
|
|
166
|
+
/**
|
|
167
|
+
* Settles once the last `disconnected` has been delivered. A `connect()`
|
|
168
|
+
* waits for it, so the end of one session is never heard during the next.
|
|
169
|
+
*/
|
|
170
|
+
#disconnectedDelivered: Promise<void> | undefined;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether the transport delivers notifications, as it reports.
|
|
174
|
+
*
|
|
175
|
+
* For a `tcp`, `ssh` or `web` transport this is the kind's own truth, known
|
|
176
|
+
* when the client is made. A `custom` transport can only say once it has
|
|
177
|
+
* been opened, so until then this is `undefined` and a listener is accepted
|
|
178
|
+
* rather than guessed at; the first connection settles it.
|
|
179
|
+
*/
|
|
180
|
+
#notifications: boolean | undefined;
|
|
181
|
+
|
|
182
|
+
#listeners = new Map<keyof QueryEventMap, Subscription[]>();
|
|
183
|
+
#send: CommandExecutor["send"];
|
|
184
|
+
|
|
185
|
+
constructor(options: QueryClientOptions) {
|
|
186
|
+
this.#options = normalizeQueryOptions(options);
|
|
187
|
+
this.#logger = this.#options.logger ?? noopLogger;
|
|
188
|
+
this.#notifications = this.#options.notificationsSupported;
|
|
189
|
+
this.#reconnectOptions = this.#options.reconnect;
|
|
190
|
+
const middlewares: readonly CommandMiddleware[] = this.#options.commandMiddleware ?? [];
|
|
191
|
+
// Built once: the chain is read on every command, and changing it while
|
|
192
|
+
// those run would make the behaviour depend on timing.
|
|
193
|
+
this.#send = buildCommandChain(middlewares, (request, commandOptions = {}) =>
|
|
194
|
+
this.exec(
|
|
195
|
+
encodeRequest(request, (password) => password),
|
|
196
|
+
commandOptions,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Where the client is in its connection's life. */
|
|
202
|
+
get status(): ClientStatus {
|
|
203
|
+
return this.#status;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Which server generation the server is, read from its `version` answer.
|
|
208
|
+
* `"unknown"` before connecting.
|
|
209
|
+
*
|
|
210
|
+
* @experimental Part of the entry, which may change in a minor release.
|
|
211
|
+
*/
|
|
212
|
+
get serverFamily(): ServerFamily {
|
|
213
|
+
return this.#serverFamily;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The `version` string the server answered with; "" before connecting. */
|
|
217
|
+
get serverVersion(): string {
|
|
218
|
+
return this.#version;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The transport carrying this connection, or `undefined` when there is none.
|
|
223
|
+
*
|
|
224
|
+
* Read-only, and the transport is the interface the client itself talks
|
|
225
|
+
* over — counts of what went out and came in, and which connection it is,
|
|
226
|
+
* can be read from it for metrics. The socket underneath is not exposed:
|
|
227
|
+
* a caller that needs it supplies the seam instead (the `connect` option of
|
|
228
|
+
* a `tcp` transport, or `connection` of an `ssh` one), where it is theirs to
|
|
229
|
+
* keep.
|
|
230
|
+
*
|
|
231
|
+
* @experimental Part of the entry, which may change in a minor release.
|
|
232
|
+
*/
|
|
233
|
+
get transport(): QueryTransport | undefined {
|
|
234
|
+
return this.#transport ?? undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The notification categories this connection registered for: what `events`
|
|
239
|
+
* asked for, and empty when the transport delivers no notifications or the
|
|
240
|
+
* client is not connected.
|
|
241
|
+
*/
|
|
242
|
+
get eventCategories(): readonly QueryEventCategory[] {
|
|
243
|
+
return this.status === ClientStatus.Connected && this.#notifications === true
|
|
244
|
+
? this.#options.eventCategories
|
|
245
|
+
: [];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- Connection -----------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Connect and set the session up: open the transport, then `login` (raw TCP
|
|
252
|
+
* only), `use`, `version`, the notification registrations, and one client
|
|
253
|
+
* list.
|
|
254
|
+
*
|
|
255
|
+
* The `login` and `server` options are applied here rather than left to the
|
|
256
|
+
* caller so that a reconnection is the same session as the first connection
|
|
257
|
+
* was.
|
|
258
|
+
*
|
|
259
|
+
* A failure rejects with its cause and leaves the client disconnected; the
|
|
260
|
+
* transport it opened is closed. `signal` bounds only the connecting: once
|
|
261
|
+
* this resolves, the connection lasts until `disconnect()` or a drop.
|
|
262
|
+
* `connect()` does not retry itself; the `reconnect` option retries a
|
|
263
|
+
* connection that dropped after it was established.
|
|
264
|
+
*
|
|
265
|
+
* `connected` is delivered before this resolves, so a listener added after
|
|
266
|
+
* `await client.connect()` has missed it — add the listeners first.
|
|
267
|
+
*/
|
|
268
|
+
async connect(options: { readonly signal?: AbortSignal | undefined } = {}): Promise<void> {
|
|
269
|
+
return this.#connect(options, false);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async #connect(
|
|
273
|
+
options: { readonly signal?: AbortSignal | undefined },
|
|
274
|
+
reconnect: boolean,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const { signal } = options;
|
|
277
|
+
signal?.throwIfAborted();
|
|
278
|
+
// A disconnect() still tearing down waits to be done: a session built over
|
|
279
|
+
// one that is still closing would have its transport closed under it.
|
|
280
|
+
if (this.#stopping !== undefined) await this.#stopping;
|
|
281
|
+
// And so does an earlier session's `disconnected`, so that no listener or
|
|
282
|
+
// events() iterator of this session hears the last one end.
|
|
283
|
+
while (this.#disconnectedDelivered !== undefined) {
|
|
284
|
+
await this.#disconnectedDelivered;
|
|
285
|
+
signal?.throwIfAborted();
|
|
286
|
+
}
|
|
287
|
+
if (this.#status !== ClientStatus.Disconnected) throw new AlreadyConnectedError();
|
|
288
|
+
|
|
289
|
+
this.#stopped = false;
|
|
290
|
+
this.#generation++;
|
|
291
|
+
const generation = this.#generation;
|
|
292
|
+
this.#status = ClientStatus.Connecting;
|
|
293
|
+
try {
|
|
294
|
+
await this.#establish(signal, generation);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.#failConnection(error);
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
if (generation !== this.#generation) throw new NotConnectedError();
|
|
300
|
+
this.#status = ClientStatus.Connected;
|
|
301
|
+
this.#emit("connected", { reconnected: reconnect });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* One connection attempt, from opening the transport to having a usable
|
|
306
|
+
* session. `connect()` and the reconnect loop share it, so a reconnection is
|
|
307
|
+
* the same session the first connection was.
|
|
308
|
+
*/
|
|
309
|
+
async #establish(signal: AbortSignal | undefined, generation: number): Promise<void> {
|
|
310
|
+
const transport = await this.#openTransport(signal);
|
|
311
|
+
// Given up on while the transport was being opened: it is closed rather
|
|
312
|
+
// than installed, and the caller hears that nothing is connected.
|
|
313
|
+
if (generation !== this.#generation || this.#status !== ClientStatus.Connecting) {
|
|
314
|
+
await transport.close().catch(() => {});
|
|
315
|
+
throw new NotConnectedError();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A custom transport only says here whether it can deliver notifications,
|
|
319
|
+
// so a request to register for them is checked here rather than only at
|
|
320
|
+
// construction. The transport is closed first: the option is the caller's
|
|
321
|
+
// mistake, and leaving a connection open behind it helps nobody.
|
|
322
|
+
this.#notifications = transport.supportsNotifications;
|
|
323
|
+
try {
|
|
324
|
+
assertEventsSupported({ ...this.#options, notificationsSupported: this.#notifications });
|
|
325
|
+
} catch (error) {
|
|
326
|
+
await transport.close().catch(() => {});
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
this.#transport = transport;
|
|
331
|
+
this.#closed = false;
|
|
332
|
+
this.#abandoned = 0;
|
|
333
|
+
this.#pending = null;
|
|
334
|
+
this.#clients.clear();
|
|
335
|
+
this.#clid = 0;
|
|
336
|
+
this.#serverFamily = "unknown";
|
|
337
|
+
this.#version = "";
|
|
338
|
+
transport.onLine = (line) => this.#handleLine(line);
|
|
339
|
+
transport.onClose = (error) => this.#handleClose(error);
|
|
340
|
+
|
|
341
|
+
const { login, server, nickname, eventCategories } = this.#options;
|
|
342
|
+
if (login !== undefined) await this.login(login.name, login.password, { signal });
|
|
343
|
+
if (server !== undefined) await this.use(server, { nickname, signal });
|
|
344
|
+
this.#version = await this.#getVersion(signal);
|
|
345
|
+
this.#serverFamily = serverFamilyOf(this.#version);
|
|
346
|
+
if (this.#notifications) {
|
|
347
|
+
for (const category of eventCategories) {
|
|
348
|
+
// The channel category takes the channel to watch, or 0 for all of them;
|
|
349
|
+
// the others take none, and the server refuses one they do not know.
|
|
350
|
+
await this.registerNotifications(category, category === "channel" ? 0 : undefined, {
|
|
351
|
+
signal,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
// Only worth listing when there is something to fill: the groups and the
|
|
355
|
+
// invoker of a message this client would otherwise not know.
|
|
356
|
+
if (eventCategories.length > 0) await this.#learnClients(signal);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Leave the server and close the connection.
|
|
362
|
+
*
|
|
363
|
+
* A `connect()` still in flight fails with a NotConnectedError, and so do
|
|
364
|
+
* the commands still waiting for an answer. Resolves once `disconnected` has
|
|
365
|
+
* been delivered, so a listener added after `await client.disconnect()` does
|
|
366
|
+
* not hear it. Does nothing on a client that is not connected; called again
|
|
367
|
+
* while an earlier call is still running, it waits for that one. An explicit
|
|
368
|
+
* `disconnect()` never reconnects.
|
|
369
|
+
*/
|
|
370
|
+
async disconnect(): Promise<void> {
|
|
371
|
+
// Recorded before anything else: a transport that closes during the
|
|
372
|
+
// teardown below must not be mistaken for an outage worth retrying.
|
|
373
|
+
this.#stopped = true;
|
|
374
|
+
this.#stopReconnecting?.abort();
|
|
375
|
+
this.#generation++;
|
|
376
|
+
|
|
377
|
+
if (this.#stopping !== undefined) return this.#stopping;
|
|
378
|
+
if (this.#status === ClientStatus.Disconnected) return this.#disconnectedDelivered;
|
|
379
|
+
|
|
380
|
+
this.#status = ClientStatus.Disconnected;
|
|
381
|
+
const stopping = this.#teardown();
|
|
382
|
+
this.#stopping = stopping;
|
|
383
|
+
try {
|
|
384
|
+
await stopping;
|
|
385
|
+
} finally {
|
|
386
|
+
this.#stopping = undefined;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async #teardown(): Promise<void> {
|
|
391
|
+
this.#logger.info("disconnecting from query server");
|
|
392
|
+
const transport = this.#transport;
|
|
393
|
+
this.#transport = null;
|
|
394
|
+
// Set before the transport is closed, so the close it reports is not read
|
|
395
|
+
// as the connection dropping.
|
|
396
|
+
this.#closed = true;
|
|
397
|
+
this.#fail(new NotConnectedError());
|
|
398
|
+
await transport?.close().catch(() => {});
|
|
399
|
+
// Announced here, with no cause: `disconnected` delivering `undefined` is
|
|
400
|
+
// what says the client left on purpose.
|
|
401
|
+
await this.#announceDisconnected(undefined, false);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** `await using client = …` disconnects when the scope ends. */
|
|
405
|
+
[Symbol.asyncDispose](): Promise<void> {
|
|
406
|
+
return this.disconnect();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---- Commands ------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Send a typed command. Passwords go as they are: ServerQuery takes the
|
|
413
|
+
* password itself and hashes it server-side, where the voice protocol wants
|
|
414
|
+
* the hash.
|
|
415
|
+
*/
|
|
416
|
+
send(
|
|
417
|
+
request: CommandRequest,
|
|
418
|
+
options: CommandOptions = {},
|
|
419
|
+
): Promise<Array<Record<string, string>>> {
|
|
420
|
+
return this.#send(request, options);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Send a raw command line and return the rows that answer it. */
|
|
424
|
+
exec(line: string, options: CommandOptions = {}): Promise<Array<Record<string, string>>> {
|
|
425
|
+
const run = this.#queue.then(() => this.#execNow(line, options));
|
|
426
|
+
// The queue moves on whatever this command's outcome.
|
|
427
|
+
this.#queue = run.catch(() => {});
|
|
428
|
+
return run;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Log in with a query account. Over WebQuery the api key does this, and the
|
|
433
|
+
* server refuses the command; over SSH the session is logged in already.
|
|
434
|
+
*
|
|
435
|
+
* Public because a caller driving the client by hand needs it: `connect()`
|
|
436
|
+
* runs it from the `login` option, and this is the same `login` command.
|
|
437
|
+
*/
|
|
438
|
+
async login(name: string, password: string, options: CommandOptions = {}): Promise<void> {
|
|
439
|
+
await this.exec(
|
|
440
|
+
buildCommandOrdered("login", [
|
|
441
|
+
["client_login_name", name],
|
|
442
|
+
["client_login_password", password],
|
|
443
|
+
]),
|
|
444
|
+
options,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Select the virtual server the following commands address, by id or by
|
|
450
|
+
* voice port. `nickname` names the query client on it.
|
|
451
|
+
*
|
|
452
|
+
* Public for the same reason as {@link QueryClient.login}: `connect()` runs
|
|
453
|
+
* it from the `server` option, and this is the same `use` command. Over
|
|
454
|
+
* WebQuery the transport answers it, by addressing that server from then on.
|
|
455
|
+
*/
|
|
456
|
+
async use(
|
|
457
|
+
server: { readonly id: ServerID | number } | { readonly port: number },
|
|
458
|
+
options: CommandOptions & { readonly nickname?: string | undefined } = {},
|
|
459
|
+
): Promise<void> {
|
|
460
|
+
await this.exec(
|
|
461
|
+
buildCommandOrdered("use", [
|
|
462
|
+
"id" in server ? ["sid", String(server.id)] : ["port", String(server.port)],
|
|
463
|
+
...(options.nickname === undefined
|
|
464
|
+
? []
|
|
465
|
+
: ([["client_nickname", options.nickname]] as const)),
|
|
466
|
+
]),
|
|
467
|
+
options,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Ask the server for the notifications of one category: `server`, `channel`
|
|
473
|
+
* (for one channel, or `0` for all), `textserver`, `textchannel`,
|
|
474
|
+
* `textprivate`, and on TeamSpeak 6 `bans`.
|
|
475
|
+
*
|
|
476
|
+
* Public for the same reason as {@link QueryClient.login}: `connect()` runs
|
|
477
|
+
* it once per configured category. With the WebQuery transport there is
|
|
478
|
+
* nothing to register for and the server refuses it, so the client never
|
|
479
|
+
* calls it there, and neither should a caller.
|
|
480
|
+
*/
|
|
481
|
+
async registerNotifications(
|
|
482
|
+
event: QueryEventCategory,
|
|
483
|
+
channelID?: bigint | number,
|
|
484
|
+
options: CommandOptions = {},
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
await this.exec(
|
|
487
|
+
buildCommandOrdered("servernotifyregister", [
|
|
488
|
+
["event", event],
|
|
489
|
+
...(channelID === undefined ? [] : ([["id", String(channelID)]] as const)),
|
|
490
|
+
]),
|
|
491
|
+
options,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---- Events --------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Listen for an event. Returns a function that removes the listener, which is
|
|
499
|
+
* also a `Disposable` (`using stop = client.on(...)`).
|
|
500
|
+
*
|
|
501
|
+
* ```ts
|
|
502
|
+
* client.on("textMessage", (message) => console.log(message.message));
|
|
503
|
+
* ```
|
|
504
|
+
*
|
|
505
|
+
* A listener that throws does not stop the others: the rest of the event's
|
|
506
|
+
* listeners, and the events after it, are still delivered, and the exception
|
|
507
|
+
* is rethrown on its own, from a microtask, so it surfaces as an
|
|
508
|
+
* `uncaughtException` just as an EventEmitter listener's would.
|
|
509
|
+
*
|
|
510
|
+
* With the WebQuery transport no server event can ever arrive, so this throws
|
|
511
|
+
* a `TypeError` for one rather than accepting a listener that would never
|
|
512
|
+
* run. `connected` and `disconnected` are still accepted: they come from the
|
|
513
|
+
* client, not the server.
|
|
514
|
+
*/
|
|
515
|
+
on<K extends keyof QueryEventMap>(event: K, listener: Listener<QueryEventMap, K>): Unsubscribe {
|
|
516
|
+
if (this.#notifications === false && SERVER_EVENTS.has(event)) {
|
|
517
|
+
throw new TypeError(
|
|
518
|
+
`this transport delivers no notifications, so "${event}" cannot be listened for`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
const subscription: Subscription = {
|
|
522
|
+
listener: listener as (payload: unknown) => void,
|
|
523
|
+
removed: false,
|
|
524
|
+
};
|
|
525
|
+
// A new array rather than an edit in place: a delivery may be iterating
|
|
526
|
+
// over the old one.
|
|
527
|
+
this.#listeners.set(event, [...(this.#listeners.get(event) ?? []), subscription]);
|
|
528
|
+
|
|
529
|
+
const unsubscribe = (): void => {
|
|
530
|
+
if (subscription.removed) return;
|
|
531
|
+
subscription.removed = true;
|
|
532
|
+
const list = this.#listeners.get(event) ?? [];
|
|
533
|
+
this.#listeners.set(
|
|
534
|
+
event,
|
|
535
|
+
list.filter((s) => s !== subscription),
|
|
536
|
+
);
|
|
537
|
+
};
|
|
538
|
+
return Object.assign(unsubscribe, { [Symbol.dispose]: unsubscribe });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Listen for the next occurrence of an event only. */
|
|
542
|
+
once<K extends keyof QueryEventMap>(event: K, listener: Listener<QueryEventMap, K>): Unsubscribe {
|
|
543
|
+
const unsubscribe = this.on(event, ((payload: QueryEventMap[K]) => {
|
|
544
|
+
unsubscribe();
|
|
545
|
+
listener(payload);
|
|
546
|
+
}) as Listener<QueryEventMap, K>);
|
|
547
|
+
return unsubscribe;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* An event as an async iterator, for `for await`:
|
|
552
|
+
*
|
|
553
|
+
* ```ts
|
|
554
|
+
* for await (const message of client.events("textMessage")) { … }
|
|
555
|
+
* ```
|
|
556
|
+
*
|
|
557
|
+
* Events that arrive while the loop body runs are buffered, so none is
|
|
558
|
+
* missed. The buffer is unbounded: an iterator that is not consumed as fast
|
|
559
|
+
* as events arrive holds every one of them, so stop iterating (or abort
|
|
560
|
+
* `signal`) when you stop reading.
|
|
561
|
+
*
|
|
562
|
+
* Iteration ends when the loop exits early (`break`, `return`, a throw), and
|
|
563
|
+
* — for every event but `disconnected` — when the client disconnects. When
|
|
564
|
+
* `signal` aborts, listening stops at once, whatever is buffered is
|
|
565
|
+
* discarded, and the pending and every later `next()` reject with the
|
|
566
|
+
* signal's reason.
|
|
567
|
+
*/
|
|
568
|
+
events<K extends keyof QueryEventMap>(
|
|
569
|
+
event: K,
|
|
570
|
+
options: { readonly signal?: AbortSignal | undefined } = {},
|
|
571
|
+
): AsyncIterableIterator<QueryEventMap[K]> {
|
|
572
|
+
const { signal } = options;
|
|
573
|
+
const buffered: Array<QueryEventMap[K]> = [];
|
|
574
|
+
// The next() calls waiting for an event, oldest first.
|
|
575
|
+
const waiting: Array<{
|
|
576
|
+
resolve: (result: IteratorResult<QueryEventMap[K]>) => void;
|
|
577
|
+
reject: (reason: unknown) => void;
|
|
578
|
+
}> = [];
|
|
579
|
+
let ended = false;
|
|
580
|
+
let aborted = false;
|
|
581
|
+
|
|
582
|
+
const finishWaiting = (): void => {
|
|
583
|
+
for (const waiter of waiting.splice(0)) {
|
|
584
|
+
waiter.resolve({ value: undefined, done: true });
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const stopListening = this.on(event, ((payload: QueryEventMap[K]) => {
|
|
589
|
+
if (ended) return;
|
|
590
|
+
const waiter = waiting.shift();
|
|
591
|
+
if (waiter) {
|
|
592
|
+
waiter.resolve({ value: payload, done: false });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
buffered.push(payload);
|
|
596
|
+
}) as Listener<QueryEventMap, K>);
|
|
597
|
+
const stopOnDisconnect =
|
|
598
|
+
event === "disconnected"
|
|
599
|
+
? undefined
|
|
600
|
+
: this.on("disconnected", () => {
|
|
601
|
+
cleanup();
|
|
602
|
+
finishWaiting();
|
|
603
|
+
});
|
|
604
|
+
const cleanup = (): void => {
|
|
605
|
+
ended = true;
|
|
606
|
+
stopListening();
|
|
607
|
+
stopOnDisconnect?.();
|
|
608
|
+
signal?.removeEventListener("abort", abort);
|
|
609
|
+
};
|
|
610
|
+
const abort = (): void => {
|
|
611
|
+
aborted = true;
|
|
612
|
+
buffered.length = 0;
|
|
613
|
+
cleanup();
|
|
614
|
+
const reason = signal!.reason;
|
|
615
|
+
for (const waiter of waiting.splice(0)) waiter.reject(reason);
|
|
616
|
+
};
|
|
617
|
+
if (signal?.aborted) abort();
|
|
618
|
+
else signal?.addEventListener("abort", abort, { once: true });
|
|
619
|
+
|
|
620
|
+
const iterator: AsyncIterableIterator<QueryEventMap[K]> = {
|
|
621
|
+
next(): Promise<IteratorResult<QueryEventMap[K]>> {
|
|
622
|
+
if (aborted) return Promise.reject(signal!.reason);
|
|
623
|
+
if (buffered.length > 0) return Promise.resolve({ value: buffered.shift()!, done: false });
|
|
624
|
+
if (ended) {
|
|
625
|
+
cleanup();
|
|
626
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
627
|
+
}
|
|
628
|
+
return new Promise((resolve, reject) => waiting.push({ resolve, reject }));
|
|
629
|
+
},
|
|
630
|
+
return(): Promise<IteratorResult<QueryEventMap[K]>> {
|
|
631
|
+
buffered.length = 0;
|
|
632
|
+
cleanup();
|
|
633
|
+
finishWaiting();
|
|
634
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
635
|
+
},
|
|
636
|
+
[Symbol.asyncIterator]() {
|
|
637
|
+
return iterator;
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
return iterator;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ---- Private -------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
/** Build and open the transport the options describe. */
|
|
646
|
+
async #openTransport(signal: AbortSignal | undefined): Promise<QueryTransport> {
|
|
647
|
+
const transport: ResolvedTransport = this.#options.transport;
|
|
648
|
+
switch (transport.kind) {
|
|
649
|
+
case "tcp":
|
|
650
|
+
return connectTcp({
|
|
651
|
+
host: transport.host,
|
|
652
|
+
port: transport.port,
|
|
653
|
+
connect: transport.connect,
|
|
654
|
+
signal,
|
|
655
|
+
});
|
|
656
|
+
case "ssh":
|
|
657
|
+
return connectSsh({ ...transport.connect, signal });
|
|
658
|
+
case "web":
|
|
659
|
+
return new WebQueryTransport({
|
|
660
|
+
url: transport.url,
|
|
661
|
+
apiKey: transport.apiKey,
|
|
662
|
+
fetch: transport.fetch,
|
|
663
|
+
serverID: serverIDOf(this.#options.server),
|
|
664
|
+
});
|
|
665
|
+
case "custom":
|
|
666
|
+
return transport.open(signal);
|
|
667
|
+
default:
|
|
668
|
+
// Every kind is handled above; a value from outside this module's
|
|
669
|
+
// types would otherwise open nothing and hang the attempt.
|
|
670
|
+
throw new TypeError(
|
|
671
|
+
`unknown transport kind ${String((transport as { kind: unknown }).kind)}`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** The server's `version` string, and nothing else of the answer. */
|
|
677
|
+
async #getVersion(signal: AbortSignal | undefined): Promise<string> {
|
|
678
|
+
const rows = await this.exec("version", { signal });
|
|
679
|
+
return rows[0]?.["version"] ?? "";
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* List the clients once, so that `invokerGroups` and the invoker fields of a
|
|
684
|
+
* self-move can be filled: an enter notification is only seen for clients
|
|
685
|
+
* that arrive after registering, and the ones already there arrive as none.
|
|
686
|
+
*/
|
|
687
|
+
async #learnClients(signal: AbortSignal | undefined): Promise<void> {
|
|
688
|
+
const rows = await this.exec("clientlist -uid -groups", { signal }).catch((error: unknown) => {
|
|
689
|
+
// A registration that worked and a list that did not leaves the client
|
|
690
|
+
// usable: a sender it has never seen simply has no known groups, which
|
|
691
|
+
// the event type already allows for.
|
|
692
|
+
this.#logger.warn("listing clients after registering failed", { error: String(error) });
|
|
693
|
+
return [] as Array<Record<string, string>>;
|
|
694
|
+
});
|
|
695
|
+
this.#clid = 0;
|
|
696
|
+
for (const row of rows) {
|
|
697
|
+
const result = handleNotification(
|
|
698
|
+
{ name: "notifycliententerview", params: row },
|
|
699
|
+
0,
|
|
700
|
+
this.#clients,
|
|
701
|
+
);
|
|
702
|
+
if (result.kind !== "clientEnter") continue;
|
|
703
|
+
// Ours is the one query client with our nickname. Without a nickname
|
|
704
|
+
// option there is no name to match, and the id stays unknown.
|
|
705
|
+
if (
|
|
706
|
+
this.#clid === 0 &&
|
|
707
|
+
result.info.type === ClientType.Query &&
|
|
708
|
+
this.#options.nickname !== undefined &&
|
|
709
|
+
result.info.nickname === this.#options.nickname
|
|
710
|
+
) {
|
|
711
|
+
this.#clid = result.info.id;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async #execNow(line: string, options: CommandOptions): Promise<Array<Record<string, string>>> {
|
|
717
|
+
const { signal, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = options;
|
|
718
|
+
signal?.throwIfAborted();
|
|
719
|
+
if (this.#closed || this.#transport === null) throw new NotConnectedError();
|
|
720
|
+
|
|
721
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
722
|
+
let onAbort: (() => void) | undefined;
|
|
723
|
+
const answered = new Promise<Array<Record<string, string>>>((resolve, reject) => {
|
|
724
|
+
this.#pending = { command: parseCommand(line)?.name ?? "", rows: [], resolve, reject };
|
|
725
|
+
timer = setTimeout(
|
|
726
|
+
() => reject(new CommandTimeoutError(parseCommand(line)?.name ?? "")),
|
|
727
|
+
timeoutMs,
|
|
728
|
+
);
|
|
729
|
+
onAbort = () => reject(signal!.reason);
|
|
730
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
731
|
+
});
|
|
732
|
+
// The answer may settle while the transport is still sending, as it does
|
|
733
|
+
// when a send carries the whole round trip (WebQuery); a failure of the
|
|
734
|
+
// send first would otherwise leave this rejection unhandled.
|
|
735
|
+
answered.catch(() => {});
|
|
736
|
+
const sending = { failed: false };
|
|
737
|
+
let abandonedByUs = false;
|
|
738
|
+
const send = this.#transport.send(line).then(
|
|
739
|
+
() => {},
|
|
740
|
+
(error: unknown) => {
|
|
741
|
+
// A send that fails after the command gave up will not be answered.
|
|
742
|
+
if (abandonedByUs) this.#abandoned--;
|
|
743
|
+
sending.failed = true;
|
|
744
|
+
throw error;
|
|
745
|
+
},
|
|
746
|
+
);
|
|
747
|
+
try {
|
|
748
|
+
await Promise.race([send, answered]);
|
|
749
|
+
return await answered;
|
|
750
|
+
} finally {
|
|
751
|
+
clearTimeout(timer);
|
|
752
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
753
|
+
if (this.#pending !== null) {
|
|
754
|
+
this.#pending = null;
|
|
755
|
+
if (!sending.failed) {
|
|
756
|
+
this.#abandoned++;
|
|
757
|
+
abandonedByUs = true;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
#handleLine(line: string): void {
|
|
764
|
+
const trimmed = line.replace(/\r$/, "");
|
|
765
|
+
if (!trimmed) return;
|
|
766
|
+
const cmd = parseCommand(trimmed);
|
|
767
|
+
if (!cmd) return;
|
|
768
|
+
|
|
769
|
+
if (cmd.name === "error") {
|
|
770
|
+
if (this.#abandoned > 0) {
|
|
771
|
+
this.#abandoned--;
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const pending = this.#pending;
|
|
775
|
+
this.#pending = null;
|
|
776
|
+
if (!pending) return;
|
|
777
|
+
const { err } = parseServerError(cmd.params);
|
|
778
|
+
const refusal = err ?? transferRefusal(pending.command, pending.rows);
|
|
779
|
+
if (refusal) pending.reject(refusal);
|
|
780
|
+
else pending.resolve(pending.rows);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (cmd.name.startsWith("notify")) {
|
|
784
|
+
// A notification is never part of an answer.
|
|
785
|
+
for (const row of splitRows(trimmed)) {
|
|
786
|
+
const parsed = parseCommand(row);
|
|
787
|
+
if (parsed) this.#notification(parsed.name, parsed.params);
|
|
788
|
+
}
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (this.#abandoned > 0) return;
|
|
792
|
+
for (const row of splitRows(trimmed)) {
|
|
793
|
+
const parsed = parseCommand(row);
|
|
794
|
+
if (parsed) this.#pending?.rows.push(parsed.params);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Turn one notification into the core's events, then report it as it arrived. */
|
|
799
|
+
#notification(name: string, params: Record<string, string>): void {
|
|
800
|
+
const result = handleNotification({ name, params }, this.#clid, this.#clients);
|
|
801
|
+
switch (result.kind) {
|
|
802
|
+
case "clientEnter":
|
|
803
|
+
this.#emit("clientEnter", result.info);
|
|
804
|
+
break;
|
|
805
|
+
case "clientLeave":
|
|
806
|
+
this.#emit("clientLeave", result.event);
|
|
807
|
+
// A query client is never kicked from the server, but a ban or a
|
|
808
|
+
// server stopping reaches it as a leave of its own: the same two
|
|
809
|
+
// reasons the voice client reads.
|
|
810
|
+
if (
|
|
811
|
+
result.isSelf &&
|
|
812
|
+
(result.event.reason === Reason.KickServer ||
|
|
813
|
+
result.event.reason === Reason.KickServerBan)
|
|
814
|
+
) {
|
|
815
|
+
this.#emit("kicked", kickedEvent(result.event));
|
|
816
|
+
}
|
|
817
|
+
break;
|
|
818
|
+
case "clientMoved":
|
|
819
|
+
this.#emit("clientMoved", result.event);
|
|
820
|
+
// A channel kick moves the client to the default channel, so it
|
|
821
|
+
// arrives as a move of its own, not as leaving view.
|
|
822
|
+
if (
|
|
823
|
+
this.#clid !== 0 &&
|
|
824
|
+
result.event.clientID === this.#clid &&
|
|
825
|
+
result.event.reason === Reason.KickChannel
|
|
826
|
+
) {
|
|
827
|
+
this.#emit("kicked", kickedEvent(result.event));
|
|
828
|
+
}
|
|
829
|
+
break;
|
|
830
|
+
case "textMessage":
|
|
831
|
+
this.#emit("textMessage", result.message);
|
|
832
|
+
break;
|
|
833
|
+
case "poked":
|
|
834
|
+
case "unknown":
|
|
835
|
+
// A query client is never poked, and anything else is not an event the
|
|
836
|
+
// core models; `notification` still carries it.
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
// After any event it produced, as the voice client sends it. A listener's
|
|
840
|
+
// failure is its own: it must not stop the answer being read.
|
|
841
|
+
if (this.#listeners.get("notification")?.length) {
|
|
842
|
+
this.#emit("notification", { name, params: { ...params } });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#emit<K extends keyof QueryEventMap>(event: K, payload: QueryEventMap[K]): void {
|
|
847
|
+
for (const subscription of this.#listeners.get(event) ?? []) {
|
|
848
|
+
// Checked at delivery: a listener removed while its event was queued
|
|
849
|
+
// does not get it.
|
|
850
|
+
if (subscription.removed) continue;
|
|
851
|
+
try {
|
|
852
|
+
subscription.listener(payload);
|
|
853
|
+
} catch (error) {
|
|
854
|
+
// Rethrown on its own, so it surfaces as an uncaught exception rather
|
|
855
|
+
// than breaking the listeners after it.
|
|
856
|
+
queueMicrotask(() => {
|
|
857
|
+
throw error;
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
#fail(error: unknown): void {
|
|
864
|
+
const pending = this.#pending;
|
|
865
|
+
this.#pending = null;
|
|
866
|
+
pending?.reject(error);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
#handleClose(error: Error | undefined): void {
|
|
870
|
+
if (this.#status === ClientStatus.Disconnected) return;
|
|
871
|
+
this.#transport = null;
|
|
872
|
+
this.#closed = true;
|
|
873
|
+
this.#fail(new NotConnectedError(error ? { cause: error } : undefined));
|
|
874
|
+
const wasConnected = this.#status === ClientStatus.Connected;
|
|
875
|
+
this.#status = ClientStatus.Disconnected;
|
|
876
|
+
if (!wasConnected) {
|
|
877
|
+
// The transport closed while the session was being set up, so this is
|
|
878
|
+
// the attempt failing rather than a live connection dropping. The
|
|
879
|
+
// attempt's own command rejects, and `connect()` rejects with it.
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
this.#lastDrop = error ?? new TeamspeakError("connection closed");
|
|
883
|
+
// Announced first, then retried: a caller watching `disconnected` should
|
|
884
|
+
// hear about the drop even when a reconnect is already on its way, because
|
|
885
|
+
// a silent recovery hides an outage.
|
|
886
|
+
void this.#announceDisconnected(
|
|
887
|
+
this.#lastDrop,
|
|
888
|
+
this.#willReconnect(this.#lastDrop, this.#loopAttempt + 1),
|
|
889
|
+
);
|
|
890
|
+
this.#scheduleReconnect(this.#lastDrop).catch((failure: unknown) => {
|
|
891
|
+
this.#logger.error("reconnecting failed", { error: String(failure) });
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* A connection attempt has failed for good. The transport it opened is
|
|
897
|
+
* closed, so a later `connect()` starts from nothing.
|
|
898
|
+
*
|
|
899
|
+
* A `connect()` the caller made is theirs to see rejected, and is not
|
|
900
|
+
* announced as a disconnection. An attempt of the reconnect loop is: the
|
|
901
|
+
* caller hears the loop's own answer, so that a loop which has given up says
|
|
902
|
+
* so instead of only ever having reported the drop that started it.
|
|
903
|
+
*/
|
|
904
|
+
#failConnection(error: unknown): void {
|
|
905
|
+
if (this.#status === ClientStatus.Connected) return;
|
|
906
|
+
this.#status = ClientStatus.Disconnected;
|
|
907
|
+
this.#closed = true;
|
|
908
|
+
// The cause is reported as it is, not wrapped: a misuse of the options,
|
|
909
|
+
// such as asking for events a transport cannot deliver, must stay the
|
|
910
|
+
// TypeError it is.
|
|
911
|
+
this.#fail(error);
|
|
912
|
+
const transport = this.#transport;
|
|
913
|
+
this.#transport = null;
|
|
914
|
+
void transport?.close().catch(() => {});
|
|
915
|
+
if (!this.#reconnecting) return;
|
|
916
|
+
const cause =
|
|
917
|
+
error instanceof Error ? error : new TeamspeakError(String(error), { cause: error });
|
|
918
|
+
void this.#announceDisconnected(cause, this.#willReconnect(cause, this.#loopAttempt + 1));
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
#announceDisconnected(cause: Error | undefined, willReconnect: boolean): Promise<void> {
|
|
922
|
+
let resolve!: () => void;
|
|
923
|
+
const delivered = new Promise<void>((r) => (resolve = r));
|
|
924
|
+
try {
|
|
925
|
+
this.#emit("disconnected", { error: cause, willReconnect });
|
|
926
|
+
} finally {
|
|
927
|
+
resolve();
|
|
928
|
+
}
|
|
929
|
+
this.#disconnectedDelivered = delivered;
|
|
930
|
+
void delivered.then(() => {
|
|
931
|
+
if (this.#disconnectedDelivered === delivered) this.#disconnectedDelivered = undefined;
|
|
932
|
+
});
|
|
933
|
+
return delivered;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Whether the reconnect loop will make attempt number `attempt` after `err`.
|
|
938
|
+
* `disconnected` reports it, and the loop acts on the same answer: the
|
|
939
|
+
* caller's `shouldRetry` is asked once per failure, not once to announce it
|
|
940
|
+
* and again to act on it.
|
|
941
|
+
*/
|
|
942
|
+
#willReconnect(err: Error, attempt: number): boolean {
|
|
943
|
+
const options = this.#reconnectOptions;
|
|
944
|
+
if (options === undefined || this.#stopped) return false;
|
|
945
|
+
const { maxAttempts } = resolveBackoffPolicy(options);
|
|
946
|
+
if (maxAttempts !== 0 && attempt > maxAttempts) return false;
|
|
947
|
+
const asked = this.#retryAnswer;
|
|
948
|
+
if (asked?.err === err && asked.attempt === attempt) return asked.retry;
|
|
949
|
+
let retry: boolean;
|
|
950
|
+
let threw = false;
|
|
951
|
+
try {
|
|
952
|
+
retry = (options.shouldRetry ?? defaultShouldRetry)(err, attempt);
|
|
953
|
+
} catch (failure) {
|
|
954
|
+
this.#logger.error("shouldRetry threw; not reconnecting", { error: String(failure) });
|
|
955
|
+
retry = false;
|
|
956
|
+
threw = true;
|
|
957
|
+
}
|
|
958
|
+
this.#retryAnswer = { err, attempt, retry, threw };
|
|
959
|
+
return retry;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Retry a dropped connection according to the reconnect policy: every
|
|
964
|
+
* attempt re-opens the transport and reruns the whole setup, `login` and
|
|
965
|
+
* `use` included.
|
|
966
|
+
*
|
|
967
|
+
* A refused `login` or `use` ends the loop, through the default
|
|
968
|
+
* `shouldRetry`: the credentials or the target are wrong, and the same ones
|
|
969
|
+
* fail the same way. Retrying them is also what turns a dropped connection
|
|
970
|
+
* into a ban, since those are the two commands that reach the flood limit
|
|
971
|
+
* fastest.
|
|
972
|
+
*
|
|
973
|
+
* Never runs after an explicit `disconnect()`, and never more than one at a
|
|
974
|
+
* time: two failures in quick succession would otherwise start competing
|
|
975
|
+
* loops, and a loop shorter than the server's antiflood window bans the IP
|
|
976
|
+
* rather than reconnecting it. A drop while the loop runs is the loop's to
|
|
977
|
+
* retry: it ends only once its attempt is still connected when it resumes.
|
|
978
|
+
*/
|
|
979
|
+
async #scheduleReconnect(err: Error): Promise<void> {
|
|
980
|
+
const options = this.#reconnectOptions;
|
|
981
|
+
if (options === undefined || this.#stopped || this.#reconnecting) return;
|
|
982
|
+
|
|
983
|
+
const policy = resolveBackoffPolicy(options);
|
|
984
|
+
this.#reconnecting = true;
|
|
985
|
+
const stop = new AbortController();
|
|
986
|
+
this.#stopReconnecting = stop;
|
|
987
|
+
try {
|
|
988
|
+
for (let attempt = 1; policy.maxAttempts === 0 || attempt <= policy.maxAttempts; attempt++) {
|
|
989
|
+
if (this.#stopped) return;
|
|
990
|
+
if (!this.#willReconnect(err, attempt)) {
|
|
991
|
+
if (!this.#retryAnswer?.threw) {
|
|
992
|
+
this.#logger.info("not retrying", { attempt, reason: err.message });
|
|
993
|
+
}
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const delay = backoffDelay(policy, attempt);
|
|
998
|
+
this.#logger.info("reconnecting", { attempt, delayMs: delay });
|
|
999
|
+
// A timer that keeps the process alive: an application that does
|
|
1000
|
+
// nothing but stay connected would otherwise exit during the wait.
|
|
1001
|
+
await sleep(delay, stop.signal);
|
|
1002
|
+
if (this.#stopped) return;
|
|
1003
|
+
// Connected, or connecting, by hand during the wait: nothing is left
|
|
1004
|
+
// to retry, and an attempt now would only fail with
|
|
1005
|
+
// AlreadyConnectedError.
|
|
1006
|
+
if (this.#status !== ClientStatus.Disconnected) return;
|
|
1007
|
+
|
|
1008
|
+
this.#loopAttempt = attempt;
|
|
1009
|
+
try {
|
|
1010
|
+
await this.#connect({ signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) }, true);
|
|
1011
|
+
// Read through the getter: the status may have changed while
|
|
1012
|
+
// connect() resolved, which the field's narrowing would not see.
|
|
1013
|
+
if (this.status === ClientStatus.Connected) {
|
|
1014
|
+
this.#logger.info("reconnected", { attempt });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
// The new session dropped before this loop resumed. That drop found
|
|
1018
|
+
// this loop running and left the retrying to it.
|
|
1019
|
+
err = this.#lastDrop ?? new TeamspeakError("connection closed");
|
|
1020
|
+
} catch (retryErr) {
|
|
1021
|
+
if (retryErr instanceof AlreadyConnectedError) return;
|
|
1022
|
+
err =
|
|
1023
|
+
retryErr instanceof Error
|
|
1024
|
+
? retryErr
|
|
1025
|
+
: new TeamspeakError(String(retryErr), { cause: retryErr });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
this.#logger.warn("reconnect attempts exhausted", { attempts: policy.maxAttempts });
|
|
1029
|
+
} finally {
|
|
1030
|
+
this.#reconnecting = false;
|
|
1031
|
+
this.#loopAttempt = 0;
|
|
1032
|
+
if (this.#stopReconnecting === stop) this.#stopReconnecting = undefined;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* The core's server events. A transport with no notifications cannot deliver
|
|
1039
|
+
* any of them, so a listener for one on such a client could never run.
|
|
1040
|
+
*/
|
|
1041
|
+
const SERVER_EVENTS: ReadonlySet<string> = new Set([
|
|
1042
|
+
"textMessage",
|
|
1043
|
+
"clientEnter",
|
|
1044
|
+
"clientLeave",
|
|
1045
|
+
"clientMoved",
|
|
1046
|
+
"notification",
|
|
1047
|
+
"kicked",
|
|
1048
|
+
]);
|
|
1049
|
+
|
|
1050
|
+
function kickedEvent(event: {
|
|
1051
|
+
readonly reason: number;
|
|
1052
|
+
readonly reasonMessage: string;
|
|
1053
|
+
readonly invokerName: string;
|
|
1054
|
+
readonly invokerUID: string;
|
|
1055
|
+
readonly invokerID: number;
|
|
1056
|
+
}): KickedEvent {
|
|
1057
|
+
return {
|
|
1058
|
+
reason: event.reason,
|
|
1059
|
+
reasonMessage: event.reasonMessage,
|
|
1060
|
+
invokerName: event.invokerName,
|
|
1061
|
+
invokerUID: event.invokerUID as KickedEvent["invokerUID"],
|
|
1062
|
+
invokerID: event.invokerID as KickedEvent["invokerID"],
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** The server id a WebQuery transport addresses, from the `server` option. */
|
|
1067
|
+
function serverIDOf(server: ResolvedQueryOptions["server"]): ServerID | number | undefined {
|
|
1068
|
+
return server !== undefined && "id" in server ? server.id : undefined;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Which server generation a `version` string names. */
|
|
1072
|
+
function serverFamilyOf(version: string): ServerFamily {
|
|
1073
|
+
const major = Number.parseInt(version, 10);
|
|
1074
|
+
if (Number.isNaN(major)) return "unknown";
|
|
1075
|
+
if (major === 3) return "ts3";
|
|
1076
|
+
if (major >= 6) return "ts6";
|
|
1077
|
+
return "unknown";
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** Sleep, or wake early once `signal` aborts. Resolves either way, never rejects. */
|
|
1081
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
1082
|
+
if (ms <= 0 || signal.aborted) return Promise.resolve();
|
|
1083
|
+
return new Promise((resolve) => {
|
|
1084
|
+
const done = (): void => {
|
|
1085
|
+
clearTimeout(timer);
|
|
1086
|
+
signal.removeEventListener("abort", done);
|
|
1087
|
+
resolve();
|
|
1088
|
+
};
|
|
1089
|
+
const timer = setTimeout(done, ms);
|
|
1090
|
+
signal.addEventListener("abort", done, { once: true });
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* The rows of a line, which ServerQuery separates with `|`.
|
|
1096
|
+
*
|
|
1097
|
+
* Every row carries all its fields, so rows are not completed from the row
|
|
1098
|
+
* before as the voice protocol's are; a `|` inside a value is escaped (`\p`),
|
|
1099
|
+
* so splitting on it is exact. A notification's name is repeated on each row.
|
|
1100
|
+
*/
|
|
1101
|
+
function splitRows(line: string): string[] {
|
|
1102
|
+
const space = line.indexOf(" ");
|
|
1103
|
+
const head = space < 0 ? line : line.slice(0, space);
|
|
1104
|
+
const named = /^[A-Za-z0-9]+$/.test(head);
|
|
1105
|
+
const rest = named ? (space < 0 ? "" : line.slice(space + 1)) : line;
|
|
1106
|
+
const prefix = named ? `${head} ` : "";
|
|
1107
|
+
return rest
|
|
1108
|
+
.split("|")
|
|
1109
|
+
.filter((row) => row !== "")
|
|
1110
|
+
.map((row) => prefix + row);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* The refusal a file transfer negotiation reports inside a successful answer.
|
|
1115
|
+
*
|
|
1116
|
+
* `ftinitupload` and `ftinitdownload` answer `error id=0` even when they
|
|
1117
|
+
* refuse, and say so in the row instead: `clientftfid=2 status=2058
|
|
1118
|
+
* msg=file\sis\sin\suse`, with no `ftkey`. Left as it is, the typed command
|
|
1119
|
+
* would find no key and report that the server sent no data, losing the
|
|
1120
|
+
* reason; this turns the row into the server error it is.
|
|
1121
|
+
*/
|
|
1122
|
+
function transferRefusal(
|
|
1123
|
+
command: string,
|
|
1124
|
+
rows: ReadonlyArray<Record<string, string>>,
|
|
1125
|
+
): ServerError | undefined {
|
|
1126
|
+
if (command !== "ftinitupload" && command !== "ftinitdownload") return undefined;
|
|
1127
|
+
const row = rows[0];
|
|
1128
|
+
if (row === undefined || row["ftkey"] !== undefined) return undefined;
|
|
1129
|
+
const status = Number(row["status"]);
|
|
1130
|
+
if (!Number.isInteger(status) || status === 0) return undefined;
|
|
1131
|
+
return new ServerError(status, row["msg"] ?? "");
|
|
1132
|
+
}
|