@honeybbq/teamspeak 1.0.0-beta.13 → 1.0.0-beta.14

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 (64) hide show
  1. package/README.md +19 -1
  2. package/dist/client/api/files.d.ts +89 -0
  3. package/dist/client/api/files.d.ts.map +1 -0
  4. package/dist/client/api/files.js +125 -0
  5. package/dist/client/api/files.js.map +1 -0
  6. package/dist/client/client.d.ts +2 -2
  7. package/dist/client/client.d.ts.map +1 -1
  8. package/dist/client/client.js.map +1 -1
  9. package/dist/client/index.d.ts +1 -0
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +1 -0
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/core/api/channels.d.ts +53 -1
  14. package/dist/core/api/channels.d.ts.map +1 -1
  15. package/dist/core/api/channels.js +64 -1
  16. package/dist/core/api/channels.js.map +1 -1
  17. package/dist/core/api/clients.d.ts +87 -1
  18. package/dist/core/api/clients.d.ts.map +1 -1
  19. package/dist/core/api/clients.js +95 -1
  20. package/dist/core/api/clients.js.map +1 -1
  21. package/dist/core/api/command.d.ts +40 -1
  22. package/dist/core/api/command.d.ts.map +1 -1
  23. package/dist/core/api/command.js +26 -1
  24. package/dist/core/api/command.js.map +1 -1
  25. package/dist/core/api/files.d.ts +39 -1
  26. package/dist/core/api/files.d.ts.map +1 -1
  27. package/dist/core/api/files.js +46 -1
  28. package/dist/core/api/files.js.map +1 -1
  29. package/dist/core/api/groups.d.ts +28 -3
  30. package/dist/core/api/groups.d.ts.map +1 -1
  31. package/dist/core/api/groups.js +32 -3
  32. package/dist/core/api/groups.js.map +1 -1
  33. package/dist/core/api/moderation.d.ts +36 -2
  34. package/dist/core/api/moderation.d.ts.map +1 -1
  35. package/dist/core/api/moderation.js +36 -3
  36. package/dist/core/api/moderation.js.map +1 -1
  37. package/dist/core/api/server.d.ts +94 -1
  38. package/dist/core/api/server.d.ts.map +1 -1
  39. package/dist/core/api/server.js +105 -1
  40. package/dist/core/api/server.js.map +1 -1
  41. package/dist/core/index.d.ts +1 -1
  42. package/dist/core/index.d.ts.map +1 -1
  43. package/dist/core/index.js.map +1 -1
  44. package/dist/query/client.d.ts.map +1 -1
  45. package/dist/query/client.js +76 -5
  46. package/dist/query/client.js.map +1 -1
  47. package/dist/query/late-rows.d.ts +31 -0
  48. package/dist/query/late-rows.d.ts.map +1 -0
  49. package/dist/query/late-rows.js +49 -0
  50. package/dist/query/late-rows.js.map +1 -0
  51. package/package.json +1 -1
  52. package/src/client/api/files.ts +249 -0
  53. package/src/client/client.ts +2 -2
  54. package/src/client/index.ts +1 -0
  55. package/src/core/api/channels.ts +122 -1
  56. package/src/core/api/clients.ts +167 -1
  57. package/src/core/api/command.ts +74 -1
  58. package/src/core/api/files.ts +72 -1
  59. package/src/core/api/groups.ts +64 -3
  60. package/src/core/api/moderation.ts +52 -3
  61. package/src/core/api/server.ts +194 -1
  62. package/src/core/index.ts +8 -1
  63. package/src/query/client.ts +82 -4
  64. package/src/query/late-rows.ts +64 -0
@@ -10,6 +10,8 @@ import {
10
10
  firstRow,
11
11
  exec,
12
12
  query,
13
+ queryRows,
14
+ continueOnErrorParams,
13
15
  list,
14
16
  encodeFields,
15
17
  nonEmpty,
@@ -22,7 +24,7 @@ import {
22
24
  durationMs,
23
25
  SECOND,
24
26
  } from "./command.js";
25
- import type { Params, WireNames } from "./command.js";
27
+ import type { BatchOptions, Params, WireNames } from "./command.js";
26
28
  import type { CommandExecutor, CommandOptions, ParamValue } from "../command/request.js";
27
29
  import type { BanID, ClientDBID, ClientID, MessageID, UID } from "../command/ids.js";
28
30
  import { asBanID, asClientDBID, asMessageID, asUID } from "../command/ids.js";
@@ -250,8 +252,11 @@ export async function addBan(
250
252
  *
251
253
  * Measured on TS3 3.13.8 and TS6 beta13.1: two bans, one for the uid and one
252
254
  * for the address. TS6 has also answered with no `banid` at all though both
253
- * bans were made (about once in a hundred calls over SSH and WebQuery), so an
254
- * empty result does not mean no ban. A caller that must undo the ban lists the
255
+ * bans were made (about once in a hundred calls over WebQuery), so an empty
256
+ * result does not mean no ban, and no lookup of the ids is made: a ban has no
257
+ * field that ties it to this call. Over SSH, TS6 may write the `banid` rows
258
+ * after the `error` line; the ServerQuery client waits briefly for them, so
259
+ * they are not lost to that. A caller that must undo the ban lists the
255
260
  * bans before and after and removes what is new: a ban on the address refuses
256
261
  * every later connection from it.
257
262
  */
@@ -269,6 +274,50 @@ export async function banClient(
269
274
  return rows.flatMap((row) => (str(row, "banid") === "" ? [] : [asBanID(big(row, "banid"))]));
270
275
  }
271
276
 
277
+ /** How {@link banClients} bans, and whether it goes on past a failure. */
278
+ export interface BanClientsOptions extends BanClientOptions, BatchOptions {}
279
+
280
+ /**
281
+ * Ban several connected clients by clid, in one command, such as
282
+ * `banclient time=60 clid=5|clid=6`, and return the id of every ban the server
283
+ * reports. With no ids nothing is sent and the result is `[]`.
284
+ *
285
+ * Each client costs the bans {@link banClient} describes; clients that share an
286
+ * address share one address ban, so two clients behind one address made three
287
+ * ids. The voice protocol reports no ids, so there this resolves to `[]` even
288
+ * when bans were made.
289
+ *
290
+ * The server reports one answer for the whole call, never one per client: the
291
+ * ids of the bans made, then success, or one error. Measured on TeamSpeak 3
292
+ * 3.13.8 and TeamSpeak 6 beta13.1, over raw ServerQuery, SSH, WebQuery and the
293
+ * voice protocol alike:
294
+ *
295
+ * - Without `continueOnError`, the server first checks that every id names a
296
+ * client: one that does not (512) refuses the call and nothing is banned.
297
+ * Past that, clients are banned in the order given and a failure for one
298
+ * (insufficient permissions, 2568) stops the call: the clients before it are
299
+ * banned, the ones after it are not, and the error is that client's.
300
+ * - With it (`-continueonerror`), a client that cannot be banned is skipped,
301
+ * the others are banned, the ids of the bans made are returned, and the
302
+ * answer is success even when none could be banned.
303
+ *
304
+ * A client that is banned is disconnected.
305
+ */
306
+ export async function banClients(
307
+ client: CommandExecutor,
308
+ clientIDs: readonly ClientID[],
309
+ options: BanClientsOptions = {},
310
+ ): Promise<BanID[]> {
311
+ const rows = await queryRows(
312
+ client,
313
+ "banclient",
314
+ [...continueOnErrorParams(options), ...encodeFields<BanClientOptions>(options, banClientNames)],
315
+ clientIDs.map((clid) => [["clid", clid]]),
316
+ options,
317
+ );
318
+ return rows.flatMap((row) => (str(row, "banid") === "" ? [] : [asBanID(big(row, "banid"))]));
319
+ }
320
+
272
321
  /** Remove a ban. */
273
322
  export async function deleteBan(
274
323
  client: CommandExecutor,
@@ -13,6 +13,8 @@ import {
13
13
  U16,
14
14
  firstRow,
15
15
  exec,
16
+ execRows,
17
+ continueOnErrorParams,
16
18
  query,
17
19
  list,
18
20
  encodeFields,
@@ -28,7 +30,13 @@ import {
28
30
  durationMs,
29
31
  SECOND,
30
32
  } from "./command.js";
31
- import type { PermissionEntry, WireNames } from "./command.js";
33
+ import type {
34
+ BatchOptions,
35
+ Params,
36
+ PermissionEntry,
37
+ PermissionValue,
38
+ WireNames,
39
+ } from "./command.js";
32
40
  import type { CommandExecutor, CommandOptions } from "../command/request.js";
33
41
  import type {
34
42
  ChannelID,
@@ -560,6 +568,67 @@ export async function getServerGroupsByClient(
560
568
  return rows.map((row) => ({ id: asServerGroupID(big(row, "sgid")), name: str(row, "name") }));
561
569
  }
562
570
 
571
+ /**
572
+ * Add several clients to a server group, in one command, such as
573
+ * `servergroupaddclient sgid=9 cldbid=3|cldbid=4`. With no ids nothing is sent.
574
+ *
575
+ * The server reports one answer for the whole call, never one per client.
576
+ * Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 beta13.1, over raw
577
+ * ServerQuery, SSH, WebQuery and the voice protocol alike, so this resolves to
578
+ * nothing and a failure says only what the server said:
579
+ *
580
+ * - Without `continueOnError`, the clients are added in the order given and
581
+ * the call stops at the first one that fails (an unknown database id, 512;
582
+ * a client already in the group, 2561): the clients before it are in the
583
+ * group, the ones after it are not, and the error is that client's.
584
+ * - With it (`-continueonerror`), every client is tried and each one that can
585
+ * be added is. The answer is success only if none failed; otherwise it is
586
+ * the error of the last one that failed, so two different failures show
587
+ * only the later. Read the members, as `listServerGroupClients` reports
588
+ * them, to know who was added.
589
+ */
590
+ export async function addClientsToServerGroup(
591
+ client: CommandExecutor,
592
+ sgid: ServerGroupID,
593
+ databaseIDs: readonly ClientDBID[],
594
+ options: BatchOptions = {},
595
+ ): Promise<void> {
596
+ await execRows(
597
+ client,
598
+ "servergroupaddclient",
599
+ [...continueOnErrorParams(options), ["sgid", sgid]],
600
+ databaseIDs.map((cldbid) => [["cldbid", cldbid]]),
601
+ options,
602
+ );
603
+ }
604
+
605
+ /**
606
+ * Remove several clients from a server group, in one command
607
+ * (`servergroupdelclient sgid=9 cldbid=3|cldbid=4`). With no ids nothing is
608
+ * sent.
609
+ *
610
+ * As for {@link addClientsToServerGroup}, the server reports one answer for
611
+ * the call. Without `continueOnError` the call stops at the first client that
612
+ * fails, with earlier clients removed and later ones not; with it, every client
613
+ * is tried and the answer is the error of the last that failed. A client that
614
+ * is not in the group fails with 2563 (empty result set), as does an unknown
615
+ * database id.
616
+ */
617
+ export async function removeClientsFromServerGroup(
618
+ client: CommandExecutor,
619
+ sgid: ServerGroupID,
620
+ databaseIDs: readonly ClientDBID[],
621
+ options: BatchOptions = {},
622
+ ): Promise<void> {
623
+ await execRows(
624
+ client,
625
+ "servergroupdelclient",
626
+ [...continueOnErrorParams(options), ["sgid", sgid]],
627
+ databaseIDs.map((cldbid) => [["cldbid", cldbid]]),
628
+ options,
629
+ );
630
+ }
631
+
563
632
  /** Permissions set on a server group. */
564
633
  export async function listServerGroupPermissions(
565
634
  client: CommandExecutor,
@@ -601,6 +670,84 @@ export async function addServerGroupPermission(
601
670
  );
602
671
  }
603
672
 
673
+ /** One permission to set on a server group, as {@link addServerGroupPermissions} takes it. */
674
+ export interface ServerGroupPermissionValue extends PermissionValue {
675
+ /** Whether the value is negated, so the lowest wins; false by default. Always sent (`permnegated`). */
676
+ readonly negated?: boolean | undefined;
677
+ /** Whether it skips channel and client overrides; false by default. Always sent (`permskip`). */
678
+ readonly skip?: boolean | undefined;
679
+ }
680
+
681
+ /**
682
+ * Set several permissions on a server group, in one command: each permission
683
+ * is a row of its own, with all its parameters. With none, nothing is sent.
684
+ *
685
+ * The server checks every row before it applies any: a permission it does not
686
+ * know (2562), a value it cannot read (1540) or a row missing a parameter
687
+ * (1539) refuses the call, and none is set, with or without `continueOnError`.
688
+ * Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 beta13.1, over raw
689
+ * ServerQuery, SSH, WebQuery and the voice protocol alike. Resolves to
690
+ * nothing: the server never reports an answer per row.
691
+ *
692
+ * A permission the caller has too little modify power for (2570, or 2568 where
693
+ * the group power is short) is another matter: the rows are applied in order.
694
+ * Without `continueOnError` the first row that fails ends the call, so the rows
695
+ * before it are set and the rows after it are not; with it the rows that fail
696
+ * are skipped and the others set. The error is the failing row's, and which row
697
+ * is not reported; with several failing rows which error comes back was not
698
+ * measured. Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 beta13.1, over raw
699
+ * ServerQuery (TeamSpeak 3), SSH, WebQuery and the voice protocol, in both
700
+ * orders of a free and a locked permission.
701
+ */
702
+ export async function addServerGroupPermissions(
703
+ client: CommandExecutor,
704
+ sgid: ServerGroupID,
705
+ permissions: readonly ServerGroupPermissionValue[],
706
+ options: BatchOptions = {},
707
+ ): Promise<void> {
708
+ await execRows(
709
+ client,
710
+ "servergroupaddperm",
711
+ [...continueOnErrorParams(options), ["sgid", sgid]],
712
+ permissions.map(serverGroupPermissionRow),
713
+ options,
714
+ );
715
+ }
716
+
717
+ const serverGroupPermissionRow = (entry: ServerGroupPermissionValue): Params => [
718
+ permissionParam(entry.permission),
719
+ ["permvalue", entry.value],
720
+ ["permnegated", entry.negated ?? false],
721
+ ["permskip", entry.skip ?? false],
722
+ ];
723
+
724
+ /**
725
+ * Remove several permissions from a server group, in one command: each
726
+ * permission is a row (`permsid=…|permsid=…`). With none, nothing is sent.
727
+ *
728
+ * As for {@link addServerGroupPermissions}, the server checks every row first
729
+ * and a permission it does not know (2562) refuses the call with none removed,
730
+ * with or without `continueOnError`. Removing a permission the group does not
731
+ * have succeeds. A permission the caller has too little modify power for
732
+ * (2570) ends the call at that row, so the rows before it are removed and the
733
+ * rows after it are not; with `continueOnError` that row is skipped and the
734
+ * others removed.
735
+ */
736
+ export async function deleteServerGroupPermissions(
737
+ client: CommandExecutor,
738
+ sgid: ServerGroupID,
739
+ permissions: readonly Permission[],
740
+ options: BatchOptions = {},
741
+ ): Promise<void> {
742
+ await execRows(
743
+ client,
744
+ "servergroupdelperm",
745
+ [...continueOnErrorParams(options), ["sgid", sgid]],
746
+ permissions.map((permission) => [permissionParam(permission)]),
747
+ options,
748
+ );
749
+ }
750
+
604
751
  /** Remove a permission from a server group. */
605
752
  export async function deleteServerGroupPermission(
606
753
  client: CommandExecutor,
@@ -643,6 +790,52 @@ export async function addServerGroupPermissionByType(
643
790
  );
644
791
  }
645
792
 
793
+ /**
794
+ * Set several permissions on every regular server group of a type, on every
795
+ * virtual server of the instance, in one command; see
796
+ * {@link addServerGroupPermissionByType} for what the type selects and what it
797
+ * needs. Each permission is a row of its own. With none, nothing is sent.
798
+ *
799
+ * The server documents no `-continueonerror` for this command, so none is
800
+ * offered. A permission it does not know (2562) refuses the call before any is
801
+ * set; a voice client gets error 2568, as for the single form.
802
+ */
803
+ export async function addServerGroupPermissionsByType(
804
+ client: CommandExecutor,
805
+ type: GroupAutoUpdateType,
806
+ permissions: readonly ServerGroupPermissionValue[],
807
+ options?: CommandOptions,
808
+ ): Promise<void> {
809
+ await execRows(
810
+ client,
811
+ "servergroupautoaddperm",
812
+ [["sgtype", type]],
813
+ permissions.map(serverGroupPermissionRow),
814
+ options,
815
+ );
816
+ }
817
+
818
+ /**
819
+ * Remove several permissions from every regular server group of a type, in one
820
+ * command; see {@link addServerGroupPermissionsByType}. With none, nothing is
821
+ * sent. Needs `b_permission_modify_power_ignore`: a voice client gets error
822
+ * 2568.
823
+ */
824
+ export async function deleteServerGroupPermissionsByType(
825
+ client: CommandExecutor,
826
+ type: GroupAutoUpdateType,
827
+ permissions: readonly Permission[],
828
+ options?: CommandOptions,
829
+ ): Promise<void> {
830
+ await execRows(
831
+ client,
832
+ "servergroupautodelperm",
833
+ [["sgtype", type]],
834
+ permissions.map((permission) => [permissionParam(permission)]),
835
+ options,
836
+ );
837
+ }
838
+
646
839
  /**
647
840
  * Remove a permission from every regular server group of a type, on every
648
841
  * virtual server of the instance; see {@link addServerGroupPermissionByType}.
package/src/core/index.ts CHANGED
@@ -150,5 +150,12 @@ export * from "./api/icons.js";
150
150
  export * from "./api/credentials.js";
151
151
  export * from "./api/instance.js";
152
152
  export { applyChannelChanges } from "./api/command.js";
153
- export type { Channel, ChannelChanges, PagingOptions, PermissionEntry } from "./api/command.js";
153
+ export type {
154
+ BatchOptions,
155
+ Channel,
156
+ ChannelChanges,
157
+ PagingOptions,
158
+ PermissionEntry,
159
+ PermissionValue,
160
+ } from "./api/command.js";
154
161
  export { ChannelTree } from "./channel-tree.js";
@@ -39,6 +39,7 @@ import { connectTcp } from "./tcp.js";
39
39
  import { connectSsh } from "./ssh.js";
40
40
  import { WebQueryTransport } from "./web.js";
41
41
  import { GuestAccessError, QueryAuthError } from "./errors.js";
42
+ import { LATE_ROW_GRACE_MS, LATE_ROW_QUIET_MS, hasLateRow, lateRowsOf } from "./late-rows.js";
42
43
  import {
43
44
  assertEventsSupported,
44
45
  normalizeQueryOptions,
@@ -103,6 +104,16 @@ interface Pending {
103
104
  readonly rows: Array<Record<string, string>>;
104
105
  resolve(rows: Array<Record<string, string>>): void;
105
106
  reject(error: unknown): void;
107
+ /** Set once the `error` line has been read: what follows is a late row or nothing. */
108
+ held?: {
109
+ readonly keys: readonly string[];
110
+ readonly several: boolean;
111
+ readonly any: boolean;
112
+ timer: ReturnType<typeof setTimeout>;
113
+ /** Wait for another row, or for the rows to stop. */
114
+ wait(ms: number): void;
115
+ finish(): void;
116
+ };
106
117
  }
107
118
 
108
119
  /**
@@ -132,6 +143,12 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
132
143
  #version = "";
133
144
 
134
145
  #pending: Pending | null = null;
146
+ /**
147
+ * The key of a row that was waited for and did not come before its answer
148
+ * was given up on. Should it arrive later, it belongs to that command and is
149
+ * dropped rather than handed to the next one.
150
+ */
151
+ #strayKey: string | undefined;
135
152
  /**
136
153
  * Answers still to come for commands that gave up waiting. ServerQuery has
137
154
  * no return_code, only order, so each is read and discarded when it arrives
@@ -392,6 +409,7 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
392
409
  this.#closed = false;
393
410
  this.#abandoned = 0;
394
411
  this.#pending = null;
412
+ this.#strayKey = undefined;
395
413
  this.#clients.clear();
396
414
  this.#clid = 0;
397
415
  this.#serverFamily = "unknown";
@@ -933,8 +951,12 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
933
951
  clearTimeout(nudger);
934
952
  if (onAbort) signal?.removeEventListener("abort", onAbort);
935
953
  if (this.#pending !== null) {
954
+ const gone = this.#pending;
936
955
  this.#pending = null;
937
- if (!sending.failed) {
956
+ if (gone.held) {
957
+ // Its `error` line was read already, so no answer is still to come.
958
+ clearTimeout(gone.held.timer);
959
+ } else if (!sending.failed) {
938
960
  this.#abandoned++;
939
961
  abandonedByUs = true;
940
962
  }
@@ -957,9 +979,49 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
957
979
  this.#pending = null;
958
980
  if (!pending) return;
959
981
  const { err } = parseServerError(cmd.params);
982
+ this.#strayKey = undefined;
960
983
  const refusal = err ?? transferRefusal(pending.command, pending.rows);
961
- if (refusal) pending.reject(refusal);
962
- else pending.resolve(pending.rows);
984
+ if (refusal) {
985
+ pending.reject(refusal);
986
+ return;
987
+ }
988
+ const late = this.#options.transport.kind === "web" ? undefined : lateRowsOf(pending.command);
989
+ const hasRow = late !== undefined && hasLateRow(late, pending.rows);
990
+ if (late === undefined || (hasRow && late.several !== true)) {
991
+ pending.resolve(pending.rows);
992
+ return;
993
+ }
994
+ // A create that succeeded, with its row not here yet or perhaps not all
995
+ // of them: the server may write them just after this line. Wait briefly,
996
+ // with the command still the one on the wire, so the rows are its own and
997
+ // the next command is not sent before them.
998
+ this.#pending = pending;
999
+ const finish = (): void => {
1000
+ clearTimeout(pending.held?.timer);
1001
+ if (this.#pending === pending) this.#pending = null;
1002
+ pending.resolve(pending.rows);
1003
+ };
1004
+ const held: NonNullable<Pending["held"]> = {
1005
+ keys: late.keys,
1006
+ several: late.several === true,
1007
+ any: late.any === true,
1008
+ timer: setTimeout(() => {}, 0),
1009
+ finish,
1010
+ wait: (ms) => {
1011
+ clearTimeout(held.timer);
1012
+ held.timer = setTimeout(() => {
1013
+ // Given up on, with no row at all: one that still comes is not the
1014
+ // next command's.
1015
+ const got = hasLateRow(late, pending.rows);
1016
+ if (!got && this.#pending === pending && held.keys[0] !== undefined) {
1017
+ this.#strayKey = held.keys[0];
1018
+ }
1019
+ finish();
1020
+ }, ms);
1021
+ },
1022
+ };
1023
+ pending.held = held;
1024
+ held.wait(hasRow || late.any === true ? LATE_ROW_QUIET_MS : LATE_ROW_GRACE_MS);
963
1025
  return;
964
1026
  }
965
1027
  if (cmd.name.startsWith("notify")) {
@@ -973,7 +1035,23 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
973
1035
  if (this.#abandoned > 0) return;
974
1036
  for (const row of splitRows(trimmed)) {
975
1037
  const parsed = parseCommand(row);
976
- if (parsed) this.#pending?.rows.push(parsed.params);
1038
+ if (!parsed) continue;
1039
+ const keys = Object.keys(parsed.params);
1040
+ if (this.#strayKey !== undefined && keys.length === 1 && keys[0] === this.#strayKey) {
1041
+ const own = this.#pending === null ? undefined : lateRowsOf(this.#pending.command);
1042
+ this.#strayKey = undefined;
1043
+ // The late row of an earlier command, which gave up waiting for it. A
1044
+ // command whose own answer carries that key keeps it: the two cannot be
1045
+ // told apart, and lateness was measured at under a millisecond against
1046
+ // a wait of 250, so it almost certainly belongs to the command asking.
1047
+ if (!own?.keys.includes(keys[0])) continue;
1048
+ }
1049
+ this.#pending?.rows.push(parsed.params);
1050
+ const held = this.#pending?.held;
1051
+ if (held && (held.any || keys.some((k) => held.keys.includes(k)))) {
1052
+ if (held.several || held.any) held.wait(LATE_ROW_QUIET_MS);
1053
+ else held.finish();
1054
+ }
977
1055
  }
978
1056
  }
979
1057
 
@@ -0,0 +1,64 @@
1
+ /** The row a command's answer carries, and whether it carries several. */
2
+ export interface LateRows {
3
+ /** The keys the row is known by. */
4
+ readonly keys: readonly string[];
5
+ /**
6
+ * The answer has one row for each of several things it made, and all, some or
7
+ * none of them may follow the `error` line, so it is held until the rows stop.
8
+ */
9
+ readonly several?: boolean;
10
+ /** Any row is the answer: a read, which has no one key. */
11
+ readonly any?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Commands whose answer carries the row that names what they made, and the key
16
+ * that row is known by.
17
+ *
18
+ * TeamSpeak 6 over SSH has been measured to write that row after the command's
19
+ * `error` line, a fraction of a millisecond later, in a share of the answers.
20
+ * A client that takes the `error` line for the end of the answer loses the row
21
+ * and, worse, hands it to whichever command is sent next.
22
+ */
23
+ export const LATE_ROWS: ReadonlyMap<string, LateRows> = new Map<string, LateRows>([
24
+ ["channelcreate", { keys: ["cid"] }],
25
+ ["servergroupadd", { keys: ["sgid"] }],
26
+ ["servergroupcopy", { keys: ["sgid"] }],
27
+ ["channelgroupadd", { keys: ["cgid"] }],
28
+ ["channelgroupcopy", { keys: ["cgid"] }],
29
+ ["banadd", { keys: ["banid"] }],
30
+ ["banclient", { keys: ["banid"], several: true }],
31
+ ["privilegekeyadd", { keys: ["token"] }],
32
+ ["tokenadd", { keys: ["token"] }],
33
+ ["apikeyadd", { keys: ["apikey"] }],
34
+ ["servercreate", { keys: ["sid"] }],
35
+ ]);
36
+
37
+ /** How long a successful answer without its row is held open for the row to arrive. */
38
+ export const LATE_ROW_GRACE_MS = 250;
39
+
40
+ /** For an answer with several rows: how long without a new one counts as its end. */
41
+ export const LATE_ROW_QUIET_MS = 25;
42
+
43
+ /**
44
+ * Commands that read something, by their names: a `list`, `info` or `find`, a
45
+ * `get…`, `whoami`, `version`. A plain read has been measured to have its whole
46
+ * answer after the `error` line too, much less often than a create (2 in 3000
47
+ * `clientlist` calls, while clients were connecting), so one that is answered
48
+ * with no row is held briefly as well. An empty success is rare for these: the
49
+ * servers answer an empty list with error 1281.
50
+ */
51
+ const READS = /(list|info|find|search)$|^(get\w+|whoami|version|permfind|permoverview|permget)$/;
52
+
53
+ /** What is known of where a command's answer rows may fall, or `undefined` for none. */
54
+ export function lateRowsOf(command: string): LateRows | undefined {
55
+ const known = LATE_ROWS.get(command);
56
+ if (known !== undefined) return known;
57
+ return READS.test(command) ? { keys: [], any: true } : undefined;
58
+ }
59
+
60
+ /** Whether `rows` already hold what {@link lateRowsOf} says to wait for. */
61
+ export function hasLateRow(late: LateRows, rows: ReadonlyArray<Record<string, string>>): boolean {
62
+ if (late.any === true) return rows.length > 0;
63
+ return rows.some((row) => late.keys.some((k) => row[k] !== undefined));
64
+ }