@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
@@ -12,6 +12,8 @@ import {
12
12
  nonZero,
13
13
  firstRow,
14
14
  exec,
15
+ execRows,
16
+ continueOnErrorParams,
15
17
  query,
16
18
  list,
17
19
  parseChannel,
@@ -22,7 +24,13 @@ import {
22
24
  big,
23
25
  str,
24
26
  } from "./command.js";
25
- import type { Channel, PermissionEntry, WireNames } from "./command.js";
27
+ import type {
28
+ BatchOptions,
29
+ Channel,
30
+ PermissionEntry,
31
+ PermissionValue,
32
+ WireNames,
33
+ } from "./command.js";
26
34
  import type { CommandExecutor, CommandOptions } from "../command/request.js";
27
35
  import type { ChannelID, ClientDBID } from "../command/ids.js";
28
36
  import { asChannelID } from "../command/ids.js";
@@ -354,6 +362,71 @@ export async function addChannelPermission(
354
362
  );
355
363
  }
356
364
 
365
+ /**
366
+ * Set several permissions on a channel, in one command: each permission is a
367
+ * row (`permsid=… permvalue=…|permsid=… permvalue=…`). With none, nothing is
368
+ * sent.
369
+ *
370
+ * The server checks every row before it applies any: a permission it does not
371
+ * know (2562) or a value it cannot read (1540) refuses the call, and none is
372
+ * set, with or without `continueOnError`. Measured on TeamSpeak 3 3.13.8 and
373
+ * TeamSpeak 6 beta13.1, over raw ServerQuery, SSH, WebQuery and the voice
374
+ * protocol alike. Resolves to nothing: the server never reports an answer per
375
+ * row.
376
+ *
377
+ * A permission the caller has too little modify power for (2570, or 2568 where
378
+ * the group power is short) is another matter: the rows are applied in order.
379
+ * Without `continueOnError` the first row that fails ends the call, so the rows
380
+ * before it are set and the rows after it are not; with it the rows that fail
381
+ * are skipped and the others set. The error is the failing row's, and which row
382
+ * is not reported; with several failing rows which error comes back was not
383
+ * measured. Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 beta13.1, over raw
384
+ * ServerQuery (TeamSpeak 3), SSH, WebQuery and the voice protocol, in both
385
+ * orders of a free and a locked permission.
386
+ */
387
+ export async function addChannelPermissions(
388
+ client: CommandExecutor,
389
+ cid: ChannelID,
390
+ permissions: readonly PermissionValue[],
391
+ options: BatchOptions = {},
392
+ ): Promise<void> {
393
+ await execRows(
394
+ client,
395
+ "channeladdperm",
396
+ [...continueOnErrorParams(options), ["cid", cid]],
397
+ permissions.map((entry) => [permissionParam(entry.permission), ["permvalue", entry.value]]),
398
+ options,
399
+ );
400
+ }
401
+
402
+ /**
403
+ * Remove several permissions from a channel, in one command: each permission is
404
+ * a row (`permsid=…|permsid=…`). With none, nothing is sent.
405
+ *
406
+ * The server checks every row first, and an unknown permission (2562) refuses
407
+ * the call with none removed. Removing a permission the channel does not have
408
+ * succeeds. A permission the caller has too little modify power for (2570) ends
409
+ * the call at that row: the rows before it are removed and the rows after it
410
+ * are not, measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 beta13.1 over every
411
+ * query transport and the voice protocol. The server documents no
412
+ * `-continueonerror` for this command, so none is offered, and sent anyway it
413
+ * changed nothing.
414
+ */
415
+ export async function deleteChannelPermissions(
416
+ client: CommandExecutor,
417
+ cid: ChannelID,
418
+ permissions: readonly Permission[],
419
+ options?: CommandOptions,
420
+ ): Promise<void> {
421
+ await execRows(
422
+ client,
423
+ "channeldelperm",
424
+ [["cid", cid]],
425
+ permissions.map((permission) => [permissionParam(permission)]),
426
+ options,
427
+ );
428
+ }
429
+
357
430
  /** Remove a permission from a channel. */
358
431
  export async function deleteChannelPermission(
359
432
  client: CommandExecutor,
@@ -409,6 +482,54 @@ export async function addChannelClientPermission(
409
482
  );
410
483
  }
411
484
 
485
+ /**
486
+ * Set several permissions on one client within one channel, in one command:
487
+ * each permission is a row. With none, nothing is sent. The server's answer
488
+ * is as for {@link addChannelPermissions}: an unknown permission or a value it
489
+ * cannot read refuses the call with none set, and a row the caller lacks the
490
+ * power for ends it there, or is skipped with `continueOnError`.
491
+ */
492
+ export async function addChannelClientPermissions(
493
+ client: CommandExecutor,
494
+ cid: ChannelID,
495
+ cldbid: ClientDBID,
496
+ permissions: readonly PermissionValue[],
497
+ options: BatchOptions = {},
498
+ ): Promise<void> {
499
+ await execRows(
500
+ client,
501
+ "channelclientaddperm",
502
+ [...continueOnErrorParams(options), ["cid", cid], ["cldbid", cldbid]],
503
+ permissions.map((entry) => [permissionParam(entry.permission), ["permvalue", entry.value]]),
504
+ options,
505
+ );
506
+ }
507
+
508
+ /**
509
+ * Remove several permissions from one client within one channel, in one
510
+ * command. With none, nothing is sent. The server's answer is as for
511
+ * {@link deleteChannelPermissions}, and it documents no `-continueonerror` for
512
+ * this command either.
513
+ */
514
+ export async function deleteChannelClientPermissions(
515
+ client: CommandExecutor,
516
+ cid: ChannelID,
517
+ cldbid: ClientDBID,
518
+ permissions: readonly Permission[],
519
+ options?: CommandOptions,
520
+ ): Promise<void> {
521
+ await execRows(
522
+ client,
523
+ "channelclientdelperm",
524
+ [
525
+ ["cid", cid],
526
+ ["cldbid", cldbid],
527
+ ],
528
+ permissions.map((permission) => [permissionParam(permission)]),
529
+ options,
530
+ );
531
+ }
532
+
412
533
  /** Remove a permission from one client within one channel. */
413
534
  export async function deleteChannelClientPermission(
414
535
  client: CommandExecutor,
@@ -12,6 +12,8 @@ import {
12
12
  U16,
13
13
  firstRow,
14
14
  exec,
15
+ execRows,
16
+ continueOnErrorParams,
15
17
  query,
16
18
  list,
17
19
  pages,
@@ -25,7 +27,14 @@ import {
25
27
  optNum,
26
28
  date,
27
29
  } from "./command.js";
28
- import type { PagingOptions, Params, PermissionEntry, WireNames } from "./command.js";
30
+ import type {
31
+ BatchOptions,
32
+ PagingOptions,
33
+ Params,
34
+ PermissionEntry,
35
+ PermissionValue,
36
+ WireNames,
37
+ } from "./command.js";
29
38
  import type { CommandExecutor, CommandOptions } from "../command/request.js";
30
39
  import type {
31
40
  ChannelGroupID,
@@ -419,6 +428,43 @@ export async function moveClient(
419
428
  );
420
429
  }
421
430
 
431
+ /** How {@link moveClients} enters the channel. */
432
+ export interface ClientsMoveOptions extends ClientMoveOptions, BatchOptions {}
433
+
434
+ /**
435
+ * Move several clients to one channel, in one command, such as
436
+ * `clientmove clid=5|clid=6 cid=3`. With no ids nothing is sent.
437
+ *
438
+ * The server reports one answer for the whole call, never one per client, so
439
+ * this resolves to nothing and a failure says only what the server said.
440
+ * Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 beta13.1, over raw
441
+ * ServerQuery, SSH, WebQuery and the voice protocol alike:
442
+ *
443
+ * - Without `continueOnError`, the server checks every client first: one id it
444
+ * cannot move (no such client, error 512; a client already in the channel,
445
+ * 770) refuses the call, and no client is moved.
446
+ * - With it (`-continueonerror`), a client that cannot be moved is skipped, the
447
+ * others are moved, and the answer is success, even when none could be moved.
448
+ * Read the channel back, as `listClients` reports it, to know who moved.
449
+ *
450
+ * A channel that does not exist fails the call either way (768).
451
+ */
452
+ export async function moveClients(
453
+ client: CommandExecutor,
454
+ clientIDs: readonly ClientID[],
455
+ channelID: ChannelID,
456
+ options: ClientsMoveOptions = {},
457
+ ): Promise<void> {
458
+ await execRows(
459
+ client,
460
+ "clientmove",
461
+ [...continueOnErrorParams(options), ["cid", channelID], ["cpw", options.password]],
462
+ clientIDs.map((clid) => [["clid", clid]]),
463
+ options,
464
+ ["cpw"],
465
+ );
466
+ }
467
+
422
468
  /** The reasons a client can be kicked for. */
423
469
  export type KickReason = typeof Reason.KickChannel | typeof Reason.KickServer;
424
470
 
@@ -456,6 +502,51 @@ export async function kickClient(
456
502
  );
457
503
  }
458
504
 
505
+ /** What {@link kickClients} tells the kicked clients, and whether it goes on past a failure. */
506
+ export interface ClientsKickOptions extends ClientKickOptions, BatchOptions {}
507
+
508
+ /**
509
+ * Kick several clients for one reason, in one command, such as
510
+ * `clientkick reasonid=4 clid=5|clid=6`. The reason is as for
511
+ * {@link kickClient}; with no ids nothing is sent.
512
+ *
513
+ * The server reports one answer for the whole call, never one per client, so
514
+ * this resolves to nothing and a failure says only what the server said.
515
+ * Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 beta13.1, over raw
516
+ * ServerQuery, SSH, WebQuery and the voice protocol alike:
517
+ *
518
+ * - Without `continueOnError`, the clients are kicked in the order given and
519
+ * the call stops at the first one that fails (an id no client has, 512):
520
+ * the clients before it are kicked, the ones after it are not, and the
521
+ * error is that client's.
522
+ * - With it (`-continueonerror`), every client is tried, a failing one is
523
+ * skipped, and the answer is success, even when none could be kicked. Read
524
+ * the clients back, as `listClients` reports them, to know who was kicked.
525
+ */
526
+ export async function kickClients(
527
+ client: CommandExecutor,
528
+ clientIDs: readonly ClientID[],
529
+ reason: KickReason,
530
+ options: ClientsKickOptions = {},
531
+ ): Promise<void> {
532
+ if (reason !== Reason.KickChannel && reason !== Reason.KickServer) {
533
+ throw new RangeError(
534
+ `a kick's reason is Reason.KickChannel or Reason.KickServer, not ${String(reason)}`,
535
+ );
536
+ }
537
+ await execRows(
538
+ client,
539
+ "clientkick",
540
+ [
541
+ ...continueOnErrorParams(options),
542
+ ["reasonid", reason],
543
+ ["reasonmsg", nonEmpty(options.reasonMessage ?? "")],
544
+ ],
545
+ clientIDs.map((clid) => [["clid", clid]]),
546
+ options,
547
+ );
548
+ }
549
+
459
550
  /** Send a poke to a client. */
460
551
  export async function pokeClient(
461
552
  client: CommandExecutor,
@@ -1049,6 +1140,53 @@ export async function addClientPermission(
1049
1140
  );
1050
1141
  }
1051
1142
 
1143
+ /** One permission to set on a client, server-wide, as {@link addClientPermissions} takes it. */
1144
+ export interface ClientPermissionValue extends PermissionValue {
1145
+ /** Whether it skips channel and client overrides; false by default. Always sent (`permskip`). */
1146
+ readonly skip?: boolean | undefined;
1147
+ }
1148
+
1149
+ /**
1150
+ * Set several permissions on a client, server-wide, in one command: each
1151
+ * permission is a row (`permsid=… permvalue=… permskip=0|…`). With none,
1152
+ * nothing is sent.
1153
+ *
1154
+ * The server checks every row before it applies any: a permission it does not
1155
+ * know (2562) or a value it cannot read (1540) refuses the call, and none is
1156
+ * set, with or without `continueOnError`. Measured on TeamSpeak 3 3.13.8 and
1157
+ * TeamSpeak 6 beta13.1, over raw ServerQuery, SSH, WebQuery and the voice
1158
+ * protocol alike. Resolves to nothing: the server never reports one answer
1159
+ * per row.
1160
+ *
1161
+ * A permission the caller has too little modify power for (2570, or 2568 where
1162
+ * the group power is short) is another matter: the rows are applied in order.
1163
+ * Without `continueOnError` the first row that fails ends the call, so the rows
1164
+ * before it are set and the rows after it are not; with it the rows that fail
1165
+ * are skipped and the others set. The error is the failing row's, and which row
1166
+ * is not reported; with several failing rows which error comes back was not
1167
+ * measured. Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 beta13.1, over raw
1168
+ * ServerQuery (TeamSpeak 3), SSH, WebQuery and the voice protocol, in both
1169
+ * orders of a free and a locked permission.
1170
+ */
1171
+ export async function addClientPermissions(
1172
+ client: CommandExecutor,
1173
+ cldbid: ClientDBID,
1174
+ permissions: readonly ClientPermissionValue[],
1175
+ options: BatchOptions = {},
1176
+ ): Promise<void> {
1177
+ await execRows(
1178
+ client,
1179
+ "clientaddperm",
1180
+ [...continueOnErrorParams(options), ["cldbid", cldbid]],
1181
+ permissions.map((entry) => [
1182
+ permissionParam(entry.permission),
1183
+ ["permvalue", entry.value],
1184
+ ["permskip", entry.skip ?? false],
1185
+ ]),
1186
+ options,
1187
+ );
1188
+ }
1189
+
1052
1190
  /** Remove a permission from a client. */
1053
1191
  export async function deleteClientPermission(
1054
1192
  client: CommandExecutor,
@@ -1059,6 +1197,34 @@ export async function deleteClientPermission(
1059
1197
  await exec(client, "clientdelperm", [["cldbid", cldbid], permissionParam(permission)], options);
1060
1198
  }
1061
1199
 
1200
+ /**
1201
+ * Remove several permissions from a client, in one command: each permission is
1202
+ * a row (`permsid=…|permsid=…`). With none, nothing is sent.
1203
+ *
1204
+ * The server checks every row before it removes any: one permission it does
1205
+ * not know (2562) refuses the call and none is removed. Removing a permission
1206
+ * the client does not have succeeds. A permission the caller has too little
1207
+ * modify power for (2570) ends the call at that row: the rows before it are
1208
+ * removed and the rows after it are not, measured on TeamSpeak 3 3.13.8 and
1209
+ * TeamSpeak 6 beta13.1 over every query transport and the voice protocol. The
1210
+ * server documents no `-continueonerror` for this command, so none is offered,
1211
+ * and sent anyway it changed nothing.
1212
+ */
1213
+ export async function deleteClientPermissions(
1214
+ client: CommandExecutor,
1215
+ cldbid: ClientDBID,
1216
+ permissions: readonly Permission[],
1217
+ options?: CommandOptions,
1218
+ ): Promise<void> {
1219
+ await execRows(
1220
+ client,
1221
+ "clientdelperm",
1222
+ [["cldbid", cldbid]],
1223
+ permissions.map((permission) => [permissionParam(permission)]),
1224
+ options,
1225
+ );
1226
+ }
1227
+
1062
1228
  // ---- Custom properties --------------------------------------------------------
1063
1229
 
1064
1230
  /** One custom property of a client: a key and a value, both free text. */
@@ -54,6 +54,47 @@ function commandOptions(options: CommandOptions | undefined): CommandOptions | u
54
54
  return { signal: options.signal, timeoutMs: options.timeoutMs };
55
55
  }
56
56
 
57
+ /**
58
+ * Send a command that takes a list and produces no data: `params` are the
59
+ * parameters of the first row, and every entry of `rows` is one more row of
60
+ * the list (`a=1 b=2|b=3`). With no rows, nothing is sent.
61
+ */
62
+ async function execRows(
63
+ client: CommandExecutor,
64
+ command: string,
65
+ params: Params,
66
+ rows: readonly Params[],
67
+ options?: CommandOptions,
68
+ passwords: readonly string[] = [],
69
+ ): Promise<void> {
70
+ if (rows.length === 0) return;
71
+ await client.send({ command, params, rows, passwords }, commandOptions(options));
72
+ }
73
+
74
+ /**
75
+ * Send a list command that answers with rows, as {@link execRows} sends one.
76
+ * With no rows nothing is sent, and the answer is empty.
77
+ */
78
+ async function queryRows(
79
+ client: CommandExecutor,
80
+ command: string,
81
+ params: Params,
82
+ rows: readonly Params[],
83
+ options?: CommandOptions,
84
+ ): Promise<Array<Record<string, string>>> {
85
+ if (rows.length === 0) return [];
86
+ return client.send({ command, params, rows, passwords: [] }, commandOptions(options));
87
+ }
88
+
89
+ /**
90
+ * The `-continueonerror` switch, as a command's first parameter: the server
91
+ * reads it from the first row only, so a switch written after the last row
92
+ * does nothing.
93
+ */
94
+ const continueOnErrorParams = (options: {
95
+ readonly continueOnError?: boolean | undefined;
96
+ }): Params => (options.continueOnError === true ? [["-continueonerror", true]] : []);
97
+
57
98
  /** Send a command and return its response rows. */
58
99
  async function query(
59
100
  client: CommandExecutor,
@@ -187,7 +228,17 @@ function firstRow(
187
228
  return row;
188
229
  }
189
230
 
190
- export { exec, query, list, pages, commandOptions, firstRow };
231
+ export {
232
+ exec,
233
+ execRows,
234
+ query,
235
+ queryRows,
236
+ list,
237
+ pages,
238
+ commandOptions,
239
+ continueOnErrorParams,
240
+ firstRow,
241
+ };
191
242
  export type { ParamValue };
192
243
 
193
244
  // ---- Field tables -----------------------------------------------------------
@@ -722,6 +773,28 @@ function parseChannelChanges(row: Record<string, string>): ChannelChanges {
722
773
 
723
774
  export { parseChannel, parseChannelChanges };
724
775
 
776
+ /**
777
+ * What a command that takes a list of options carries beside the usual signal
778
+ * and timeout: whether the server keeps going past an item that fails.
779
+ */
780
+ export interface BatchOptions extends CommandOptions {
781
+ /**
782
+ * Send `-continueonerror`, which the server documents for this command: it
783
+ * processes every item and skips the ones that fail, instead of stopping at
784
+ * the first. What it then answers differs by command, and each function says.
785
+ * False by default.
786
+ */
787
+ readonly continueOnError?: boolean | undefined;
788
+ }
789
+
790
+ /** One permission to set, with its value: an entry of a batch that adds permissions. */
791
+ export interface PermissionValue {
792
+ /** The permission to set. */
793
+ readonly permission: Permission;
794
+ /** Its value (`permvalue`). */
795
+ readonly value: number;
796
+ }
797
+
725
798
  /** One entry of a permission list. */
726
799
  export interface PermissionEntry {
727
800
  /**
@@ -96,6 +96,73 @@ export async function listFiles(
96
96
  return rows.map(parseFileEntry);
97
97
  }
98
98
 
99
+ /** One entry of a recursive listing: what {@link listFiles} reports, with where it is. */
100
+ export interface FileTreeEntry extends FileEntry {
101
+ /**
102
+ * The entry's full path in the channel's file area, from its root: `/a/b.txt`
103
+ * for the file `b.txt` in the directory `a`. A directory's path has no
104
+ * trailing slash.
105
+ */
106
+ readonly path: string;
107
+ }
108
+
109
+ /** How {@link listFilesRecursive} walks the file area. */
110
+ export interface FileTreeOptions extends FileOptions {
111
+ /**
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.
115
+ */
116
+ readonly maxDepth?: number | undefined;
117
+ }
118
+
119
+ /** A path without the slashes at its end; "/" becomes "". */
120
+ function withoutTrailingSlashes(path: string): string {
121
+ let end = path.length;
122
+ while (end > 0 && path.charCodeAt(end - 1) === 0x2f) end--;
123
+ return path.slice(0, end);
124
+ }
125
+
126
+ /** How far {@link listFilesRecursive} goes down unless told otherwise. */
127
+ const DEFAULT_MAX_DEPTH = 16;
128
+
129
+ /**
130
+ * Every file and directory under a path in a channel's file area, in one list:
131
+ * the entries of the directory in the order {@link listFiles} gives them, each
132
+ * directory followed by what it holds.
133
+ *
134
+ * It lists one directory at a time, one command each, so a large tree costs
135
+ * that many round trips. `maxDepth` bounds how far it goes down; a directory
136
+ * at the bound is listed, but not entered. The options (password, signal,
137
+ * timeoutMs) reach every command, and the first error ends the walk. An empty
138
+ * directory has no entries, and a path that is not a directory fails as
139
+ * {@link listFiles} does: TS3 3.13.8 answers 2052 and TS6 beta13.1 2051 for a
140
+ * path that does not exist.
141
+ *
142
+ * File commands are not served over WebQuery (error 5120).
143
+ */
144
+ export async function listFilesRecursive(
145
+ client: CommandExecutor,
146
+ cid: ChannelID,
147
+ path: string,
148
+ options: FileTreeOptions = {},
149
+ ): Promise<FileTreeEntry[]> {
150
+ 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)}`);
153
+ }
154
+ const found: FileTreeEntry[] = [];
155
+ const walk = async (directory: string, depth: number): Promise<void> => {
156
+ for (const entry of await listFiles(client, cid, directory, listOptions)) {
157
+ const entryPath = `${withoutTrailingSlashes(directory)}/${entry.name}`;
158
+ found.push({ ...entry, path: entryPath });
159
+ if (entry.isDirectory && depth < maxDepth) await walk(entryPath, depth + 1);
160
+ }
161
+ };
162
+ await walk(asPath(path), 0);
163
+ return found;
164
+ }
165
+
99
166
  /** Size and timestamp of one file. */
100
167
  export async function getFileInfo(
101
168
  client: CommandExecutor,
@@ -144,7 +211,11 @@ export async function deleteFile(
144
211
  );
145
212
  }
146
213
 
147
- /** Create a directory, including any missing parents. */
214
+ /**
215
+ * Create a directory. Its parent has to exist: measured on TS3 3.13.8 and TS6
216
+ * beta13.1, a path whose parent is missing is refused with 2052 (file
217
+ * input/output error), so nested directories are made one level at a time.
218
+ */
148
219
  export async function createDirectory(
149
220
  client: CommandExecutor,
150
221
  cid: ChannelID,
@@ -8,6 +8,8 @@ import {
8
8
  U32,
9
9
  firstRow,
10
10
  exec,
11
+ execRows,
12
+ continueOnErrorParams,
11
13
  query,
12
14
  list,
13
15
  encodeFields,
@@ -21,7 +23,13 @@ import {
21
23
  bool,
22
24
  date,
23
25
  } from "./command.js";
24
- import type { Params, PermissionEntry, WireNames } from "./command.js";
26
+ import type {
27
+ BatchOptions,
28
+ Params,
29
+ PermissionEntry,
30
+ PermissionValue,
31
+ WireNames,
32
+ } from "./command.js";
25
33
  import type { CommandExecutor, CommandOptions } from "../command/request.js";
26
34
  import type {
27
35
  ChannelGroupID,
@@ -267,6 +275,55 @@ export async function addChannelGroupPermission(
267
275
  );
268
276
  }
269
277
 
278
+ /**
279
+ * Set several permissions on a channel group, in one command: each permission
280
+ * is a row. With none, nothing is sent. The server's answer is as for
281
+ * {@link addChannelPermissions}: an unknown permission or a value it cannot read
282
+ * refuses the call with none set, with or without `continueOnError`, and a row
283
+ * the caller lacks the power for ends it there, or is skipped with
284
+ * `continueOnError`.
285
+ */
286
+ export async function addChannelGroupPermissions(
287
+ client: CommandExecutor,
288
+ cgid: ChannelGroupID,
289
+ permissions: readonly PermissionValue[],
290
+ options: BatchOptions = {},
291
+ ): Promise<void> {
292
+ await execRows(
293
+ client,
294
+ "channelgroupaddperm",
295
+ [...continueOnErrorParams(options), ["cgid", cgid]],
296
+ permissions.map((entry) => [permissionParam(entry.permission), ["permvalue", entry.value]]),
297
+ options,
298
+ );
299
+ }
300
+
301
+ /**
302
+ * Remove several permissions from a channel group, in one command: each
303
+ * permission is a row. With none, nothing is sent. An unknown permission (2562)
304
+ * is found first and leaves none removed, with or without `continueOnError`.
305
+ * Removing a permission the group does not have succeeds. A permission the
306
+ * caller has too little modify power for (2570) ends the call at that row, so
307
+ * the rows before it are removed and the rows after it are not; with
308
+ * `continueOnError` that row is skipped and the others removed. Measured on
309
+ * TeamSpeak 3 3.13.8 and TeamSpeak 6 beta13.1 over every query transport and
310
+ * the voice protocol.
311
+ */
312
+ export async function deleteChannelGroupPermissions(
313
+ client: CommandExecutor,
314
+ cgid: ChannelGroupID,
315
+ permissions: readonly Permission[],
316
+ options: BatchOptions = {},
317
+ ): Promise<void> {
318
+ await execRows(
319
+ client,
320
+ "channelgroupdelperm",
321
+ [...continueOnErrorParams(options), ["cgid", cgid]],
322
+ permissions.map((permission) => [permissionParam(permission)]),
323
+ options,
324
+ );
325
+ }
326
+
270
327
  /** Remove a permission from a channel group. */
271
328
  export async function deleteChannelGroupPermission(
272
329
  client: CommandExecutor,
@@ -314,8 +371,12 @@ export async function listPermissions(
314
371
  }
315
372
 
316
373
  /**
317
- * Resolve a permission name to its numeric id, or undefined when the server
318
- * knows no such permission.
374
+ * Resolve a permission name to its numeric id.
375
+ *
376
+ * A name the server does not know is not an `undefined` result: both TS3 3.13.8
377
+ * and TS6 beta13.1 refuse it with a ServerError 2562 (invalid permission ID),
378
+ * which is thrown. `undefined` is only for a server that acknowledges the
379
+ * command with no row, which neither measured server does.
319
380
  *
320
381
  * Needs an instance-level permission (`b_serverinstance_*`), which not even the
321
382
  * Server Admin group holds by default — expect error 2568 until it is granted.