@honeybbq/teamspeak 1.0.0-beta.19 → 1.0.0-beta.21

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.
Files changed (179) hide show
  1. package/dist/client/api/avatars.d.ts.map +1 -1
  2. package/dist/client/api/avatars.js +11 -2
  3. package/dist/client/api/avatars.js.map +1 -1
  4. package/dist/client/api/files.d.ts +36 -14
  5. package/dist/client/api/files.d.ts.map +1 -1
  6. package/dist/client/api/files.js +58 -9
  7. package/dist/client/api/files.js.map +1 -1
  8. package/dist/client/api/upload-checks.d.ts +36 -0
  9. package/dist/client/api/upload-checks.d.ts.map +1 -0
  10. package/dist/client/api/upload-checks.js +77 -0
  11. package/dist/client/api/upload-checks.js.map +1 -0
  12. package/dist/client/api/voice.d.ts +2 -1
  13. package/dist/client/api/voice.d.ts.map +1 -1
  14. package/dist/client/api/voice.js +6 -2
  15. package/dist/client/api/voice.js.map +1 -1
  16. package/dist/client/client.d.ts +13 -3
  17. package/dist/client/client.d.ts.map +1 -1
  18. package/dist/client/client.js +151 -49
  19. package/dist/client/client.js.map +1 -1
  20. package/dist/client/crypto/crypt.d.ts +6 -0
  21. package/dist/client/crypto/crypt.d.ts.map +1 -1
  22. package/dist/client/crypto/crypt.js +13 -7
  23. package/dist/client/crypto/crypt.js.map +1 -1
  24. package/dist/client/crypto/eax.d.ts +4 -0
  25. package/dist/client/crypto/eax.d.ts.map +1 -1
  26. package/dist/client/crypto/eax.js +27 -21
  27. package/dist/client/crypto/eax.js.map +1 -1
  28. package/dist/client/errors.d.ts +13 -0
  29. package/dist/client/errors.d.ts.map +1 -1
  30. package/dist/client/errors.js +23 -0
  31. package/dist/client/errors.js.map +1 -1
  32. package/dist/client/handshake/crypt-handshake.d.ts +2 -1
  33. package/dist/client/handshake/crypt-handshake.d.ts.map +1 -1
  34. package/dist/client/handshake/crypt-handshake.js +4 -2
  35. package/dist/client/handshake/crypt-handshake.js.map +1 -1
  36. package/dist/client/index.d.ts +1 -1
  37. package/dist/client/index.d.ts.map +1 -1
  38. package/dist/client/index.js +1 -1
  39. package/dist/client/index.js.map +1 -1
  40. package/dist/client/throttle.d.ts +11 -0
  41. package/dist/client/throttle.d.ts.map +1 -1
  42. package/dist/client/throttle.js +38 -3
  43. package/dist/client/throttle.js.map +1 -1
  44. package/dist/client/transfer.d.ts +37 -4
  45. package/dist/client/transfer.d.ts.map +1 -1
  46. package/dist/client/transfer.js +155 -15
  47. package/dist/client/transfer.js.map +1 -1
  48. package/dist/client/transport/handler.d.ts.map +1 -1
  49. package/dist/client/transport/handler.js +47 -43
  50. package/dist/client/transport/handler.js.map +1 -1
  51. package/dist/core/api/clients.d.ts +15 -15
  52. package/dist/core/api/clients.d.ts.map +1 -1
  53. package/dist/core/api/clients.js.map +1 -1
  54. package/dist/core/api/command.d.ts +8 -6
  55. package/dist/core/api/command.d.ts.map +1 -1
  56. package/dist/core/api/command.js +1 -1
  57. package/dist/core/api/command.js.map +1 -1
  58. package/dist/core/api/files.d.ts +23 -6
  59. package/dist/core/api/files.d.ts.map +1 -1
  60. package/dist/core/api/files.js +12 -9
  61. package/dist/core/api/files.js.map +1 -1
  62. package/dist/core/api/groups.d.ts +17 -2
  63. package/dist/core/api/groups.d.ts.map +1 -1
  64. package/dist/core/api/groups.js +13 -11
  65. package/dist/core/api/groups.js.map +1 -1
  66. package/dist/core/api/parse.d.ts +5 -5
  67. package/dist/core/api/parse.d.ts.map +1 -1
  68. package/dist/core/api/parse.js +16 -15
  69. package/dist/core/api/parse.js.map +1 -1
  70. package/dist/core/command/request.d.ts +6 -0
  71. package/dist/core/command/request.d.ts.map +1 -1
  72. package/dist/core/command/request.js +12 -0
  73. package/dist/core/command/request.js.map +1 -1
  74. package/dist/core/errors.d.ts +46 -3
  75. package/dist/core/errors.d.ts.map +1 -1
  76. package/dist/core/errors.js +73 -20
  77. package/dist/core/errors.js.map +1 -1
  78. package/dist/core/events.d.ts +24 -8
  79. package/dist/core/events.d.ts.map +1 -1
  80. package/dist/core/internal.d.ts +1 -1
  81. package/dist/core/internal.d.ts.map +1 -1
  82. package/dist/core/internal.js +1 -1
  83. package/dist/core/internal.js.map +1 -1
  84. package/dist/core/notifications.d.ts +1 -1
  85. package/dist/core/notifications.js +9 -8
  86. package/dist/core/notifications.js.map +1 -1
  87. package/dist/core/reconnect.d.ts +16 -5
  88. package/dist/core/reconnect.d.ts.map +1 -1
  89. package/dist/core/reconnect.js +32 -4
  90. package/dist/core/reconnect.js.map +1 -1
  91. package/dist/core/types.d.ts +15 -16
  92. package/dist/core/types.d.ts.map +1 -1
  93. package/dist/core/types.js.map +1 -1
  94. package/dist/query/client.d.ts +4 -0
  95. package/dist/query/client.d.ts.map +1 -1
  96. package/dist/query/client.js +145 -23
  97. package/dist/query/client.js.map +1 -1
  98. package/dist/query/errors.d.ts +20 -0
  99. package/dist/query/errors.d.ts.map +1 -1
  100. package/dist/query/errors.js +31 -0
  101. package/dist/query/errors.js.map +1 -1
  102. package/dist/query/index.d.ts +1 -1
  103. package/dist/query/index.d.ts.map +1 -1
  104. package/dist/query/index.js +1 -1
  105. package/dist/query/index.js.map +1 -1
  106. package/dist/query/lines.d.ts +11 -0
  107. package/dist/query/lines.d.ts.map +1 -1
  108. package/dist/query/lines.js +36 -6
  109. package/dist/query/lines.js.map +1 -1
  110. package/dist/query/options.d.ts +30 -0
  111. package/dist/query/options.d.ts.map +1 -1
  112. package/dist/query/options.js +7 -0
  113. package/dist/query/options.js.map +1 -1
  114. package/dist/query/reconnect.d.ts +5 -0
  115. package/dist/query/reconnect.d.ts.map +1 -1
  116. package/dist/query/reconnect.js +26 -7
  117. package/dist/query/reconnect.js.map +1 -1
  118. package/dist/query/ssh.js +9 -4
  119. package/dist/query/ssh.js.map +1 -1
  120. package/dist/query/stream.d.ts +2 -0
  121. package/dist/query/stream.d.ts.map +1 -1
  122. package/dist/query/stream.js +19 -1
  123. package/dist/query/stream.js.map +1 -1
  124. package/dist/query/transport.d.ts +12 -2
  125. package/dist/query/transport.d.ts.map +1 -1
  126. package/dist/query/web.d.ts +3 -1
  127. package/dist/query/web.d.ts.map +1 -1
  128. package/dist/query/web.js +82 -18
  129. package/dist/query/web.js.map +1 -1
  130. package/dist/stream/published.d.ts.map +1 -1
  131. package/dist/stream/published.js +61 -25
  132. package/dist/stream/published.js.map +1 -1
  133. package/dist/stream/streams.d.ts.map +1 -1
  134. package/dist/stream/streams.js +51 -10
  135. package/dist/stream/streams.js.map +1 -1
  136. package/dist/stream/wire.d.ts +2 -2
  137. package/dist/stream/wire.d.ts.map +1 -1
  138. package/dist/stream/wire.js +27 -7
  139. package/dist/stream/wire.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/client/api/avatars.ts +15 -2
  142. package/src/client/api/files.ts +83 -23
  143. package/src/client/api/upload-checks.ts +96 -0
  144. package/src/client/api/voice.ts +5 -2
  145. package/src/client/client.ts +154 -42
  146. package/src/client/crypto/crypt.ts +15 -7
  147. package/src/client/crypto/eax.ts +27 -22
  148. package/src/client/errors.ts +25 -0
  149. package/src/client/handshake/crypt-handshake.ts +6 -3
  150. package/src/client/index.ts +1 -0
  151. package/src/client/throttle.ts +41 -3
  152. package/src/client/transfer.ts +172 -28
  153. package/src/client/transport/handler.ts +54 -46
  154. package/src/core/api/clients.ts +24 -21
  155. package/src/core/api/command.ts +9 -7
  156. package/src/core/api/files.ts +30 -16
  157. package/src/core/api/groups.ts +32 -13
  158. package/src/core/api/parse.ts +16 -15
  159. package/src/core/command/request.ts +18 -0
  160. package/src/core/errors.ts +74 -20
  161. package/src/core/events.ts +24 -8
  162. package/src/core/internal.ts +7 -1
  163. package/src/core/notifications.ts +9 -8
  164. package/src/core/reconnect.ts +60 -8
  165. package/src/core/types.ts +15 -16
  166. package/src/query/client.ts +144 -20
  167. package/src/query/errors.ts +34 -0
  168. package/src/query/index.ts +1 -0
  169. package/src/query/lines.ts +38 -6
  170. package/src/query/options.ts +36 -0
  171. package/src/query/reconnect.ts +27 -7
  172. package/src/query/ssh.ts +11 -4
  173. package/src/query/stream.ts +17 -1
  174. package/src/query/testing/live.ts +4 -4
  175. package/src/query/transport.ts +10 -2
  176. package/src/query/web.ts +76 -21
  177. package/src/stream/published.ts +70 -23
  178. package/src/stream/streams.ts +47 -13
  179. package/src/stream/wire.ts +30 -11
@@ -37,10 +37,6 @@ import type {
37
37
  import { asClientFileTransferID, asClientID, asServerFileTransferID } from "../command/ids.js";
38
38
  import type { FileDownloadInfo, FileUploadInfo } from "../types.js";
39
39
 
40
- /** The `ip` of a negotiation answer, when the server sent one. */
41
- const ipField = (row: Record<string, string>): { readonly ip?: string } =>
42
- str(row, "ip") === "" ? {} : { ip: str(row, "ip") };
43
-
44
40
  const asPath = (path: string): string => (path.startsWith("/") ? path : `/${path}`);
45
41
 
46
42
  /** One entry of a directory listing, or one file's details. */
@@ -109,9 +105,10 @@ export interface FileTreeEntry extends FileEntry {
109
105
  /** How {@link listFilesRecursive} walks the file area. */
110
106
  export interface FileTreeOptions extends FileOptions {
111
107
  /**
112
- * How many directory levels to go down from the starting path: 0 lists the
113
- * starting directory only, as {@link listFiles} does; 1 also lists the
114
- * directories in it; and so on. 16 by default. A non-negative integer.
108
+ * How many directory levels to list, counted as `find -maxdepth` counts: 1
109
+ * lists the starting directory only, as {@link listFiles} does; 2 also lists
110
+ * the directories in it; and so on. 16 by default; there is no unbounded
111
+ * walk, since every level is a round trip. A positive integer.
115
112
  */
116
113
  readonly maxDepth?: number | undefined;
117
114
  }
@@ -148,8 +145,8 @@ export async function listFilesRecursive(
148
145
  options: FileTreeOptions = {},
149
146
  ): Promise<FileTreeEntry[]> {
150
147
  const { maxDepth = DEFAULT_MAX_DEPTH, ...listOptions } = options;
151
- if (!Number.isInteger(maxDepth) || maxDepth < 0) {
152
- throw new RangeError(`maxDepth is a non-negative integer, not ${String(maxDepth)}`);
148
+ if (!Number.isInteger(maxDepth) || maxDepth < 1) {
149
+ throw new RangeError(`maxDepth is a positive integer, not ${String(maxDepth)}`);
153
150
  }
154
151
  const found: FileTreeEntry[] = [];
155
152
  const walk = async (directory: string, depth: number): Promise<void> => {
@@ -159,7 +156,7 @@ export async function listFilesRecursive(
159
156
  if (entry.isDirectory && depth < maxDepth) await walk(entryPath, depth + 1);
160
157
  }
161
158
  };
162
- await walk(asPath(path), 0);
159
+ await walk(asPath(path), 1);
163
160
  return found;
164
161
  }
165
162
 
@@ -299,7 +296,11 @@ export interface FileTransferStopOptions extends CommandOptions {
299
296
  * Stop only your own transfers: TeamSpeak 6 6.0.0-beta13.1 crashes on a
300
297
  * ServerQuery `ftstop` for a transfer that is not the connection's own (one
301
298
  * that does not exist, or another client's), and nothing here guards against
302
- * it. See "Server behaviour the SDK works around" in `docs/serverquery-responses.md`.
299
+ * it. A transfer of your own is gone once it has finished, or when nobody
300
+ * connected to it within about 10 seconds of its being negotiated (measured on
301
+ * TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0), so stopping it then crashes
302
+ * TeamSpeak 6 too. See "Server behaviour the SDK works around" in
303
+ * `docs/serverquery-responses.md`.
303
304
  */
304
305
  export async function stopFileTransfer(
305
306
  client: CommandExecutor,
@@ -395,7 +396,14 @@ export interface FileUploadOptions extends CommandOptions {
395
396
  * destroys a file unless asked to (`overwrite`, always sent).
396
397
  */
397
398
  readonly overwrite?: boolean | undefined;
398
- /** Continue a partial upload; false by default (`resume`). */
399
+ /**
400
+ * Continue a partial upload; false by default (`resume`). The server keeps
401
+ * what an interrupted upload had received, and `seekPosition` of the answer
402
+ * is how much that is: send the file from there. Measured on TeamSpeak 3
403
+ * 3.13.8 and TeamSpeak 6 6.0.0: resuming a file that is not there is refused
404
+ * with 2054 (invalid file path), and `overwrite` together with `resume` with
405
+ * 2056 (overwrite excludes resume).
406
+ */
399
407
  readonly resume?: boolean | undefined;
400
408
  }
401
409
 
@@ -436,10 +444,10 @@ export async function initFileUpload(
436
444
  return {
437
445
  fileTransferKey: str(row, "ftkey"),
438
446
  port: num(row, "port"),
447
+ size,
439
448
  seekPosition: big(row, "seekpos"),
440
449
  clientFileTransferID: asClientFileTransferID(uint(row, "clientftfid", U16)),
441
450
  serverFileTransferID: asServerFileTransferID(uint(row, "serverftfid", U16)),
442
- ...ipField(row),
443
451
  };
444
452
  }
445
453
 
@@ -447,7 +455,12 @@ export async function initFileUpload(
447
455
  export interface FileDownloadOptions extends CommandOptions {
448
456
  /** The channel's password, in cleartext; "" by default (`cpw`). */
449
457
  readonly password?: string | undefined;
450
- /** The byte offset to start from, to resume; 0 by default (`seekpos`). */
458
+ /**
459
+ * The byte offset to start from, to resume; 0 by default (`seekpos`). The
460
+ * answer's `size` stays the whole file's, and the bytes sent are those from
461
+ * here to the end; an offset past the end is refused with 2057 (invalid file
462
+ * size), measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0.
463
+ */
451
464
  readonly seekPosition?: bigint | undefined;
452
465
  }
453
466
 
@@ -463,6 +476,7 @@ export async function initFileDownload(
463
476
  path: string,
464
477
  options: FileDownloadOptions = {},
465
478
  ): Promise<FileDownloadInfo> {
479
+ const seekPosition = options.seekPosition ?? 0n;
466
480
  const rows = await query(
467
481
  client,
468
482
  "ftinitdownload",
@@ -471,7 +485,7 @@ export async function initFileDownload(
471
485
  ["name", asPath(path)],
472
486
  ["cid", cid],
473
487
  ["cpw", options.password ?? ""],
474
- ["seekpos", options.seekPosition ?? 0n],
488
+ ["seekpos", seekPosition],
475
489
  ["proto", 0],
476
490
  ],
477
491
  options,
@@ -482,8 +496,8 @@ export async function initFileDownload(
482
496
  fileTransferKey: str(row, "ftkey"),
483
497
  port: num(row, "port"),
484
498
  size: big(row, "size"),
499
+ seekPosition,
485
500
  clientFileTransferID: asClientFileTransferID(uint(row, "clientftfid", U16)),
486
501
  serverFileTransferID: asServerFileTransferID(uint(row, "serverftfid", U16)),
487
- ...ipField(row),
488
502
  };
489
503
  }
@@ -408,7 +408,10 @@ export async function getPermissionIDByName(
408
408
  * What a permission is set on, as `permfind` and `permoverview` report it:
409
409
  * discriminated on `target`, so each kind carries its own typed ids. The wire
410
410
  * sends them as `t`, `id1` and `id2`, whose meaning depends on `t`; a row with
411
- * a target this table does not name is left out of the results.
411
+ * a target this table does not name is passed through as the last kind, with
412
+ * the number and the raw ids. Narrowing on `target` therefore leaves that
413
+ * kind in the union: compare `target` with {@link PermissionTarget} and
414
+ * handle the rest in a default case.
412
415
  */
413
416
  export type PermissionLocation =
414
417
  | {
@@ -454,10 +457,20 @@ export type PermissionLocation =
454
457
  readonly databaseID: ClientDBID;
455
458
  /** The permission (`p`). */
456
459
  readonly permission: PermissionID;
460
+ }
461
+ | {
462
+ /** Set on something this table does not name: the number the server sent (`t`). */
463
+ readonly target: number;
464
+ /** The first id as sent (`id1`); what it names depends on `target`. */
465
+ readonly id1: bigint;
466
+ /** The second id as sent (`id2`). */
467
+ readonly id2: bigint;
468
+ /** The permission (`p`). */
469
+ readonly permission: PermissionID;
457
470
  };
458
471
 
459
- /** Read a location from a `permfind` or `permoverview` row; undefined for an unknown target. */
460
- function parseLocation(row: Record<string, string>): PermissionLocation | undefined {
472
+ /** Read a location from a `permfind` or `permoverview` row. */
473
+ function parseLocation(row: Record<string, string>): PermissionLocation {
461
474
  const permission = asPermissionID(uint(row, "p", U32));
462
475
  const id1 = big(row, "id1");
463
476
  const id2 = big(row, "id2");
@@ -487,7 +500,7 @@ function parseLocation(row: Record<string, string>): PermissionLocation | undefi
487
500
  permission,
488
501
  };
489
502
  default:
490
- return undefined;
503
+ return { target: num(row, "t"), id1, id2, permission };
491
504
  }
492
505
  }
493
506
 
@@ -498,7 +511,7 @@ export async function findPermission(
498
511
  options?: CommandOptions,
499
512
  ): Promise<PermissionLocation[]> {
500
513
  const rows = await list(client, "permfind", [permissionParam(permission)], options);
501
- return rows.flatMap((row) => parseLocation(row) ?? []);
514
+ return rows.map(parseLocation);
502
515
  }
503
516
 
504
517
  /** One of this connection's own permissions, as `permget` reports it. */
@@ -573,11 +586,12 @@ export async function getPermissionOverview(
573
586
  ],
574
587
  options,
575
588
  );
576
- return rows.flatMap((row) => {
577
- const location = parseLocation(row);
578
- if (location === undefined) return [];
579
- return [{ ...location, value: num(row, "v"), negated: bool(row, "n"), skip: bool(row, "s") }];
580
- });
589
+ return rows.map((row) => ({
590
+ ...parseLocation(row),
591
+ value: num(row, "v"),
592
+ negated: bool(row, "n"),
593
+ skip: bool(row, "s"),
594
+ }));
581
595
  }
582
596
 
583
597
  /**
@@ -605,7 +619,10 @@ export interface PrivilegeKey {
605
619
  readonly token: string;
606
620
  /** Which kind of group the key grants, one of {@link PrivilegeKeyType}. */
607
621
  readonly type: number;
608
- /** The server group a `PrivilegeKeyType.ServerGroup` key grants; undefined otherwise. */
622
+ /**
623
+ * The server group a `PrivilegeKeyType.ServerGroup` key grants; undefined
624
+ * for any other type, including one this SDK does not name.
625
+ */
609
626
  readonly serverGroupID: ServerGroupID | undefined;
610
627
  /** The channel group a `PrivilegeKeyType.ChannelGroup` key grants; undefined otherwise. */
611
628
  readonly channelGroupID: ChannelGroupID | undefined;
@@ -625,15 +642,17 @@ export interface PrivilegeKey {
625
642
  /**
626
643
  * Read a key. The wire carries the group as `token_id1` and the channel as
627
644
  * `token_id2`, whichever kind of group it is; which typed field each belongs in
628
- * depends on the token type.
645
+ * depends on the token type. A type this SDK does not name gets neither group
646
+ * nor channel: what its ids mean is not known.
629
647
  */
630
648
  const parseKey = (row: Record<string, string>): PrivilegeKey => {
631
649
  const type = num(row, "token_type");
632
650
  const channelGroup = type === PrivilegeKeyType.ChannelGroup;
651
+ const serverGroup = type === PrivilegeKeyType.ServerGroup;
633
652
  return {
634
653
  token: str(row, "token"),
635
654
  type,
636
- serverGroupID: channelGroup ? undefined : asServerGroupID(big(row, "token_id1")),
655
+ serverGroupID: serverGroup ? asServerGroupID(big(row, "token_id1")) : undefined,
637
656
  channelGroupID: channelGroup ? asChannelGroupID(big(row, "token_id1")) : undefined,
638
657
  channelID: channelGroup ? asChannelID(big(row, "token_id2")) : undefined,
639
658
  description: str(row, "token_description"),
@@ -45,39 +45,40 @@ const flag = (key: string): FieldSpec<boolean> => ({ keys: [key], read: bool });
45
45
  /**
46
46
  * The wire key of every client field but `id`, and how each is read: the one
47
47
  * table `parseClient` and `parseClientChanges` share, so a row, a listing and
48
- * a change cannot disagree about a name. `always` marks the fields a full
49
- * record has had from the start, which read as a default from a row without
50
- * them; every other field is left out when the row lacks it.
48
+ * a change cannot disagree about a name. `always` marks the fields every
49
+ * source carries, which read as a default from a row without them; every
50
+ * other field is left out when the row lacks it.
51
51
  */
52
52
  export const clientFields: {
53
53
  readonly [K in Exclude<keyof ClientEntry, "id">]-?: FieldSpec<ClientEntry[K]>;
54
54
  } = {
55
55
  nickname: { keys: ["client_nickname"], read: str, always: true },
56
- uid: { keys: ["client_unique_identifier"], read: (r, k) => asUID(str(r, k)), always: true },
57
56
  // A listing names the channel `cid`, an enter notification `ctid`.
58
57
  channelID: { keys: ["cid", "ctid"], read: (r, k) => asChannelID(big(r, k)), always: true },
59
58
  type: { keys: ["client_type"], read: num, always: true },
59
+ uid: { keys: ["client_unique_identifier"], read: (r, k) => asUID(str(r, k)) },
60
60
  serverGroups: {
61
61
  keys: ["client_servergroups"],
62
62
  read: (r, k) => parseServerGroups(r[k] ?? ""),
63
- always: true,
64
63
  },
65
- isAway: { keys: ["client_away"], read: bool, always: true },
66
- isRecording: { keys: ["client_is_recording"], read: bool, always: true },
67
- isTalker: { keys: ["client_is_talker"], read: bool, always: true },
64
+ isAway: flag("client_away"),
65
+ isRecording: flag("client_is_recording"),
66
+ isTalker: flag("client_is_talker"),
68
67
  channelGroupID: {
69
68
  keys: ["client_channel_group_id"],
70
69
  read: (r, k) => asChannelGroupID(big(r, k)),
71
- always: true,
72
70
  },
73
- createdAt: { keys: ["client_created"], read: date, always: true },
74
- lastConnectedAt: { keys: ["client_lastconnected"], read: date, always: true },
71
+ createdAt: { keys: ["client_created"], read: date },
72
+ lastConnectedAt: { keys: ["client_lastconnected"], read: date },
75
73
  idleTimeMs: {
76
74
  keys: ["client_idle_time"],
77
- read: (r, k) => (r[k] === undefined ? undefined : durationMs(r, k, MILLISECOND)),
75
+ read: (r, k) => durationMs(r, k, MILLISECOND),
76
+ },
77
+ databaseID: {
78
+ keys: ["client_database_id"],
79
+ read: (r, k) => asClientDBID(big(r, k)),
78
80
  always: true,
79
81
  },
80
- databaseID: { keys: ["client_database_id"], read: (r, k) => asClientDBID(big(r, k)) },
81
82
  awayMessage: text("client_away_message"),
82
83
  isInputMuted: flag("client_input_muted"),
83
84
  isOutputMuted: flag("client_output_muted"),
@@ -120,8 +121,8 @@ export const clientFields: {
120
121
  * which names its channel `ctid` where the list says `cid`. Reached by the
121
122
  * client module through `./internal`.
122
123
  *
123
- * The fields a client record has always had read as a default from a row
124
- * without them; the later ones are `undefined` instead.
124
+ * The fields every source carries read as a default from a row without
125
+ * them; the others are `undefined` instead.
125
126
  */
126
127
  export function parseClient(row: Record<string, string>): ClientEntry {
127
128
  return {
@@ -40,6 +40,12 @@ export interface CommandOptions {
40
40
  readonly signal?: AbortSignal | undefined;
41
41
  /** Milliseconds to wait for the answer before failing with a CommandTimeoutError. */
42
42
  readonly timeoutMs?: number | undefined;
43
+ /**
44
+ * Called once, when the command has left the flood throttle and was handed to
45
+ * the connection. A command that fails without it was never sent; one that
46
+ * fails after it may have been carried out.
47
+ */
48
+ readonly onSent?: (() => void) | undefined;
43
49
  }
44
50
 
45
51
  /**
@@ -96,6 +102,13 @@ export function encodeRequest(
96
102
  return [line, ...rest.map((row) => buildCommandOrdered("", encode(row)).slice(1))].join("|");
97
103
  }
98
104
 
105
+ /**
106
+ * A parameter name: letters, digits and underscores, with one leading `-` for a
107
+ * flag such as `-uid`. Every key measured on TeamSpeak 3 and 6, in a command or
108
+ * an answer, is of this form.
109
+ */
110
+ const VALID_KEY = /^-?[A-Za-z0-9_]+$/;
111
+
99
112
  function encodeParams(
100
113
  params: ReadonlyArray<readonly [string, ParamValue]>,
101
114
  passwords: readonly string[],
@@ -103,6 +116,11 @@ function encodeParams(
103
116
  ): Array<readonly [string, string]> {
104
117
  const out: Array<readonly [string, string]> = [];
105
118
  for (const [key, value] of params) {
119
+ // A key is a protocol identifier, written to the line as it is: one with a
120
+ // space, a newline or a pipe in it would add parameters, rows or a whole
121
+ // command to what the caller meant. Only a key a server could have named
122
+ // is sent.
123
+ if (!VALID_KEY.test(key)) throw new TypeError(`${JSON.stringify(key)} is not a parameter name`);
106
124
  // Flags (`-uid`) are switches: present when true, absent otherwise.
107
125
  if (key.startsWith("-")) {
108
126
  if (value === true) out.push([key, ""]);
@@ -231,13 +231,78 @@ export class NoDataError extends TeamspeakError {
231
231
  }
232
232
 
233
233
  /**
234
- * Read an error id. One that is missing or unreadable is reported as
235
- * {@link ErrorCode.Undefined}: an error line that does not say what went wrong
236
- * has still not said that nothing did.
234
+ * `raw` as an unsigned 32-bit number, or undefined unless it is nothing but
235
+ * digits and fits: "12abc", " 7", "-1" and "4294967296" are not numbers the
236
+ * server sends, and are not read as 12, 7, 1 or a code no one defined.
237
+ */
238
+ function parseUint32(raw: string | undefined): number | undefined {
239
+ if (raw === undefined || !/^\d+$/.test(raw)) return undefined;
240
+ const value = Number(raw);
241
+ return value <= 0xffff_ffff ? value : undefined;
242
+ }
243
+
244
+ /**
245
+ * Read an error id. One that is missing or unreadable (not an unsigned 32-bit
246
+ * number) is reported as {@link ErrorCode.Undefined}: an error line that does
247
+ * not say what went wrong has still not said that nothing did.
237
248
  */
238
249
  export function parseErrorCode(raw: string | undefined): number {
239
- if (raw === undefined || !/^\d+$/.test(raw)) return ErrorCode.Undefined;
240
- return Number(raw);
250
+ return parseUint32(raw) ?? ErrorCode.Undefined;
251
+ }
252
+
253
+ /**
254
+ * How many times a command refused as flooding is sent again after the pause
255
+ * the server asked for. On the voice protocol the first resend is usually
256
+ * refused once more with a shorter wait, and the second goes through (measured
257
+ * on TeamSpeak 3 and 6); on ServerQuery a refusal can be answered with a
258
+ * longer wait, which the caller's timeout then decides whether to sit out.
259
+ */
260
+ export const FLOOD_RETRIES = 3;
261
+ /**
262
+ * How long to pause for a refusal that asked for `waitMs`. The wait is a floor:
263
+ * on the voice protocol a resend after exactly the wait is refused again, and
264
+ * one after half as long again goes through; on ServerQuery a resend after
265
+ * exactly 1 second was refused 2 times in 5, and one after 2.2 seconds went
266
+ * through 5 times in 5 (TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0). Every
267
+ * refusal costs a retry and lengthens the server's pause, so the pause is
268
+ * twice the wait.
269
+ */
270
+ export function floodHoldMs(waitMs: number): number {
271
+ return waitMs * 2;
272
+ }
273
+
274
+ /**
275
+ * How long the server asks a client it refused as flooding to wait, in
276
+ * milliseconds. Undefined for any other error, or one that names no wait.
277
+ *
278
+ * Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0: a voice client whose
279
+ * antiflood points pass `virtualserver_antiflood_points_needed_command_block`
280
+ * gets `error id=524 msg=client\sis\sflooding extra_msg=retry\sin\s1985ms`
281
+ * for each command, and the command is not run. The wait is a floor, not a
282
+ * promise: a command sent as the wait ends is refused again with a shorter one,
283
+ * and the second try went through in every measurement.
284
+ *
285
+ * A ServerQuery client (raw TCP and SSH; over WebQuery the server closes the
286
+ * connection and says nothing) that sends faster than
287
+ * `serverinstance_serverquery_flood_commands` per `_flood_time` gets the same
288
+ * error with the wait in whole seconds,
289
+ * `extra_msg=please\swait\s1\sseconds`, and the command is not run. The first
290
+ * refusals say 1; later ones say the instance's
291
+ * `serverinstance_serverquery_ban_time`, as the address is banned for that long
292
+ * (new connections are dropped meanwhile). Asking again every half second kept
293
+ * the ban going for as long as it was kept up (9 seconds against a ban time of
294
+ * 4), while waiting out the pause and asking once got through. The wait is a
295
+ * floor here too: about one resend in three after exactly the wait was refused
296
+ * again, with the ban time.
297
+ */
298
+ export function floodRetryDelayMs(error: Error): number | undefined {
299
+ if (!(error instanceof ServerError) || error.code !== ErrorCode.ClientIsFlooding)
300
+ return undefined;
301
+ const extra = error.extraMessage ?? "";
302
+ const millis = /^retry in (\d{1,9})ms$/.exec(extra);
303
+ if (millis !== null) return Number(millis[1]);
304
+ const seconds = /^please wait (\d{1,9}) seconds$/.exec(extra);
305
+ return seconds === null ? undefined : Number(seconds[1]) * 1000;
241
306
  }
242
307
 
243
308
  /**
@@ -249,28 +314,17 @@ export function parseServerError(params: Record<string, string>): {
249
314
  rc: number | null;
250
315
  } {
251
316
  const code = parseErrorCode(params["id"]);
252
- const rcStr = params["return_code"];
253
317
 
254
318
  let err: ServerError | null = null;
255
319
  if (code !== ErrorCode.OK) {
256
- const failedPermid = params["failed_permid"];
320
+ const failedPermid = parseUint32(params["failed_permid"]);
257
321
  err = new ServerError(code, params["msg"] ?? "", {
258
322
  extraMessage: params["extra_msg"] || undefined,
259
- failedPermissionID:
260
- failedPermid !== undefined &&
261
- /^\d{1,10}$/.test(failedPermid) &&
262
- Number(failedPermid) <= 0xffff_ffff
263
- ? asPermissionID(Number(failedPermid))
264
- : undefined,
323
+ failedPermissionID: failedPermid === undefined ? undefined : asPermissionID(failedPermid),
265
324
  failedPermission: params["failed_permission"] || undefined,
266
325
  });
267
326
  }
268
327
 
269
- let rc: number | null = null;
270
- if (rcStr !== undefined && rcStr !== "") {
271
- const parsed = parseInt(rcStr, 10);
272
- if (!isNaN(parsed)) rc = parsed;
273
- }
274
-
275
- return { err, rc };
328
+ // A return code that is not an unsigned 32-bit number names no command.
329
+ return { err, rc: parseUint32(params["return_code"]) ?? null };
276
330
  }
@@ -20,6 +20,11 @@ export interface TextMessageEvent {
20
20
  * The sender's server groups. A text message does not carry them; they are
21
21
  * those of the sender as the client knows it, so they are `undefined` for a
22
22
  * sender the client has not seen.
23
+ *
24
+ * A ServerQuery connection is not sent `notifyclientupdated`, so for a query
25
+ * client they are the groups from the listing taken on connecting, kept
26
+ * current only by clients entering, leaving and moving: a change of groups
27
+ * made since is not in them.
23
28
  */
24
29
  readonly invokerGroups: readonly ServerGroupID[] | undefined;
25
30
  /**
@@ -38,15 +43,21 @@ export interface TextMessageEvent {
38
43
 
39
44
  /** A client changing channel, of its own accord or moved by someone. */
40
45
  export interface ClientMovedEvent {
41
- /** Who moved the client; the client itself for a move of its own. */
46
+ /**
47
+ * Who moved the client; "" when the server names no one, which it does not
48
+ * for a move of the client's own (reason 0).
49
+ */
42
50
  readonly invokerName: string;
43
- /** The mover's unique identifier. */
51
+ /** The mover's unique identifier; "" when none is named. */
44
52
  readonly invokerUID: UID;
45
53
  /** The channel the client is now in. */
46
54
  readonly targetChannelID: ChannelID;
47
55
  /**
48
- * The channel the client came from (`cfid`). Absent when the server sends
49
- * none: TS3 3.13.8 sends no `cfid` on a move, TS6 6.0.0-beta13.1 does.
56
+ * The channel the client came from: `cfid`, or when the server sends none
57
+ * (TS3 3.13.8 sends no `cfid` on a move, TS6 6.0.0 does) the channel the
58
+ * client was known to be in. Absent only when the server sent none and the
59
+ * client was not known. A client's own roster entry is updated before the
60
+ * event is delivered, so this is the only place the previous channel is.
50
61
  */
51
62
  readonly fromChannelID?: ChannelID | undefined;
52
63
  /** Why the client moved; compare it with `Reason`. */
@@ -55,7 +66,7 @@ export interface ClientMovedEvent {
55
66
  readonly reasonMessage: string;
56
67
  /** The client that moved. */
57
68
  readonly clientID: ClientID;
58
- /** The mover. */
69
+ /** The mover; 0 when the server names no one, as for a move of the client's own. */
59
70
  readonly invokerID: ClientID;
60
71
  }
61
72
 
@@ -113,8 +124,9 @@ export interface KickedEvent {
113
124
  /**
114
125
  * A client coming into view: the record `listClients` returns, so one type
115
126
  * describes a client however it was learned of, with what only the
116
- * notification says. Its own id is `id`, as in every record. The fields only
117
- * `clientlist -times` reports are undefined here.
127
+ * notification says. Its own id is `id`, as in every record. The notification
128
+ * carries every field but the ones only `clientlist` reports (`-times`,
129
+ * `-info`, `-ip`), which are undefined here.
118
130
  *
119
131
  * `fromChannelID` and `reason` belong to the event: the roster and
120
132
  * `view.clients` keep the record without them.
@@ -365,7 +377,11 @@ export interface VoiceDataEvent {
365
377
  * server may relay a codec that table does not name.
366
378
  */
367
379
  readonly codec: number;
368
- /** One encoded frame, copied: it stays valid after the listener returns. */
380
+ /**
381
+ * One encoded frame, copied: it stays valid after the listener returns. Empty
382
+ * when the speaker stopped talking: it is the end of that speech, not a
383
+ * frame to decode.
384
+ */
369
385
  readonly data: Uint8Array;
370
386
  }
371
387
 
@@ -20,6 +20,12 @@ export type { NotificationResult } from "./notifications.js";
20
20
  export { parseIconID } from "./command/ids.js";
21
21
  export { responseSpec } from "./command/responses.js";
22
22
  export type { ResponseSpec } from "./command/responses.js";
23
- export { parseErrorCode, parseServerError } from "./errors.js";
23
+ export {
24
+ floodHoldMs,
25
+ FLOOD_RETRIES,
26
+ floodRetryDelayMs,
27
+ parseErrorCode,
28
+ parseServerError,
29
+ } from "./errors.js";
24
30
  export { splitCommandRows, parseUint64, parseUint16, parseInt10, sleep } from "./helpers.js";
25
31
  export { jitteredDelay, resolveBackoffPolicy } from "./reconnect.js";
@@ -81,7 +81,7 @@ export type NotificationResult =
81
81
  * client's own id (0 when it is not known yet), and `clients` the clients the
82
82
  * caller knows, which the enter, leave, move, update and channel group
83
83
  * notifications keep current and the others read: a text message carries no
84
- * groups, and a move of the client's own carries no invoker. An entry is
84
+ * groups, and a move carries no `cfid` on TS3. An entry is
85
85
  * replaced, never edited, so an event that holds one stays as it was.
86
86
  *
87
87
  * The server-group notifications leave the clients alone: the update that
@@ -214,22 +214,23 @@ function handleClientMoved(cmd: Command, clients: Map<number, ClientEntry>): Not
214
214
  const existing = clients.get(clid);
215
215
  if (existing) clients.set(clid, { ...existing, channelID: ctid });
216
216
 
217
- // A move the client made itself names no invoker, so the fields are empty;
218
- // the client it knows by `clid` is who that was.
219
- const invokerID = asClientID(parseUint16(cmd.params["invokerid"] ?? ""));
220
- const invoker = clients.get(invokerID === 0 ? clid : invokerID);
217
+ // Where the client came from: `cfid` when the server sends it, and when it
218
+ // does not (TS3 sends none on a move) the channel the roster had it in,
219
+ // which this notification is about to replace.
220
+ const from = cmd.params["cfid"] === undefined ? existing?.channelID : undefined;
221
221
 
222
+ // A move the client made itself (reason 0) names no invoker, and the fields
223
+ // are left empty: it is not filled in from the roster.
222
224
  return {
223
225
  kind: "clientMoved",
224
226
  event: {
225
227
  clientID: clid,
226
228
  targetChannelID: ctid,
227
229
  ...channelOf(cmd.params, "cfid", "fromChannelID"),
230
+ ...(from === undefined ? {} : { fromChannelID: from }),
228
231
  reason: parseInt10(cmd.params["reasonid"] ?? ""),
229
232
  reasonMessage: cmd.params["reasonmsg"] ?? "",
230
- invokerID,
231
- invokerName: cmd.params["invokername"] ?? invoker?.nickname ?? "",
232
- invokerUID: asUID(cmd.params["invokeruid"] ?? invoker?.uid ?? ""),
233
+ ...invoked(cmd.params),
233
234
  },
234
235
  };
235
236
  }