@profullstack/group-call 0.4.0 → 0.5.0
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/README.md +7 -0
- package/dist/index.d.ts +54 -3
- package/dist/index.js +123 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,13 @@ await line.handle(JSON.parse(rawBody).data);
|
|
|
46
46
|
- **Call people in.** `dial(code, phone, { from, connectionId, name?, prompt? })` rings a phone
|
|
47
47
|
from your number. Whoever answers presses 1 and is in the room, so voicemail never joins.
|
|
48
48
|
The room rides in the call's `client_state`, so an answer after a restart still lands right.
|
|
49
|
+
- **Call a voice agent in.** `dial(code, "sip:+1555...@sip.rtc.elevenlabs.io", { ..., direct: true, name, headers })`
|
|
50
|
+
joins on answer with no press 1 and no greeting, and sends `headers` (`X-...` only) on the INVITE.
|
|
51
|
+
ElevenLabs Agents expose them as dynamic variables (`X-Otr-Episode` -> `{{sip_otr_episode}}`).
|
|
52
|
+
- **Record a room.** `record(code)` records the conference, everyone mixed, as mp3 (or wav) until it
|
|
53
|
+
ends or `stopRecording(code)`. `onRecording({ code, conferenceId, recordingId, mp3, wav, ... })` fires
|
|
54
|
+
on `conference.recording.saved`; the links are short-lived, so download straight away, or get fresh
|
|
55
|
+
ones with `recordingLinks(recordingId)`.
|
|
49
56
|
- **Room API.** `say(code, text, voice?)` speaks into a room. Also `participants(code)`,
|
|
50
57
|
`kick(code, leg)`, `list()`, `hasCallers(code)` and `callersIn(code)`.
|
|
51
58
|
- **App hooks.** `onCode` can join, welcome, reject, or take the call over: `call.gather(...)`
|
package/dist/index.d.ts
CHANGED
|
@@ -40,6 +40,20 @@ export interface Participant {
|
|
|
40
40
|
interface Room extends RoomInfo {
|
|
41
41
|
/** Legs currently in the room, so a caller is never counted twice. */
|
|
42
42
|
legs: Map<string, Participant>;
|
|
43
|
+
/** The conference a recording was started on, or null; a remade conference is not being recorded. */
|
|
44
|
+
recordingConf?: string | null;
|
|
45
|
+
}
|
|
46
|
+
/** A finished recording of a room's conference, as Telnyx saved it. */
|
|
47
|
+
export interface Recording {
|
|
48
|
+
/** The room, when this process started the recording; null after a restart lost track. */
|
|
49
|
+
code: string | null;
|
|
50
|
+
conferenceId: string;
|
|
51
|
+
recordingId: string;
|
|
52
|
+
/** Short-lived signed links; fetch the file promptly, or ask Telnyx for /recordings/{id} again. */
|
|
53
|
+
mp3: string | null;
|
|
54
|
+
wav: string | null;
|
|
55
|
+
startedAt: string | null;
|
|
56
|
+
endedAt: string | null;
|
|
43
57
|
}
|
|
44
58
|
/** Sending a text. Injected because the number that sends is not always the one called. */
|
|
45
59
|
export interface Sms {
|
|
@@ -131,6 +145,8 @@ export interface GroupCallOptions {
|
|
|
131
145
|
};
|
|
132
146
|
/** A finished line somebody said in a room (needs `transcribe`). */
|
|
133
147
|
onTranscript?: (code: string, who: Participant, text: string) => void;
|
|
148
|
+
/** A room recording (see `record`) is saved on Telnyx and ready to download. */
|
|
149
|
+
onRecording?: (recording: Recording) => void;
|
|
134
150
|
/** Injected for tests. */
|
|
135
151
|
now?: () => number;
|
|
136
152
|
fetch?: typeof globalThis.fetch;
|
|
@@ -180,6 +196,10 @@ export declare class GroupCall {
|
|
|
180
196
|
private readonly dialled;
|
|
181
197
|
/** How many times each leg has been asked for a code, so it is not forever. */
|
|
182
198
|
private readonly asks;
|
|
199
|
+
/** Names for legs `dial` placed with one, used when they enter. */
|
|
200
|
+
private readonly legName;
|
|
201
|
+
/** Which room each conference we are recording belongs to, for the saved webhook. */
|
|
202
|
+
private readonly recordingRoom;
|
|
183
203
|
/** Legs whose voice failed once, which hear the plain one from then on. */
|
|
184
204
|
private readonly plainVoice;
|
|
185
205
|
/** Names promised for callers about to arrive from a number: agents announce themselves. */
|
|
@@ -245,6 +265,12 @@ export declare class GroupCall {
|
|
|
245
265
|
* room. The room is carried in the call's client_state too, so an answer
|
|
246
266
|
* that arrives after a restart still knows where it was going. Returns the
|
|
247
267
|
* new leg, or null when Telnyx would not place the call.
|
|
268
|
+
*
|
|
269
|
+
* `direct` skips the press-1 question and joins on answer, silently: for a
|
|
270
|
+
* machine that will not press anything, like a voice agent. `to` may then be
|
|
271
|
+
* a SIP URI (`sip:+1555...@sip.example.com`), and `headers` are sent on the
|
|
272
|
+
* INVITE as custom X- headers, which is how such an agent is told what call
|
|
273
|
+
* this is.
|
|
248
274
|
*/
|
|
249
275
|
dial(code: string, to: string, opts: {
|
|
250
276
|
from: string;
|
|
@@ -253,10 +279,16 @@ export declare class GroupCall {
|
|
|
253
279
|
prompt?: string;
|
|
254
280
|
welcome?: string;
|
|
255
281
|
timeoutSecs?: number;
|
|
282
|
+
direct?: boolean;
|
|
283
|
+
headers?: Record<string, string>;
|
|
256
284
|
}): Promise<{
|
|
257
285
|
leg: string;
|
|
258
286
|
} | null>;
|
|
259
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* A dialled phone was picked up: press 1 to join, so voicemail never does.
|
|
289
|
+
* Telnyx itself replays the prompt (three tries) and ignores any other key,
|
|
290
|
+
* so an ended gather without a 1 is a no.
|
|
291
|
+
*/
|
|
260
292
|
private offer;
|
|
261
293
|
/** Hand a well-formed code to the app, and act on what it says the code is. */
|
|
262
294
|
private routeCode;
|
|
@@ -276,9 +308,9 @@ export declare class GroupCall {
|
|
|
276
308
|
* Put a leg into a room, making the conference if it is the first one there.
|
|
277
309
|
*
|
|
278
310
|
* `welcome` is what the caller hears once they are in, before the count of
|
|
279
|
-
* who else is.
|
|
311
|
+
* who else is; null joins them without a word.
|
|
280
312
|
*/
|
|
281
|
-
join(leg: string, code: string, welcome?: string): Promise<void>;
|
|
313
|
+
join(leg: string, code: string, welcome?: string | null): Promise<void>;
|
|
282
314
|
/**
|
|
283
315
|
* A conference's name: findable again after a restart, but not the code itself,
|
|
284
316
|
* which would be a room's whole key sitting in somebody else's dashboard. A
|
|
@@ -307,6 +339,25 @@ export declare class GroupCall {
|
|
|
307
339
|
* nobody is on the phone there, which is most of the time and costs nothing.
|
|
308
340
|
*/
|
|
309
341
|
say(code: string, text: string, voice?: string, settings?: Record<string, unknown>): Promise<boolean>;
|
|
342
|
+
/**
|
|
343
|
+
* Record the room's conference, everyone mixed into one file. Runs until the
|
|
344
|
+
* conference ends (the last caller leaves) or `stopRecording`; `onRecording`
|
|
345
|
+
* hears when the file is saved. False when nobody is on the line yet, since
|
|
346
|
+
* there is no conference to record until somebody is.
|
|
347
|
+
*/
|
|
348
|
+
record(code: string, opts?: {
|
|
349
|
+
format?: "mp3" | "wav";
|
|
350
|
+
beep?: boolean;
|
|
351
|
+
}): Promise<boolean>;
|
|
352
|
+
/** Whether this process is recording the room's current conference. */
|
|
353
|
+
recording(code: string): boolean;
|
|
354
|
+
/** Stop recording; the file is saved and `onRecording` follows. */
|
|
355
|
+
stopRecording(code: string): Promise<boolean>;
|
|
356
|
+
/** A saved recording's current download links, for when the webhook's have expired. */
|
|
357
|
+
recordingLinks(recordingId: string): Promise<{
|
|
358
|
+
mp3: string | null;
|
|
359
|
+
wav: string | null;
|
|
360
|
+
} | null>;
|
|
310
361
|
/** Hang up one participant in a room. */
|
|
311
362
|
kick(code: string, leg: string): Promise<boolean>;
|
|
312
363
|
/** Whether anybody is on the phone in a room. */
|
package/dist/index.js
CHANGED
|
@@ -73,6 +73,8 @@ function dialState(raw) {
|
|
|
73
73
|
code: s["code"],
|
|
74
74
|
...(typeof s["prompt"] === "string" ? { prompt: s["prompt"] } : {}),
|
|
75
75
|
...(typeof s["welcome"] === "string" ? { welcome: s["welcome"] } : {}),
|
|
76
|
+
...(s["direct"] === true ? { direct: true } : {}),
|
|
77
|
+
...(typeof s["name"] === "string" ? { name: s["name"] } : {}),
|
|
76
78
|
};
|
|
77
79
|
}
|
|
78
80
|
catch {
|
|
@@ -187,6 +189,10 @@ export class GroupCall {
|
|
|
187
189
|
dialled = new Map();
|
|
188
190
|
/** How many times each leg has been asked for a code, so it is not forever. */
|
|
189
191
|
asks = new Map();
|
|
192
|
+
/** Names for legs `dial` placed with one, used when they enter. */
|
|
193
|
+
legName = new Map();
|
|
194
|
+
/** Which room each conference we are recording belongs to, for the saved webhook. */
|
|
195
|
+
recordingRoom = new Map();
|
|
190
196
|
/** Legs whose voice failed once, which hear the plain one from then on. */
|
|
191
197
|
plainVoice = new Set();
|
|
192
198
|
/** Names promised for callers about to arrive from a number: agents announce themselves. */
|
|
@@ -281,13 +287,34 @@ export class GroupCall {
|
|
|
281
287
|
async handle(event) {
|
|
282
288
|
const type = event.event_type ?? "";
|
|
283
289
|
const payload = event.payload ?? {};
|
|
290
|
+
// A conference event, not a leg's: it may carry no call_control_id at all.
|
|
291
|
+
if (type === "conference.recording.saved") {
|
|
292
|
+
const conferenceId = typeof payload["conference_id"] === "string" ? payload["conference_id"] : "";
|
|
293
|
+
const recordingId = typeof payload["recording_id"] === "string" ? payload["recording_id"] : "";
|
|
294
|
+
if (!conferenceId || !recordingId)
|
|
295
|
+
return;
|
|
296
|
+
const urls = (payload["recording_urls"] ?? payload["public_recording_urls"] ?? {});
|
|
297
|
+
const str = (v) => (typeof v === "string" && v !== "" ? v : null);
|
|
298
|
+
const code = this.recordingRoom.get(conferenceId) ?? null;
|
|
299
|
+
this.recordingRoom.delete(conferenceId);
|
|
300
|
+
this.options.onRecording?.({
|
|
301
|
+
code,
|
|
302
|
+
conferenceId,
|
|
303
|
+
recordingId,
|
|
304
|
+
mp3: str(urls["mp3"]),
|
|
305
|
+
wav: str(urls["wav"]),
|
|
306
|
+
startedAt: str(payload["recording_started_at"]),
|
|
307
|
+
endedAt: str(payload["recording_ended_at"]),
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
284
311
|
const leg = typeof payload["call_control_id"] === "string" ? payload["call_control_id"] : "";
|
|
285
312
|
if (!leg)
|
|
286
313
|
return;
|
|
287
314
|
if (type === "call.initiated") {
|
|
288
315
|
// Only inbound. An outbound leg we dialled is not somebody calling in,
|
|
289
316
|
// and answering it would be answering ourselves.
|
|
290
|
-
if (payload["direction"] !== "incoming")
|
|
317
|
+
if (payload["direction"] !== "incoming" || this.dialled.has(leg))
|
|
291
318
|
return;
|
|
292
319
|
if (typeof payload["from"] === "string")
|
|
293
320
|
this.legFrom.set(leg, payload["from"]);
|
|
@@ -295,14 +322,23 @@ export class GroupCall {
|
|
|
295
322
|
return;
|
|
296
323
|
}
|
|
297
324
|
if (type === "call.answered") {
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
325
|
+
// A leg `dial` rang is somebody picking up, to be asked in -- checked before
|
|
326
|
+
// the direction, because a call to a SIP URI does not always say "outgoing".
|
|
327
|
+
// Any other answered outbound leg is somebody else's business.
|
|
328
|
+
const dialled = this.dialled.get(leg) ?? dialState(payload["client_state"]);
|
|
329
|
+
if (dialled?.direct) {
|
|
330
|
+
this.dialled.delete(leg);
|
|
331
|
+
if (dialled.name)
|
|
332
|
+
this.legName.set(leg, dialled.name);
|
|
333
|
+
await this.join(leg, dialled.code, null);
|
|
304
334
|
return;
|
|
305
335
|
}
|
|
336
|
+
if (dialled) {
|
|
337
|
+
await this.offer(leg, dialled);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (payload["direction"] === "outgoing")
|
|
341
|
+
return;
|
|
306
342
|
await this.ask(leg);
|
|
307
343
|
return;
|
|
308
344
|
}
|
|
@@ -355,6 +391,7 @@ export class GroupCall {
|
|
|
355
391
|
this.release(leg);
|
|
356
392
|
this.legFrom.delete(leg);
|
|
357
393
|
this.dialled.delete(leg);
|
|
394
|
+
this.legName.delete(leg);
|
|
358
395
|
this.pendingGather.delete(leg);
|
|
359
396
|
this.asks.delete(leg);
|
|
360
397
|
this.plainVoice.delete(leg);
|
|
@@ -372,38 +409,59 @@ export class GroupCall {
|
|
|
372
409
|
* room. The room is carried in the call's client_state too, so an answer
|
|
373
410
|
* that arrives after a restart still knows where it was going. Returns the
|
|
374
411
|
* new leg, or null when Telnyx would not place the call.
|
|
412
|
+
*
|
|
413
|
+
* `direct` skips the press-1 question and joins on answer, silently: for a
|
|
414
|
+
* machine that will not press anything, like a voice agent. `to` may then be
|
|
415
|
+
* a SIP URI (`sip:+1555...@sip.example.com`), and `headers` are sent on the
|
|
416
|
+
* INVITE as custom X- headers, which is how such an agent is told what call
|
|
417
|
+
* this is.
|
|
375
418
|
*/
|
|
376
419
|
async dial(code, to, opts) {
|
|
377
|
-
const
|
|
420
|
+
const sip = opts.direct === true && /^sips?:[^\s@]+@[^\s@]+$/i.test(to.trim());
|
|
421
|
+
const number = sip ? to.trim() : normalNumber(to);
|
|
378
422
|
if (!number || !opts.connectionId || !opts.from)
|
|
379
423
|
return null;
|
|
380
|
-
const state = {
|
|
424
|
+
const state = {
|
|
425
|
+
code,
|
|
426
|
+
...(opts.prompt ? { prompt: opts.prompt.slice(0, 300) } : {}),
|
|
427
|
+
...(opts.welcome ? { welcome: opts.welcome.slice(0, 200) } : {}),
|
|
428
|
+
...(opts.direct ? { direct: true } : {}),
|
|
429
|
+
...(opts.direct && opts.name ? { name: opts.name.slice(0, 60) } : {}),
|
|
430
|
+
};
|
|
431
|
+
const headers = Object.entries(opts.headers ?? {})
|
|
432
|
+
.filter(([name]) => /^X-[A-Za-z0-9-]{1,60}$/.test(name))
|
|
433
|
+
.map(([name, value]) => ({ name, value: String(value).slice(0, 200) }));
|
|
381
434
|
const placed = (await this.request("/calls", {
|
|
382
435
|
connection_id: opts.connectionId,
|
|
383
436
|
to: number,
|
|
384
437
|
from: normalNumber(opts.from),
|
|
385
438
|
timeout_secs: opts.timeoutSecs ?? 30,
|
|
386
439
|
client_state: Buffer.from(JSON.stringify({ gc: "dial", ...state }), "utf8").toString("base64"),
|
|
440
|
+
...(headers.length ? { custom_headers: headers } : {}),
|
|
387
441
|
}));
|
|
388
442
|
const leg = placed?.data?.call_control_id;
|
|
389
443
|
if (typeof leg !== "string")
|
|
390
444
|
return null;
|
|
391
445
|
this.dialled.set(leg, state);
|
|
392
446
|
this.legFrom.set(leg, number);
|
|
393
|
-
if (opts.name)
|
|
447
|
+
if (opts.name && opts.direct)
|
|
448
|
+
this.legName.set(leg, opts.name.slice(0, 60));
|
|
449
|
+
else if (opts.name)
|
|
394
450
|
this.expect(code, number, opts.name, 5 * 60 * 1000);
|
|
395
451
|
this.options.onEvent?.(` ringing a phone into a room.`);
|
|
396
452
|
return { leg };
|
|
397
453
|
}
|
|
398
|
-
/**
|
|
399
|
-
|
|
454
|
+
/**
|
|
455
|
+
* A dialled phone was picked up: press 1 to join, so voicemail never does.
|
|
456
|
+
* Telnyx itself replays the prompt (three tries) and ignores any other key,
|
|
457
|
+
* so an ended gather without a 1 is a no.
|
|
458
|
+
*/
|
|
459
|
+
async offer(leg, dialled) {
|
|
400
460
|
this.dialled.delete(leg);
|
|
401
461
|
const prompt = dialled.prompt ?? "This is group call, calling you into a room.";
|
|
402
|
-
await this.gather(leg, `${
|
|
462
|
+
await this.gather(leg, `${prompt} Press 1 to join.`, { validDigits: "1", maximumDigits: 1, timeoutMillis: 6000 }, async (digits) => {
|
|
403
463
|
if (digits === "1")
|
|
404
464
|
return this.join(leg, dialled.code, dialled.welcome);
|
|
405
|
-
if (tries < 2)
|
|
406
|
-
return this.offer(leg, dialled, tries + 1);
|
|
407
465
|
await this.command(leg, "speak", { payload: "Okay, not joining. Goodbye.", ...this.voiceBody(leg) });
|
|
408
466
|
await this.command(leg, "hangup", {});
|
|
409
467
|
});
|
|
@@ -492,7 +550,7 @@ export class GroupCall {
|
|
|
492
550
|
* Put a leg into a room, making the conference if it is the first one there.
|
|
493
551
|
*
|
|
494
552
|
* `welcome` is what the caller hears once they are in, before the count of
|
|
495
|
-
* who else is.
|
|
553
|
+
* who else is; null joins them without a word.
|
|
496
554
|
*/
|
|
497
555
|
async join(leg, code, welcome = `Welcome to room ${spokenCode(code)}. `) {
|
|
498
556
|
const room = this.room(code);
|
|
@@ -586,7 +644,7 @@ export class GroupCall {
|
|
|
586
644
|
if (room.legs.has(leg))
|
|
587
645
|
return;
|
|
588
646
|
const from = this.legFrom.get(leg) ?? "";
|
|
589
|
-
const who = { leg, from, name: this.claimName(room.code, from), joinedAt: this.now() };
|
|
647
|
+
const who = { leg, from, name: this.legName.get(leg) || this.claimName(room.code, from), joinedAt: this.now() };
|
|
590
648
|
room.legs.set(leg, who);
|
|
591
649
|
room.callers = room.legs.size;
|
|
592
650
|
this.options.onEvent?.(` ${who.name || "a caller"} joined a room (${room.callers} on the line).`);
|
|
@@ -626,7 +684,7 @@ export class GroupCall {
|
|
|
626
684
|
* the room do not hear every arrival welcomed.
|
|
627
685
|
*/
|
|
628
686
|
async greet(room, leg, welcome) {
|
|
629
|
-
if (room.conferenceId === null)
|
|
687
|
+
if (room.conferenceId === null || welcome === null)
|
|
630
688
|
return;
|
|
631
689
|
await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
|
|
632
690
|
payload: `${welcome}${peopleHere(room.callers)}`,
|
|
@@ -654,6 +712,53 @@ export class GroupCall {
|
|
|
654
712
|
});
|
|
655
713
|
return answer !== null;
|
|
656
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* Record the room's conference, everyone mixed into one file. Runs until the
|
|
717
|
+
* conference ends (the last caller leaves) or `stopRecording`; `onRecording`
|
|
718
|
+
* hears when the file is saved. False when nobody is on the line yet, since
|
|
719
|
+
* there is no conference to record until somebody is.
|
|
720
|
+
*/
|
|
721
|
+
async record(code, opts = {}) {
|
|
722
|
+
const room = this.rooms.get(code);
|
|
723
|
+
if (!room || room.conferenceId === null || room.callers === 0)
|
|
724
|
+
return false;
|
|
725
|
+
if (room.recordingConf === room.conferenceId)
|
|
726
|
+
return true;
|
|
727
|
+
const started = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/record_start`, {
|
|
728
|
+
format: opts.format ?? "mp3",
|
|
729
|
+
play_beep: opts.beep ?? false,
|
|
730
|
+
});
|
|
731
|
+
if (started === null)
|
|
732
|
+
return false;
|
|
733
|
+
room.recordingConf = room.conferenceId;
|
|
734
|
+
this.recordingRoom.set(room.conferenceId, code);
|
|
735
|
+
this.options.onEvent?.(` recording a room.`);
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
/** Whether this process is recording the room's current conference. */
|
|
739
|
+
recording(code) {
|
|
740
|
+
const room = this.rooms.get(code);
|
|
741
|
+
return Boolean(room && room.conferenceId !== null && room.recordingConf === room.conferenceId);
|
|
742
|
+
}
|
|
743
|
+
/** Stop recording; the file is saved and `onRecording` follows. */
|
|
744
|
+
async stopRecording(code) {
|
|
745
|
+
const room = this.rooms.get(code);
|
|
746
|
+
if (!room || room.conferenceId === null || room.recordingConf !== room.conferenceId)
|
|
747
|
+
return false;
|
|
748
|
+
const stopped = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/record_stop`, {});
|
|
749
|
+
if (stopped !== null)
|
|
750
|
+
room.recordingConf = null;
|
|
751
|
+
return stopped !== null;
|
|
752
|
+
}
|
|
753
|
+
/** A saved recording's current download links, for when the webhook's have expired. */
|
|
754
|
+
async recordingLinks(recordingId) {
|
|
755
|
+
const got = (await this.get(`/recordings/${encodeURIComponent(recordingId)}`));
|
|
756
|
+
const urls = got?.data?.download_urls;
|
|
757
|
+
if (!urls)
|
|
758
|
+
return null;
|
|
759
|
+
const str = (v) => (typeof v === "string" && v !== "" ? v : null);
|
|
760
|
+
return { mp3: str(urls["mp3"]), wav: str(urls["wav"]) };
|
|
761
|
+
}
|
|
657
762
|
/** Hang up one participant in a room. */
|
|
658
763
|
async kick(code, leg) {
|
|
659
764
|
if (!this.rooms.get(code)?.legs.has(leg))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/group-call",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A group call on a phone number: dial in, key a room code, talk with everyone else who did, people and AI agents alike. Telnyx Call Control state machine extracted from nixamp's party line; the engine behind nixamp live joins and group.call.pit.world.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|