@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
@@ -23,6 +23,7 @@ import {
23
23
  import type { IconID, UID } from "@honeybbq/teamspeak";
24
24
  import type { Client } from "../client.js";
25
25
  import type { FileTransferOptions } from "../transfer.js";
26
+ import { verifyStored, withDialCleanup } from "./upload-checks.js";
26
27
 
27
28
  /** Options for the avatar and icon transfers. */
28
29
  export type AvatarTransferOptions = Pick<FileTransferOptions, "signal" | "trace">;
@@ -84,7 +85,14 @@ export async function uploadAvatar(
84
85
  overwrite: true,
85
86
  ...(options.signal ? { signal: options.signal } : {}),
86
87
  });
87
- await client.uploadFileData(client.host, info, Readable.from([data]), options);
88
+ await withDialCleanup(client, info.serverFileTransferID, () =>
89
+ client.uploadFileData(client.host, info, Readable.from([data]), options),
90
+ );
91
+ // Stored under a name the server derives from the uploader's uid, not the
92
+ // one asked for (measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0).
93
+ await verifyStored(client, CHANNEL_ZERO, `/${avatarFileName(client.uid)}`, BigInt(data.length), {
94
+ signal: options.signal,
95
+ });
88
96
  await updateClient(client, { avatar: md5 });
89
97
  return md5;
90
98
  }
@@ -150,7 +158,12 @@ export async function uploadIcon(
150
158
  overwrite: true,
151
159
  ...(options.signal ? { signal: options.signal } : {}),
152
160
  });
153
- await client.uploadFileData(client.host, info, Readable.from([data]), options);
161
+ await withDialCleanup(client, info.serverFileTransferID, () =>
162
+ client.uploadFileData(client.host, info, Readable.from([data]), options),
163
+ );
164
+ await verifyStored(client, CHANNEL_ZERO, iconFileName(id), BigInt(data.length), {
165
+ signal: options.signal,
166
+ });
154
167
  return id;
155
168
  }
156
169
 
@@ -10,8 +10,8 @@
10
10
  * (error 5120), on both servers.
11
11
  */
12
12
 
13
- import { Readable, Writable } from "node:stream";
14
- import { initFileDownload, initFileUpload } from "@honeybbq/teamspeak";
13
+ import { Readable, Transform, Writable, pipeline } from "node:stream";
14
+ import { initFileDownload, initFileUpload, stopFileTransfer } from "@honeybbq/teamspeak";
15
15
  import type {
16
16
  ChannelID,
17
17
  CommandExecutor,
@@ -22,15 +22,15 @@ import type {
22
22
  } from "@honeybbq/teamspeak";
23
23
  import { FileTransferError } from "../errors.js";
24
24
  import { downloadFileData, uploadFileData } from "../transfer.js";
25
+ import { verifyStored, withDialCleanup } from "./upload-checks.js";
25
26
  import type { FileTransferOptions } from "../transfer.js";
26
27
 
27
28
  /**
28
29
  * How {@link uploadFile} negotiates and moves the file: the options of
29
- * `initFileUpload`, without `resume` (the whole file is sent), and those of
30
- * `uploadFileData`. `signal` and `timeoutMs` apply to the negotiation, and the
31
- * signal to the transfer too.
30
+ * `initFileUpload` and those of `uploadFileData`. `signal` and `timeoutMs`
31
+ * apply to the negotiation, and the signal to the transfer too.
32
32
  */
33
- export interface UploadFileOptions extends Omit<FileUploadOptions, "resume">, FileTransferOptions {
33
+ export interface UploadFileOptions extends FileUploadOptions, FileTransferOptions {
34
34
  /**
35
35
  * The host of the file transfer port. A voice client sends to the host it
36
36
  * connected to, so it needs none; any other connection must name it. A host
@@ -40,26 +40,30 @@ export interface UploadFileOptions extends Omit<FileUploadOptions, "resume">, Fi
40
40
  /**
41
41
  * The size in bytes of a stream passed as the data, which the server needs
42
42
  * before it takes the file. Required for a `Readable`; for bytes it is their
43
- * length, and a different value here is refused.
43
+ * length, and a different value here is refused. A stream that gives fewer
44
+ * or more bytes fails the upload.
44
45
  */
45
46
  readonly size?: bigint | number | undefined;
46
47
  }
47
48
 
48
- /** How {@link downloadFile} negotiates and moves the file. */
49
- export interface DownloadFileOptions
50
- extends Omit<FileDownloadOptions, "seekPosition">, FileTransferOptions {
49
+ /**
50
+ * How {@link downloadFile} negotiates and moves the file. With a
51
+ * `seekPosition` the bytes are the file's from there to its end.
52
+ */
53
+ export interface DownloadFileOptions extends FileDownloadOptions, FileTransferOptions {
51
54
  /** The host of the file transfer port, as for {@link UploadFileOptions}. */
52
55
  readonly host?: string | undefined;
53
56
  /**
54
57
  * Write the file into this stream instead of returning its bytes. The stream
55
- * is not ended, as for `downloadFileData`.
58
+ * is neither ended nor destroyed, as for `downloadFileData`, so it may take
59
+ * several downloads; a failed transfer still rejects the call.
56
60
  */
57
61
  readonly dest?: Writable | undefined;
58
62
  /**
59
- * The largest file, in bytes, to hold in memory when no `dest` is given;
60
- * 268435456 (256 MiB) by default. A larger file is refused with a
61
- * {@link FileTransferError} before any byte is read; the transfer the server
62
- * negotiated is left unused.
63
+ * The most bytes to hold in memory when no `dest` is given; 268435456
64
+ * (256 MiB) by default. A larger download is refused with a
65
+ * {@link FileTransferError} before any byte is read, and the transfer the
66
+ * server negotiated is stopped.
63
67
  */
64
68
  readonly maxSize?: number | undefined;
65
69
  }
@@ -147,12 +151,43 @@ function sizeOf(data: Uint8Array | Readable, declared: bigint | number | undefin
147
151
  return size;
148
152
  }
149
153
 
154
+ /** Drops the first `count` bytes of a stream. */
155
+ function skipBytes(count: number): Transform {
156
+ let left = count;
157
+ return new Transform({
158
+ transform(chunk: Buffer, _encoding, callback) {
159
+ if (left >= chunk.length) {
160
+ left -= chunk.length;
161
+ callback();
162
+ return;
163
+ }
164
+ const rest = chunk.subarray(left);
165
+ left = 0;
166
+ callback(null, rest);
167
+ },
168
+ });
169
+ }
170
+
150
171
  /**
151
172
  * Upload a file into a channel's file area: negotiate the transfer
152
173
  * (`initFileUpload`) and send the bytes (`uploadFileData`), in one call.
153
174
  *
154
175
  * `data` is the bytes, or a `Readable` with `options.size` the number of bytes
155
- * it will give. An existing file is not replaced unless `overwrite` is set, and
176
+ * it will give. It resolves once the server has closed the transfer
177
+ * connection and, by `ftgetfileinfo` (one more command), holds a file of
178
+ * exactly that size: the server does not say whether it stored a file, so a
179
+ * close alone could be a transfer someone stopped. A size that differs, or a
180
+ * file that is not there, fails the call with a {@link FileTransferError}; a
181
+ * client with no browse power cannot ask (2568) and resolves on the close. A
182
+ * stream that gives fewer or more bytes than `size` fails the call, and the
183
+ * connection is closed. When the connection to the file transfer port is
184
+ * refused or times out, the negotiated transfer is stopped on the server (else
185
+ * it lingers in `ftlist` for about 10 seconds), as it is for `downloadFile`.
186
+ *
187
+ * With `resume`, `data` is still the whole file: the server says how much it
188
+ * already holds from an interrupted upload (a file that is not there is
189
+ * refused with 2054), and only the rest is sent; `overwrite` cannot go with it
190
+ * (2056). An existing file is not replaced unless `overwrite` is set, and
156
191
  * the directory the file goes in must exist (a missing one is refused with
157
192
  * 2054, invalid file path): make it with `createDirectory` first. A refusal of
158
193
  * the negotiation is thrown as the ServerError it is, before anything is sent;
@@ -175,11 +210,21 @@ export async function uploadFile(
175
210
  const info = await initFileUpload(client, channelID, path, size, {
176
211
  password: options.password,
177
212
  overwrite: options.overwrite,
213
+ resume: options.resume,
178
214
  signal: options.signal,
179
215
  timeoutMs: options.timeoutMs,
180
216
  });
181
- const source = data instanceof Uint8Array ? Readable.from([data]) : data;
182
- await route.upload(info, source, transferOptions(options));
217
+ // What the server holds is not sent again; a position past the end is for
218
+ // the transfer to refuse.
219
+ const skip = Number(info.seekPosition);
220
+ let source: Readable;
221
+ if (data instanceof Uint8Array) source = Readable.from([data.subarray(skip)]);
222
+ else if (skip === 0) source = data;
223
+ else source = pipeline(data, skipBytes(skip), () => {});
224
+ await withDialCleanup(client, info.serverFileTransferID, () =>
225
+ route.upload(info, source, transferOptions(options)),
226
+ );
227
+ await verifyStored(client, channelID, path, size, options);
183
228
  }
184
229
 
185
230
  /**
@@ -188,7 +233,11 @@ export async function uploadFile(
188
233
  * call. With `options.dest` the bytes go into that stream instead and nothing
189
234
  * is held, which is the way to fetch a large file.
190
235
  *
191
- * A file over `maxSize` (256 MiB unless set) is refused when no `dest` is given.
236
+ * With `seekPosition` the bytes are the file's from there to its end, which is
237
+ * how to resume a partial download: append them to what was already fetched.
238
+ *
239
+ * More than `maxSize` bytes (256 MiB unless set) is refused when no `dest` is
240
+ * given, and the transfer the server negotiated is stopped.
192
241
  * A file that is not there is the ServerError of the negotiation: 2051 on TS3
193
242
  * 3.13.8 and TS6 beta13.1. A path that names a directory is not refused by the
194
243
  * server: it announces a size and sends nothing, so the call rejects with a
@@ -223,16 +272,25 @@ export async function downloadFile(
223
272
  const route = routeFor(client, options);
224
273
  const info = await initFileDownload(client, channelID, path, {
225
274
  password: options.password,
275
+ seekPosition: options.seekPosition,
226
276
  signal: options.signal,
227
277
  timeoutMs: options.timeoutMs,
228
278
  });
229
279
  if (options.dest !== undefined) {
230
- await route.download(info, options.dest, transferOptions(options));
280
+ const dest = options.dest;
281
+ await withDialCleanup(client, info.serverFileTransferID, () =>
282
+ route.download(info, dest, transferOptions(options)),
283
+ );
231
284
  return;
232
285
  }
233
- if (info.size > BigInt(maxSize)) {
286
+ const wanted = info.size - info.seekPosition;
287
+ if (wanted > BigInt(maxSize)) {
288
+ // The server holds the negotiated transfer, and lists it among the active
289
+ // ones, until it is used or stopped. It is this connection's own, which is
290
+ // the only kind `ftstop` is safe to send.
291
+ await stopFileTransfer(client, info.serverFileTransferID).catch(() => {});
234
292
  throw new FileTransferError(
235
- `the file is ${info.size} bytes, over the ${maxSize} bytes maxSize allows in memory`,
293
+ `the download is ${wanted} bytes, over the ${maxSize} bytes maxSize allows in memory`,
236
294
  );
237
295
  }
238
296
  const chunks: Buffer[] = [];
@@ -242,7 +300,9 @@ export async function downloadFile(
242
300
  done();
243
301
  },
244
302
  });
245
- await route.download(info, sink, transferOptions(options));
303
+ await withDialCleanup(client, info.serverFileTransferID, () =>
304
+ route.download(info, sink, transferOptions(options)),
305
+ );
246
306
  return Buffer.concat(chunks);
247
307
  }
248
308
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What an upload does around its data phase: stop a transfer whose connection
3
+ * was never made, and check what the server stored once it closed the
4
+ * connection. Shared by the file and avatar helpers; not exported from the
5
+ * package.
6
+ */
7
+
8
+ import { ErrorCode, ServerError, getFileInfo, stopFileTransfer } from "@honeybbq/teamspeak";
9
+ import type { ChannelID, CommandExecutor, FileUploadInfo } from "@honeybbq/teamspeak";
10
+ import { FileTransferError } from "../errors.js";
11
+ import { failedToConnect } from "../transfer.js";
12
+
13
+ /**
14
+ * The server drops a transfer nobody dialled after about 10 seconds (measured
15
+ * on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0: it is gone from `ftlist` at
16
+ * 10.05 s, and the voice client hears 2059). `ftstop` of a transfer the server
17
+ * no longer has crashes TeamSpeak 6, so a stop is only sent well inside that.
18
+ */
19
+ const STOP_WITHIN_MS = 5_000;
20
+
21
+ /**
22
+ * Runs the data phase of a transfer the server has negotiated. When it fails
23
+ * because the connection to the file transfer port was refused or timed out,
24
+ * nothing has reached the server, and the transfer would sit in its list until
25
+ * it expires: stop it, as a refused `maxSize` download does. The stop is best
26
+ * effort and never replaces the dial's error. Not sent once the connection
27
+ * opened (the key may be written and the server may have dropped the transfer),
28
+ * nor after `STOP_WITHIN_MS`.
29
+ */
30
+ export async function withDialCleanup(
31
+ client: CommandExecutor,
32
+ serverFileTransferID: FileUploadInfo["serverFileTransferID"],
33
+ move: () => Promise<void>,
34
+ ): Promise<void> {
35
+ const negotiatedAt = performance.now();
36
+ try {
37
+ await move();
38
+ } catch (error) {
39
+ if (failedToConnect(error) && performance.now() - negotiatedAt < STOP_WITHIN_MS) {
40
+ await stopFileTransfer(client, serverFileTransferID).catch(() => {});
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Checks that the server holds the whole file after an upload's connection
48
+ * closed. Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0: a completed
49
+ * upload gets no status from the server, so a close after the last byte looks
50
+ * the same whether the file was stored or the transfer was stopped (the voice
51
+ * protocol reports that, 2066, only after the close), and `ftgetfileinfo`
52
+ * already answers the final size 6 to 45 ms after the close, in every case.
53
+ * Permissions are checked at the negotiation only, so no refusal comes later.
54
+ *
55
+ * A client without browse power gets 2568 for `ftgetfileinfo`: the file cannot
56
+ * be checked, and the upload resolves on the close. Any other failure of the
57
+ * command is thrown as it is.
58
+ */
59
+ export async function verifyStored(
60
+ client: CommandExecutor,
61
+ channelID: ChannelID,
62
+ path: string,
63
+ size: bigint,
64
+ options: {
65
+ readonly password?: string | undefined;
66
+ readonly signal?: AbortSignal | undefined;
67
+ readonly timeoutMs?: number | undefined;
68
+ },
69
+ ): Promise<void> {
70
+ let stored: bigint;
71
+ try {
72
+ stored = (
73
+ await getFileInfo(client, channelID, path, {
74
+ password: options.password,
75
+ signal: options.signal,
76
+ timeoutMs: options.timeoutMs,
77
+ })
78
+ ).size;
79
+ } catch (error) {
80
+ if (error instanceof ServerError) {
81
+ if (error.code === ErrorCode.PermissionsClientInsufficient) return;
82
+ // 2051 on TeamSpeak 6, 2052 on TeamSpeak 3 for a file that is not there.
83
+ if (error.code === ErrorCode.FileNotFound || error.code === ErrorCode.FileIOError) {
84
+ throw new FileTransferError(`the server closed the upload but has no file ${path}`, {
85
+ cause: error,
86
+ });
87
+ }
88
+ }
89
+ throw error;
90
+ }
91
+ if (stored !== size) {
92
+ throw new FileTransferError(
93
+ `the server closed the upload but holds ${stored} of the ${size} bytes of ${path}`,
94
+ );
95
+ }
96
+ }
@@ -14,7 +14,8 @@ import type { ChannelID, ClientID, CommandOptions } from "@honeybbq/teamspeak";
14
14
  /**
15
15
  * The channel's description, which `listChannels` omits for bandwidth. The
16
16
  * server answers with a `notifychanneledited` that the client does not report as
17
- * a `channelEdited` event, since it is no edit.
17
+ * a `channelEdited` event, since it is no edit. Rejects with a NoDataError if
18
+ * the server sends no such answer.
18
19
  */
19
20
  export async function getChannelDescription(
20
21
  client: Client,
@@ -29,7 +30,9 @@ export async function getChannelDescription(
29
30
  // can arrive while the command waits, a real edit with other properties,
30
31
  // and the first row is then not the answer.
31
32
  const answer = rows.find((row) => row["cid"] === String(cid) && "channel_description" in row);
32
- return answer?.["channel_description"] ?? "";
33
+ // A missing answer is not an empty description.
34
+ if (answer === undefined) throw new NoDataError("channelgetdescription");
35
+ return answer["channel_description"] ?? "";
33
36
  }
34
37
 
35
38
  /**