pi-roundtable-webchat 0.9.2 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +7 -5
- package/package.json +2 -2
- package/src/chat.ts +8 -1
- package/src/plugin.ts +2 -0
- package/src/protocol.ts +3 -1
- package/src/rest.ts +1 -0
- package/src/uploads.ts +105 -29
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.9.5] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
- Release in lockstep with pi-roundtable 0.9.5; no package-specific behavior changes. 0.9.4 was tagged but never published.
|
|
11
|
+
|
|
12
|
+
## [0.9.3] - 2026-10-10
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Uploads that arrive together can no longer pass `unsentUploadBytesPerPrincipal`: the room an upload may fill is reserved per person before its body is read (its declared `Content-Length`, or the room left when none is declared), so ten slow uploads against a 64 MiB allowance take at most 64 MiB, and their request buffers are bounded by it. A body longer than its declared length is cut off and refused (413).
|
|
17
|
+
- A file name loses line and paragraph separators and bidi controls too (they become underscores), and a request `Content-Type` that is not a plain `type/subtype` is refused (415) even under a `/*` entry of `attachmentTypes`, so neither can add to the model's prompt.
|
|
18
|
+
- Long non-ASCII file names no longer fail on Linux (see pi-roundtable 0.9.3).
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- The limit `usedAttachmentBytesPerPrincipal` (1 GiB): what one person's messages may keep across all their conversations. A `send` that would pass it is refused whole with the error code `attachment_quota` (REST 429), and its files stay waiting. Deleting a conversation through the core gives its bytes back. It is enforced by the core of pi-roundtable 0.9.3; with 0.9.2 the limit is ignored.
|
|
23
|
+
|
|
8
24
|
## [0.9.2] - 2026-10-10
|
|
9
25
|
|
|
10
26
|
### Added
|
package/README.md
CHANGED
|
@@ -218,7 +218,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
|
|
|
218
218
|
| `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
|
|
219
219
|
| `{ type: "error", code, ref? }` | A frame was refused. `ref` is the `send` id or prompt id it was about. |
|
|
220
220
|
|
|
221
|
-
Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
|
|
221
|
+
Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), `attachment_quota` (the files would take what your messages keep past `usedAttachmentBytesPerPrincipal`; the whole message is refused and the files stay waiting), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
|
|
222
222
|
|
|
223
223
|
Close codes: `4401` when the token expired without a fresh `auth`, or a fresh one was refused; `4403` when the person is no longer admitted, or a fresh token names someone else.
|
|
224
224
|
The host's own limits close with `1008` (too many frames), `1009` (a frame too big), and `1006` (a client that stopped reading).
|
|
@@ -267,7 +267,7 @@ const { file: id } = await response.json();
|
|
|
267
267
|
socket.send(JSON.stringify({ type: "send", id: "2", conversation, text: "Why did this fail?", attachments: [id] }));
|
|
268
268
|
```
|
|
269
269
|
|
|
270
|
-
What the server checks, in this order: the conversation is the caller's (403, 404); the file name is present and at most 255 characters, with control characters replaced and quotes made apostrophes so a name cannot add lines to the model's prompt (400); the `Content-Type` is one of `limits.attachmentTypes` (415); the person has not made `uploadsPerHour` uploads in the last hour (429); the file is within `attachmentBytes`, by `Content-Length` and again while the body streams, so a body that lies about its length is cut off (413); the person's uploads waiting for a message stay within `unsentUploadBytesPerPrincipal` (429); and for PNG, JPEG, GIF, WebP and PDF the first bytes match the type (415), whatever the file name says.
|
|
270
|
+
What the server checks, in this order: the conversation is the caller's (403, 404); the file name is present and at most 255 characters, with control characters, line and paragraph separators and bidi controls replaced and quotes made apostrophes so a name cannot add lines to the model's prompt (400); the `Content-Type` is a plain `type/subtype` and one of `limits.attachmentTypes` (415); the person has not made `uploadsPerHour` uploads in the last hour (429); the file is within `attachmentBytes`, by `Content-Length` and again while the body streams, so a body that lies about its length is cut off (413); the person's uploads waiting for a message stay within `unsentUploadBytesPerPrincipal` (429), the bytes of uploads still arriving counted too, so a person who starts many at once cannot pass it (an upload declares its `Content-Length` to keep the room it needs small); and for PNG, JPEG, GIF, WebP and PDF the first bytes match the type (415), whatever the file name says.
|
|
271
271
|
JSON, plain text and other types have no such check, since any bytes may be labelled so.
|
|
272
272
|
The default types are `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain` and `application/pdf`; an entry ending in `/*` admits every type under it.
|
|
273
273
|
An rrweb recording is an ordinary `application/json` file.
|
|
@@ -275,10 +275,11 @@ An rrweb recording is an ordinary `application/json` file.
|
|
|
275
275
|
A `send` that names files runs the turn with them: the core moves each file into the conversation's attachment directory, shows up to four images to the model, lists every file under `## Attachments` in the turn's prompt, and lets the model read text, JSON and PDF files with `read_attachment`.
|
|
276
276
|
A plugin's tool passes a file on with `turn.attachment(file)`, which returns the bytes (see the core's [plugin guide](https://github.com/wayne930242/pi-roundtable/blob/master/docs/plugins.md)).
|
|
277
277
|
A message may name at most `attachmentsPerMessage` files, a file belongs to one message, and a message that names a file that is not waiting for the person is refused whole with `unknown_attachment`.
|
|
278
|
+
A message that would take the files its person's messages keep, across all their conversations, past `usedAttachmentBytesPerPrincipal` is refused whole with `attachment_quota` and its files stay waiting.
|
|
278
279
|
|
|
279
280
|
An upload no message used is deleted after `unsentUploadTtlMs` (24 hours): the plugin sweeps every ten minutes, and once when the host starts.
|
|
280
|
-
Files a message used stay with their conversation, in the host's data directory, as long as the conversation does.
|
|
281
|
-
This plugin has no way to delete a conversation
|
|
281
|
+
Files a message used stay with their conversation, in the host's data directory, as long as the conversation does: when the conversation is deleted, the core removes them, the uploads waiting for it, and their share of the person's allowance.
|
|
282
|
+
This plugin has no way to delete a conversation itself; a host surface that deletes one does it through the core.
|
|
282
283
|
|
|
283
284
|
Uploads are as private as the transcript: files sit in the host's data directory under the private conversation, where the owner and the operator can read them, as they can the transcript.
|
|
284
285
|
Only the person who uploaded a file can use it, and only in the conversation it was uploaded to; a shared conversation (one recorded for no one in particular) takes no uploads.
|
|
@@ -325,7 +326,8 @@ Only the person who uploaded a file can use it, and only in the conversation it
|
|
|
325
326
|
| `attachmentBytes` | 10 MiB per uploaded file; never above the core's 25 MiB |
|
|
326
327
|
| `attachmentsPerMessage` | 8 files named by one message |
|
|
327
328
|
| `uploadsPerHour` | 60 uploads per person in any hour |
|
|
328
|
-
| `unsentUploadBytesPerPrincipal` | 64 MiB per person uploaded and not yet used by a message |
|
|
329
|
+
| `unsentUploadBytesPerPrincipal` | 64 MiB per person uploaded and not yet used by a message; uploads arriving together are counted per web chat plugin instance, so two web chats on one host each hold a person to it separately |
|
|
330
|
+
| `usedAttachmentBytesPerPrincipal` | 1 GiB per person kept by their messages across all conversations until a conversation is deleted; needs pi-roundtable 0.9.3 to be enforced |
|
|
329
331
|
| `attachmentTypes` | `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain`, `application/pdf` |
|
|
330
332
|
| `unsentUploadTtlMs` | 24 hours before an unused upload is deleted |
|
|
331
333
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-webchat",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "A WebSocket chat adapter for pi-roundtable: private web conversations for people an OpenID Connect provider signs in, with live progress and approval cards",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
44
44
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
45
45
|
"@types/bun": "1.4.2",
|
|
46
|
-
"pi-roundtable": "0.9.
|
|
46
|
+
"pi-roundtable": "0.9.5",
|
|
47
47
|
"typebox": "1.3.34",
|
|
48
48
|
"typescript": "7.0.2"
|
|
49
49
|
}
|
package/src/chat.ts
CHANGED
|
@@ -579,6 +579,9 @@ export class WebChat {
|
|
|
579
579
|
channelKey(this.#deps.surface, conversation),
|
|
580
580
|
speaker.principalId,
|
|
581
581
|
files,
|
|
582
|
+
{
|
|
583
|
+
usedBytesLimit: this.#deps.limits.usedAttachmentBytesPerPrincipal,
|
|
584
|
+
},
|
|
582
585
|
);
|
|
583
586
|
} catch (error) {
|
|
584
587
|
// The refused message holds no place and opens no conversation.
|
|
@@ -586,7 +589,11 @@ export class WebChat {
|
|
|
586
589
|
if (!frame.conversation) this.#minted.delete(conversation);
|
|
587
590
|
if (!(error instanceof AttachmentRefusal)) throw error;
|
|
588
591
|
throw new Refusal(
|
|
589
|
-
error.code === "unknown_file"
|
|
592
|
+
error.code === "unknown_file"
|
|
593
|
+
? "unknown_attachment"
|
|
594
|
+
: error.code === "quota_exceeded"
|
|
595
|
+
? "attachment_quota"
|
|
596
|
+
: "forbidden",
|
|
590
597
|
);
|
|
591
598
|
}
|
|
592
599
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -73,6 +73,7 @@ const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
|
|
|
73
73
|
attachmentsPerMessage: 8,
|
|
74
74
|
uploadsPerHour: 60,
|
|
75
75
|
unsentUploadBytesPerPrincipal: 64 * 1024 * 1024,
|
|
76
|
+
usedAttachmentBytesPerPrincipal: 1024 * 1024 * 1024,
|
|
76
77
|
attachmentTypes: DEFAULT_ATTACHMENT_TYPES,
|
|
77
78
|
unsentUploadTtlMs: 24 * 60 * 60_000,
|
|
78
79
|
maxConnections: 256,
|
|
@@ -119,6 +120,7 @@ function checkAttachmentLimits(limits: WebChatLimits): void {
|
|
|
119
120
|
"attachmentsPerMessage",
|
|
120
121
|
"uploadsPerHour",
|
|
121
122
|
"unsentUploadBytesPerPrincipal",
|
|
123
|
+
"usedAttachmentBytesPerPrincipal",
|
|
122
124
|
"unsentUploadTtlMs",
|
|
123
125
|
] as const)
|
|
124
126
|
if (!Number.isInteger(limits[key]) || limits[key] < 1)
|
package/src/protocol.ts
CHANGED
|
@@ -67,7 +67,9 @@ export type ErrorCode =
|
|
|
67
67
|
| "too_many_conversations"
|
|
68
68
|
| "busy"
|
|
69
69
|
/** A `send` named a file that is not waiting for this person in this conversation; the whole message is refused. */
|
|
70
|
-
| "unknown_attachment"
|
|
70
|
+
| "unknown_attachment"
|
|
71
|
+
/** A `send` would take the person past `usedAttachmentBytesPerPrincipal`; the whole message is refused and its files stay waiting. */
|
|
72
|
+
| "attachment_quota";
|
|
71
73
|
|
|
72
74
|
/** What a client sends: one JSON object per WebSocket text message. */
|
|
73
75
|
export type ClientFrame =
|
package/src/rest.ts
CHANGED
package/src/uploads.ts
CHANGED
|
@@ -23,6 +23,11 @@ export interface UploadLimits {
|
|
|
23
23
|
uploadsPerHour: number;
|
|
24
24
|
/** The bytes one person may have uploaded and not yet sent in a message; default 64 MiB. */
|
|
25
25
|
unsentUploadBytesPerPrincipal: number;
|
|
26
|
+
/**
|
|
27
|
+
* The bytes one person's messages may keep in all their conversations, until a conversation is
|
|
28
|
+
* deleted and gives its bytes back; default 1 GiB.
|
|
29
|
+
*/
|
|
30
|
+
usedAttachmentBytesPerPrincipal: number;
|
|
26
31
|
/** The content types accepted, such as `image/png`; an entry ending in `/*` admits every type under it. */
|
|
27
32
|
attachmentTypes: readonly string[];
|
|
28
33
|
/** How long an upload no message referenced is kept before it is deleted; default 24 hours. */
|
|
@@ -66,13 +71,20 @@ const HOUR_MS = 60 * 60_000;
|
|
|
66
71
|
/** The longest file name accepted, in characters. */
|
|
67
72
|
const NAME_CHARS = 255;
|
|
68
73
|
|
|
74
|
+
/** Control characters, line and paragraph separators, and bidi controls: none reaches a prompt or a screen as it is. */
|
|
75
|
+
const UNSAFE_NAME_CHARS =
|
|
76
|
+
/[\p{Cc}\p{Zl}\p{Zp}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
|
|
77
|
+
/** A plain type/subtype, the grammar a configured `attachmentTypes` entry has too. */
|
|
78
|
+
const PLAIN_TYPE = /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/;
|
|
79
|
+
|
|
69
80
|
/**
|
|
70
|
-
* A name the model can read in its prompt: control
|
|
71
|
-
* become apostrophes, so a name cannot add lines or close the quotes
|
|
81
|
+
* A name the model can read in its prompt: control and bidi characters and line separators become
|
|
82
|
+
* underscores and quotes become apostrophes, so a name cannot add lines or close the quotes
|
|
83
|
+
* around it.
|
|
72
84
|
*/
|
|
73
85
|
function safeName(name: string | null): string {
|
|
74
86
|
const cleaned = (name ?? "")
|
|
75
|
-
.replace(
|
|
87
|
+
.replace(UNSAFE_NAME_CHARS, "_")
|
|
76
88
|
.replaceAll('"', "'")
|
|
77
89
|
.trim();
|
|
78
90
|
if (cleaned === "" || cleaned.length > NAME_CHARS)
|
|
@@ -121,6 +133,10 @@ async function readLimited(
|
|
|
121
133
|
export class Uploads {
|
|
122
134
|
readonly #deps: UploadsDeps;
|
|
123
135
|
readonly #rate: RateWindow;
|
|
136
|
+
/** The bytes each person's uploads in flight may still write, counted against their allowance. */
|
|
137
|
+
readonly #reserved = new Map<string, number>();
|
|
138
|
+
/** The last admission queued for each person, which the next one waits for. */
|
|
139
|
+
readonly #admissions = new Map<string, Promise<void>>();
|
|
124
140
|
|
|
125
141
|
constructor(deps: UploadsDeps) {
|
|
126
142
|
this.#deps = deps;
|
|
@@ -145,6 +161,51 @@ export class Uploads {
|
|
|
145
161
|
};
|
|
146
162
|
}
|
|
147
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Reserves the bytes an upload may write against the person's allowance, one admission at a
|
|
166
|
+
* time per person, so uploads in flight together count against it and not only the ones saved.
|
|
167
|
+
* Returns the bytes reserved, and the most the body may be.
|
|
168
|
+
*/
|
|
169
|
+
#admit(
|
|
170
|
+
principalId: string,
|
|
171
|
+
declared: number | undefined,
|
|
172
|
+
): Promise<{ reserved: number; cap: number }> {
|
|
173
|
+
const { limits } = this.#deps;
|
|
174
|
+
const run = async () => {
|
|
175
|
+
const holding = this.#reserved.get(principalId) ?? 0;
|
|
176
|
+
const waiting = await this.#deps.attachments().pendingBytes(principalId);
|
|
177
|
+
const room = Math.max(
|
|
178
|
+
limits.unsentUploadBytesPerPrincipal - waiting - holding,
|
|
179
|
+
0,
|
|
180
|
+
);
|
|
181
|
+
const cap = Math.min(limits.attachmentBytes, room);
|
|
182
|
+
if (declared !== undefined && declared > room)
|
|
183
|
+
throw new UploadRefusal(429, "too_many_uploads");
|
|
184
|
+
const reserved = declared === undefined ? cap : Math.min(declared, cap);
|
|
185
|
+
this.#reserved.set(principalId, holding + reserved);
|
|
186
|
+
return { reserved, cap };
|
|
187
|
+
};
|
|
188
|
+
const result = (
|
|
189
|
+
this.#admissions.get(principalId) ?? Promise.resolve()
|
|
190
|
+
).then(run);
|
|
191
|
+
const tail = result.then(
|
|
192
|
+
() => undefined,
|
|
193
|
+
() => undefined,
|
|
194
|
+
);
|
|
195
|
+
this.#admissions.set(principalId, tail);
|
|
196
|
+
void tail.then(() => {
|
|
197
|
+
if (this.#admissions.get(principalId) === tail)
|
|
198
|
+
this.#admissions.delete(principalId);
|
|
199
|
+
});
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#release(principalId: string, reserved: number): void {
|
|
204
|
+
const left = (this.#reserved.get(principalId) ?? 0) - reserved;
|
|
205
|
+
if (left > 0) this.#reserved.set(principalId, left);
|
|
206
|
+
else this.#reserved.delete(principalId);
|
|
207
|
+
}
|
|
208
|
+
|
|
148
209
|
/** Takes the request's body as a file for `principalId` in `channel`; throws an UploadRefusal. */
|
|
149
210
|
async receive(
|
|
150
211
|
request: Request,
|
|
@@ -155,37 +216,52 @@ export class Uploads {
|
|
|
155
216
|
const { limits } = this.#deps;
|
|
156
217
|
const fileName = safeName(name);
|
|
157
218
|
const type = baseType(request.headers.get("content-type"));
|
|
158
|
-
if (
|
|
219
|
+
if (
|
|
220
|
+
!type ||
|
|
221
|
+
!PLAIN_TYPE.test(type) ||
|
|
222
|
+
!isAllowedType(limits.attachmentTypes, type)
|
|
223
|
+
)
|
|
159
224
|
throw new UploadRefusal(415, "unsupported_media_type");
|
|
160
|
-
const
|
|
161
|
-
|
|
225
|
+
const header = request.headers.get("content-length");
|
|
226
|
+
const length = header === null ? Number.NaN : Number(header);
|
|
227
|
+
// A header that is no length is no declaration; the body is held to the room left instead.
|
|
228
|
+
const declared =
|
|
229
|
+
Number.isFinite(length) && length >= 0 ? length : undefined;
|
|
230
|
+
if (declared !== undefined && declared > limits.attachmentBytes)
|
|
162
231
|
throw new UploadRefusal(413, "payload_too_large");
|
|
163
232
|
if (!this.#rate.take(principalId))
|
|
164
233
|
throw new UploadRefusal(429, "too_many_uploads");
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
Math.min(limits.attachmentBytes, Math.max(room, 0)),
|
|
171
|
-
);
|
|
172
|
-
if (!bytes)
|
|
173
|
-
throw room < limits.attachmentBytes
|
|
174
|
-
? new UploadRefusal(429, "too_many_uploads")
|
|
175
|
-
: new UploadRefusal(413, "payload_too_large");
|
|
176
|
-
if (!bytesMatchType(type, bytes))
|
|
177
|
-
throw new UploadRefusal(415, "unsupported_media_type");
|
|
178
|
-
const stored = await attachments.save(channel, principalId, {
|
|
179
|
-
name: fileName,
|
|
180
|
-
contentType: type,
|
|
181
|
-
data: bytes,
|
|
182
|
-
});
|
|
183
|
-
return {
|
|
184
|
-
file: stored.file,
|
|
185
|
-
name: stored.name,
|
|
186
|
-
contentType: stored.contentType,
|
|
187
|
-
size: stored.size,
|
|
234
|
+
const { reserved, cap } = await this.#admit(principalId, declared);
|
|
235
|
+
let held = reserved;
|
|
236
|
+
const release = () => {
|
|
237
|
+
this.#release(principalId, held);
|
|
238
|
+
held = 0;
|
|
188
239
|
};
|
|
240
|
+
try {
|
|
241
|
+
const bytes = await readLimited(request, reserved);
|
|
242
|
+
if (!bytes)
|
|
243
|
+
// A body past its declared length lied; one past the room left is the allowance's.
|
|
244
|
+
throw declared === undefined && cap < limits.attachmentBytes
|
|
245
|
+
? new UploadRefusal(429, "too_many_uploads")
|
|
246
|
+
: new UploadRefusal(413, "payload_too_large");
|
|
247
|
+
if (!bytesMatchType(type, bytes))
|
|
248
|
+
throw new UploadRefusal(415, "unsupported_media_type");
|
|
249
|
+
const stored = await this.#deps.attachments().save(channel, principalId, {
|
|
250
|
+
name: fileName,
|
|
251
|
+
contentType: type,
|
|
252
|
+
data: bytes,
|
|
253
|
+
});
|
|
254
|
+
// The saved file counts as waiting from here on.
|
|
255
|
+
release();
|
|
256
|
+
return {
|
|
257
|
+
file: stored.file,
|
|
258
|
+
name: stored.name,
|
|
259
|
+
contentType: stored.contentType,
|
|
260
|
+
size: stored.size,
|
|
261
|
+
};
|
|
262
|
+
} finally {
|
|
263
|
+
release();
|
|
264
|
+
}
|
|
189
265
|
}
|
|
190
266
|
|
|
191
267
|
/** Deletes the uploads no message used within the time to live; returns how many. */
|