@fonoster/callrecord 0.30.0 → 0.31.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.
@@ -46,7 +46,7 @@ export type CallRecordWriterOptions = {
46
46
  };
47
47
  /**
48
48
  * Builds call records live and seals them at hangup (call-record "The record is built during the
49
- * call and sealed at hangup"). It implements the gateway's CallRecordSink interface.
49
+ * call and sealed at hangup"). It implements AppBridge's CallRecordSink interface.
50
50
  */
51
51
  export declare class CallRecordWriter {
52
52
  private readonly options;
@@ -92,7 +92,7 @@ export declare class CallRecordWriter {
92
92
  private sealed;
93
93
  /**
94
94
  * Seals records whose calls stopped appending more than `deadlineMs` ago without a hangup (a
95
- * gateway that crashed). Returns the callRefs sealed.
95
+ * appbridge that crashed). Returns the callRefs sealed.
96
96
  */
97
97
  sweep(now?: number, deadlineMs?: number): Promise<string[]>;
98
98
  /** Adds a version derived from the current one (it references its predecessor). */
@@ -108,7 +108,7 @@ function assertSameWorkspace(callRef, stored, expected) {
108
108
  const OTHER_SEAL_WAIT_MS = 120000;
109
109
  /**
110
110
  * Builds call records live and seals them at hangup (call-record "The record is built during the
111
- * call and sealed at hangup"). It implements the gateway's CallRecordSink interface.
111
+ * call and sealed at hangup"). It implements AppBridge's CallRecordSink interface.
112
112
  */
113
113
  class CallRecordWriter {
114
114
  constructor(options) {
@@ -304,7 +304,7 @@ class CallRecordWriter {
304
304
  }
305
305
  /**
306
306
  * Seals records whose calls stopped appending more than `deadlineMs` ago without a hangup (a
307
- * gateway that crashed). Returns the callRefs sealed.
307
+ * appbridge that crashed). Returns the callRefs sealed.
308
308
  */
309
309
  sweep() {
310
310
  return __awaiter(this, arguments, void 0, function* (now = Date.now(), deadlineMs = exports.SEAL_DEADLINE_MS) {
@@ -14,6 +14,8 @@ export type RecordIndexEntry = {
14
14
  endReason: string | null;
15
15
  failureReason: string | null;
16
16
  sipStatus: number | null;
17
+ /** Who answered, by AMD: human, voicemail, ivr, machine or unknown; null when AMD didn't run. */
18
+ amdStatus: string | null;
17
19
  startedAt: string;
18
20
  endedAt: string | null;
19
21
  durationSeconds: number;
@@ -25,8 +27,20 @@ export type RecordIndexEntry = {
25
27
  size: number;
26
28
  /** Parts present in this version: recording, messages, transcript. */
27
29
  parts: string[];
30
+ /** How the call started: call or browser_test (add-usage-metering). */
31
+ origin?: string;
32
+ /** The call's usage lines, signed with the record (add-usage-metering). */
33
+ usage?: IndexUsageLine[];
28
34
  };
29
- /** NATS subject the gateway publishes index entries on; the control plane consumes it. */
35
+ /** One usage line as the index carries it; the control plane validates it against its meters. */
36
+ export type IndexUsageLine = {
37
+ meter: string;
38
+ quantity: number;
39
+ provider?: string;
40
+ model?: string;
41
+ keySource?: "platform" | "workspace";
42
+ };
43
+ /** NATS subject AppBridge publishes index entries on; the control plane consumes it. */
30
44
  export declare const RECORD_SEALED_SUBJECT = "callrecord.sealed";
31
45
  export declare function indexEntry(callRef: string, sealed: SealedVersion | SealingVersion, vcon: Vcon, where: {
32
46
  region: string;
@@ -21,10 +21,10 @@ exports.indexEntry = indexEntry;
21
21
  * limitations under the License.
22
22
  */
23
23
  const parts_1 = require("./parts");
24
- /** NATS subject the gateway publishes index entries on; the control plane consumes it. */
24
+ /** NATS subject AppBridge publishes index entries on; the control plane consumes it. */
25
25
  exports.RECORD_SEALED_SUBJECT = "callrecord.sealed";
26
26
  function indexEntry(callRef, sealed, vcon, where) {
27
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
27
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
28
28
  const f = ((_a = vcon.fonoster) !== null && _a !== void 0 ? _a : {});
29
29
  const tel = (role) => {
30
30
  const p = vcon.parties.find((x) => x.role === role);
@@ -39,30 +39,14 @@ function indexEntry(callRef, sealed, vcon, where) {
39
39
  const size = sealed.parts.reduce((n, p) => n + (0, parts_1.partSize)(p), 0) +
40
40
  sealed.signed.payload.length +
41
41
  sealed.redacted.payload.length;
42
- return {
43
- callRef,
44
- workspaceRef: (_d = f.workspaceRef) !== null && _d !== void 0 ? _d : null,
45
- appRef: (_f = ((_e = f.appRefs) !== null && _e !== void 0 ? _e : [])[0]) !== null && _f !== void 0 ? _f : null,
46
- from: tel("caller"),
47
- to: tel("callee"),
48
- direction: (_g = f.direction) !== null && _g !== void 0 ? _g : null,
49
- status: String((_h = f.status) !== null && _h !== void 0 ? _h : "completed"),
50
- endReason: (_j = f.endReason) !== null && _j !== void 0 ? _j : null,
51
- failureReason: (_k = failure === null || failure === void 0 ? void 0 : failure.reason) !== null && _k !== void 0 ? _k : null,
52
- sipStatus: (_l = failure === null || failure === void 0 ? void 0 : failure.sipStatus) !== null && _l !== void 0 ? _l : null,
53
- startedAt,
54
- endedAt,
55
- durationSeconds: endedAt
42
+ return Object.assign(Object.assign(Object.assign({ callRef, workspaceRef: (_d = f.workspaceRef) !== null && _d !== void 0 ? _d : null, appRef: (_f = ((_e = f.appRefs) !== null && _e !== void 0 ? _e : [])[0]) !== null && _f !== void 0 ? _f : null, from: tel("caller"), to: tel("callee"), direction: (_g = f.direction) !== null && _g !== void 0 ? _g : null, status: String((_h = f.status) !== null && _h !== void 0 ? _h : "completed"), endReason: (_j = f.endReason) !== null && _j !== void 0 ? _j : null, failureReason: (_k = failure === null || failure === void 0 ? void 0 : failure.reason) !== null && _k !== void 0 ? _k : null, sipStatus: (_l = failure === null || failure === void 0 ? void 0 : failure.sipStatus) !== null && _l !== void 0 ? _l : null, amdStatus: (_o = (_m = f.amd) === null || _m === void 0 ? void 0 : _m.status) !== null && _o !== void 0 ? _o : null, startedAt,
43
+ endedAt, durationSeconds: endedAt
56
44
  ? Math.max(0, Math.round((Date.parse(endedAt) - Date.parse(startedAt)) / 1000))
57
- : 0,
58
- region: where.region,
59
- location: where.location,
60
- version: sealed.version,
61
- size,
62
- parts: [
45
+ : 0, region: where.region, location: where.location }, (f.origin ? { origin: String(f.origin) } : {})), (Array.isArray(f.usage) && f.usage.length
46
+ ? { usage: f.usage }
47
+ : {})), { version: sealed.version, size, parts: [
63
48
  ...(hasRecording ? ["recording"] : []),
64
49
  ...(hasTranscript ? ["transcript"] : []),
65
50
  ...(hasMessages ? ["messages"] : [])
66
- ]
67
- };
51
+ ] });
68
52
  }
package/dist/parts.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FilePart, Part, SealingPart, StoredPart } from "./types";
1
+ import type { FilePart, Part, SealingPart, StoredPart } from "./types";
2
2
  export declare const isFilePart: (p: SealingPart) => p is FilePart;
3
3
  export declare const isStoredPart: (p: SealingPart) => p is StoredPart;
4
4
  export declare const isDataPart: (p: SealingPart) => p is Part;
@@ -34,7 +34,7 @@ export declare class Recorder {
34
34
  export declare function wavHeader(dataBytes: number, channels: number, rate?: number): Buffer<ArrayBuffer>;
35
35
  /**
36
36
  * Builds the stereo WAV (left: caller, right: agent) from a recorder's directory, or undefined
37
- * when nothing was recorded. Works on what a crashed gateway left behind as well.
37
+ * when nothing was recorded. Works on what a crashed AppBridge left behind as well.
38
38
  */
39
39
  export declare function assembleRecording(dir: string): {
40
40
  wav: Buffer;
package/dist/recording.js CHANGED
@@ -72,7 +72,7 @@ const fs = __importStar(require("fs"));
72
72
  const path = __importStar(require("path"));
73
73
  // Full-call recording (call-record "Recording is applied by policy", design V8): an internal
74
74
  // listener attached with media-control `listener.attach`, one unmixed RTP stream per side. The
75
- // recorder writes each side as raw 16 kHz PCM next to the working record, so a gateway crash
75
+ // recorder writes each side as raw 16 kHz PCM next to the working record, so an AppBridge crash
76
76
  // keeps what was recorded; sealing turns the two sides into one stereo WAV (left: caller,
77
77
  // right: agent) and references it, with its hash, from the vCon dialog.
78
78
  exports.RECORDING_RATE = 16000;
@@ -205,7 +205,7 @@ function wavHeader(dataBytes, channels, rate = exports.RECORDING_RATE) {
205
205
  }
206
206
  /**
207
207
  * Builds the stereo WAV (left: caller, right: agent) from a recorder's directory, or undefined
208
- * when nothing was recorded. Works on what a crashed gateway left behind as well.
208
+ * when nothing was recorded. Works on what a crashed AppBridge left behind as well.
209
209
  */
210
210
  function assembleRecording(dir) {
211
211
  const metaFile = path.join(dir, "meta.json");
package/dist/sign.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SignedVcon, Vcon } from "./types";
1
+ import type { SignedVcon, Vcon } from "./types";
2
2
  export type SigningKey = {
3
3
  kid: string;
4
4
  privateKeyPem: string;
package/dist/storage.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ObjectStore } from "./objectStore";
2
2
  import { Part, RecordEntry, SealedVersion, SealingVersion, SignedVcon } from "./types";
3
3
  /**
4
- * Working records live on the gateway's local disk while the call runs: one fsynced JSON line
4
+ * Working records live on AppBridge's local disk while the call runs: one fsynced JSON line
5
5
  * per entry, plus the raw recording tracks. Sealed records go to object storage (RecordStore).
6
6
  */
7
7
  export declare class FsWorkingStore {
@@ -23,7 +23,7 @@ export declare class FsWorkingStore {
23
23
  /**
24
24
  * The owner's heartbeat on a live call's working record (ST-5): moves its time of last append
25
25
  * to now without writing, so a quiet call (hold, long silence) is not taken for an abandoned
26
- * one by another gateway sweeping the same directory.
26
+ * one by another AppBridge sweeping the same directory.
27
27
  */
28
28
  touch(callRef: string): void;
29
29
  private sealLock;
package/dist/storage.js CHANGED
@@ -68,7 +68,7 @@ const path = __importStar(require("path"));
68
68
  const objectStore_1 = require("./objectStore");
69
69
  const parts_1 = require("./parts");
70
70
  /**
71
- * Working records live on the gateway's local disk while the call runs: one fsynced JSON line
71
+ * Working records live on AppBridge's local disk while the call runs: one fsynced JSON line
72
72
  * per entry, plus the raw recording tracks. Sealed records go to object storage (RecordStore).
73
73
  */
74
74
  class FsWorkingStore {
@@ -132,7 +132,7 @@ class FsWorkingStore {
132
132
  /**
133
133
  * The owner's heartbeat on a live call's working record (ST-5): moves its time of last append
134
134
  * to now without writing, so a quiet call (hold, long silence) is not taken for an abandoned
135
- * one by another gateway sweeping the same directory.
135
+ * one by another AppBridge sweeping the same directory.
136
136
  */
137
137
  touch(callRef) {
138
138
  const now = new Date();
package/dist/types.d.ts CHANGED
@@ -16,10 +16,10 @@
16
16
  * See the License for the specific language governing permissions and
17
17
  * limitations under the License.
18
18
  */
19
- /** One working-record entry, as the gateway appends it (voice-session, "Every message is part of the call record"). */
19
+ /** One working-record entry, as AppBridge appends it (voice-session, "Every message is part of the call record"). */
20
20
  export type RecordEntry = {
21
21
  at: number;
22
- source: "app" | "gateway" | "event";
22
+ source: "app" | "appbridge" | "event";
23
23
  message: Record<string, unknown>;
24
24
  };
25
25
  /** A part stored next to the vCon (vCon external references with content hashes). */
package/dist/vcon.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { BeepSpan } from "./recording";
2
- import { RecordEntry, SealingPart, Vcon } from "./types";
1
+ import type { BeepSpan } from "./recording";
2
+ import type { RecordEntry, SealingPart, Vcon } from "./types";
3
3
  /**
4
4
  * Pinned to draft-ietf-vcon-vcon-core-04 (September 2026), which requires "0.4.0" (call-record
5
5
  * open question O4).
package/dist/vcon.js CHANGED
@@ -48,7 +48,7 @@ const iso = (ms) => new Date(ms).toISOString();
48
48
  * attached part with a content hash. Lists what is missing (recording, SIP trace, hangup).
49
49
  */
50
50
  function buildVcon(callRef, entries, options) {
51
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
51
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
52
52
  const parties = [];
53
53
  const dialog = [];
54
54
  const decisions = [];
@@ -60,8 +60,14 @@ function buildVcon(callRef, entries, options) {
60
60
  let endedAt;
61
61
  let endReason;
62
62
  const failures = [];
63
+ // Audio filters that failed, at start or mid-call, with the reason (for the "Unfiltered" badge).
64
+ const filterFailures = [];
65
+ // The call's usage: each snapshot holds all of it, so the last one wins (add-usage-metering).
66
+ let usage;
63
67
  let started;
64
68
  let failure;
69
+ // Who answered (AMD), as AppBridge reported it: the first verdict is the call's.
70
+ let amd;
65
71
  let recordingPolicy;
66
72
  let recordingFailed;
67
73
  let consentRequested = false;
@@ -71,7 +77,7 @@ function buildVcon(callRef, entries, options) {
71
77
  for (const e of entries) {
72
78
  const m = e.message;
73
79
  const type = m.type;
74
- if (e.source === "gateway" && type === "session.start") {
80
+ if (e.source === "appbridge" && type === "session.start") {
75
81
  if (!parties.length) {
76
82
  startedAt = e.at;
77
83
  parties.push({ tel: m.from, role: "caller" }, { tel: m.to, role: "callee" });
@@ -81,7 +87,7 @@ function buildVcon(callRef, entries, options) {
81
87
  appParty.set(appRef, parties.push({ name: appRef, role: "agent", meta: { appRef } }) - 1);
82
88
  currentApp = appParty.get(appRef);
83
89
  }
84
- else if (e.source === "gateway" && type === "turn.ended") {
90
+ else if (e.source === "appbridge" && type === "turn.ended") {
85
91
  const turn = {
86
92
  type: "text",
87
93
  start: iso(e.at),
@@ -104,11 +110,11 @@ function buildVcon(callRef, entries, options) {
104
110
  userStartedAt = undefined;
105
111
  decisions.push(Object.assign({ at: iso(e.at) }, m));
106
112
  }
107
- else if (e.source === "gateway" && type === "turn.user_started") {
113
+ else if (e.source === "appbridge" && type === "turn.user_started") {
108
114
  userStartedAt !== null && userStartedAt !== void 0 ? userStartedAt : (userStartedAt = e.at);
109
115
  decisions.push(Object.assign({ at: iso(e.at) }, m));
110
116
  }
111
- else if (e.source === "gateway" &&
117
+ else if (e.source === "appbridge" &&
112
118
  (type === null || type === void 0 ? void 0 : type.startsWith("turn.")) &&
113
119
  type !== "turn.estimate") {
114
120
  decisions.push(Object.assign({ at: iso(e.at) }, m));
@@ -119,12 +125,12 @@ function buildVcon(callRef, entries, options) {
119
125
  s.text += String((_e = m.text) !== null && _e !== void 0 ? _e : "");
120
126
  speech.set(id, s);
121
127
  }
122
- else if (e.source === "gateway" && type === "output.started") {
128
+ else if (e.source === "appbridge" && type === "output.started") {
123
129
  const s = speech.get(String(m.id));
124
130
  if (s)
125
131
  s.startedAt = e.at;
126
132
  }
127
- else if (e.source === "gateway" &&
133
+ else if (e.source === "appbridge" &&
128
134
  (type === "output.done" || type === "output.interrupted")) {
129
135
  const s = speech.get(String(m.id));
130
136
  if (s && s.text) {
@@ -152,15 +158,24 @@ function buildVcon(callRef, entries, options) {
152
158
  else if (e.source === "event" && type === "call.failed") {
153
159
  failure !== null && failure !== void 0 ? failure : (failure = Object.assign({ reason: String(m.reason) }, (typeof m.sipStatus === "number" ? { sipStatus: m.sipStatus } : {})));
154
160
  }
161
+ else if (e.source === "event" && type === "amd.result") {
162
+ amd !== null && amd !== void 0 ? amd : (amd = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ status: String((_j = m.status) !== null && _j !== void 0 ? _j : "unknown") }, (typeof m.confidence === "number"
163
+ ? { confidence: m.confidence }
164
+ : {})), (typeof m.detector === "string" ? { detector: m.detector } : {})), (typeof m.audioMs === "number" ? { audioMs: m.audioMs } : {})), (typeof m.reason === "string" ? { reason: m.reason } : {})), { at: iso(e.at) }));
165
+ }
155
166
  else if (e.source === "event" && type === "recording.started") {
156
- recordingPolicy = String((_j = m.policy) !== null && _j !== void 0 ? _j : "on");
167
+ recordingPolicy = String((_k = m.policy) !== null && _k !== void 0 ? _k : "on");
157
168
  }
158
169
  else if (e.source === "event" && type === "recording.failed") {
159
- recordingFailed = String((_k = m.reason) !== null && _k !== void 0 ? _k : "error");
170
+ recordingFailed = String((_l = m.reason) !== null && _l !== void 0 ? _l : "error");
160
171
  }
161
172
  else if (e.source === "event" && type === "recording.consent_requested") {
162
173
  consentRequested = true;
163
174
  }
175
+ else if (e.source === "event" && type === "usage.snapshot") {
176
+ if (Array.isArray(m.lines))
177
+ usage = m.lines;
178
+ }
164
179
  else if (e.source === "event" && type === "call.ended") {
165
180
  endedAt = e.at;
166
181
  endReason = String(m.reason);
@@ -170,8 +185,17 @@ function buildVcon(callRef, entries, options) {
170
185
  type === "filter.failed" ||
171
186
  type === "app.dropped")) {
172
187
  failures.push(type);
188
+ if (type === "filter.failed") {
189
+ filterFailures.push(Object.assign(Object.assign({}, (m.name ? { name: String(m.name) } : {})), { reason: String((_m = m.reason) !== null && _m !== void 0 ? _m : "error") }));
190
+ }
173
191
  }
174
192
  }
193
+ // Each spoken turn starts when that side began speaking and lasts until it stopped (vCon
194
+ // dialog `duration`, seconds): the transcript's timings per turn and per side.
195
+ for (const [turn, span] of heard) {
196
+ turn.start = iso(span.from);
197
+ turn.duration = Math.max(0, span.to - span.from) / 1000;
198
+ }
175
199
  // A call that never reached an app has no session.start: its parties come from call.started
176
200
  // (minimal record, call-record "Calls that never reach an app get a minimal record").
177
201
  if (!parties.length && started) {
@@ -261,11 +285,11 @@ function buildVcon(callRef, entries, options) {
261
285
  url: `parts/${p.name}`,
262
286
  content_hash: (0, parts_1.partHash)(p)
263
287
  })),
264
- fonoster: Object.assign(Object.assign(Object.assign({ callRef, region: options.region, startedAt: iso(startedAt), endedAt: endedAt ? iso(endedAt) : null, endReason: endReason !== null && endReason !== void 0 ? endReason : null, status, direction: (_l = started === null || started === void 0 ? void 0 : started.direction) !== null && _l !== void 0 ? _l : null, workspaceRef: (_m = started === null || started === void 0 ? void 0 : started.workspaceRef) !== null && _m !== void 0 ? _m : null, appRefs }, (failure ? { failure } : {})), (recordingPolicy || recordingFailed || consentRequested
288
+ fonoster: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ callRef, region: options.region, startedAt: iso(startedAt), endedAt: endedAt ? iso(endedAt) : null, endReason: endReason !== null && endReason !== void 0 ? endReason : null, status, direction: (_o = started === null || started === void 0 ? void 0 : started.direction) !== null && _o !== void 0 ? _o : null, workspaceRef: (_p = started === null || started === void 0 ? void 0 : started.workspaceRef) !== null && _p !== void 0 ? _p : null, appRefs }, (failure ? { failure } : {})), (amd ? { amd } : {})), (recordingPolicy || recordingFailed || consentRequested
265
289
  ? {
266
290
  recording: Object.assign({ policy: recordingPolicy !== null && recordingPolicy !== void 0 ? recordingPolicy : null, consentRequested }, (recordingFailed ? { failed: recordingFailed } : {}))
267
291
  }
268
- : {})), { complete: !missing.includes("hangup"), missing,
292
+ : {})), (filterFailures.length ? { filters: { failed: filterFailures } } : {})), ((started === null || started === void 0 ? void 0 : started.origin) ? { origin: started.origin } : {})), ((usage === null || usage === void 0 ? void 0 : usage.length) ? { usage } : {})), { complete: !missing.includes("hangup"), missing,
269
293
  failures })
270
294
  };
271
295
  const vcon = Object.assign(Object.assign({}, base), { uuid: (0, crypto_1.randomUUID)(), dialog });
@@ -299,7 +323,7 @@ function buildVcon(callRef, entries, options) {
299
323
  // turn that carried them, since there are no word timings yet (O3). Without a beeped copy the
300
324
  // redacted copy does not reference the recording at all.
301
325
  const beeped = redactions && recording && spans.length && !unlocatable
302
- ? (_o = recording.beep) === null || _o === void 0 ? void 0 : _o.call(recording, spans)
326
+ ? (_q = recording.beep) === null || _q === void 0 ? void 0 : _q.call(recording, spans)
303
327
  : undefined;
304
328
  if (beeped)
305
329
  parts.push(beeped);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonoster/callrecord",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Call records: working records during calls, sealed and signed vCon files after hangup",
5
5
  "author": "Pedro Sanders <psanders@fonoster.com>",
6
6
  "homepage": "https://github.com/fonoster/fonoster#readme",
@@ -13,12 +13,11 @@
13
13
  },
14
14
  "scripts": {
15
15
  "prebuild": "rimraf ./dist tsconfig.tsbuildinfo",
16
- "build": "tsc -b tsconfig.json",
17
- "clean": "rimraf ./dist node_modules tsconfig.tsbuildinfo"
16
+ "build": "tsc -b tsconfig.json"
18
17
  },
19
18
  "dependencies": {
20
19
  "@aws-sdk/client-s3": "^3.958.0",
21
- "@fonoster/logger": "^0.30.0"
20
+ "@fonoster/logger": "^0.31.0"
22
21
  },
23
22
  "files": [
24
23
  "dist",
@@ -40,5 +39,5 @@
40
39
  "devDependencies": {
41
40
  "ajv": "^8.18.0"
42
41
  },
43
- "gitHead": "08c9cfd7932675b6b16111daa4d03f14b4e287d1"
42
+ "gitHead": "f0a5102dddeea0f70f659234108805bc10690f2e"
44
43
  }