callkaro-cli-unofficial 0.5.0 → 0.6.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/dist/index.js CHANGED
@@ -36,6 +36,8 @@ const batches_1 = require("./commands/batches");
36
36
  const simulations_1 = require("./commands/simulations");
37
37
  const simVariables_1 = require("./commands/simVariables");
38
38
  const dashboardApiCmd_1 = require("./commands/dashboardApiCmd");
39
+ const adminAuth_1 = require("./lib/adminAuth");
40
+ const admin_1 = require("./commands/admin");
39
41
  const auditStrategies_1 = require("./commands/auditStrategies");
40
42
  const chatAgents_1 = require("./commands/chatAgents");
41
43
  // The command this fork installs as. Deliberately NOT `ck` or `callkaro`:
@@ -71,6 +73,7 @@ program
71
73
  .description('sign in through your browser (no password in the terminal)')
72
74
  .option('--password', 'use the classic email + password prompt instead')
73
75
  .option('--email <email>', 'email address (with --password)')
76
+ .option('--no-save-password', "don't keep the password (the token then cannot be renewed)")
74
77
  .action(accounts_1.loginCommand);
75
78
  program
76
79
  .command('logout')
@@ -114,6 +117,150 @@ dashboard
114
117
  .option('--query <k=v...>', 'query parameter, repeatable', (v, prev = []) => [...prev, v])
115
118
  .addHelpText('after', '\n Example: cku dashboard api GET /v1/test-simulations --query agentId=<id>\n Errors print the status and full body to stderr and exit 1.')
116
119
  .action(dashboardApiCmd_1.dashboardApiCommand);
120
+ // The internal admin console (intadmin.callkaro.ai). A third credential: the
121
+ // /monitor/* routes answer to the access_token_monitor cookie and to nothing
122
+ // else. It is the only place the console's write routes live — recharge,
123
+ // concurrency, pause/resume, phone numbers — and the only way to act on a
124
+ // client account when no login of yours is assigned to it.
125
+ const admin = program
126
+ .command('admin')
127
+ .description('the internal admin console: client accounts, analytics, and the console writes');
128
+ admin
129
+ .command('login')
130
+ .description('sign in to the internal admin console')
131
+ .option('--email <email>', 'skip the email prompt')
132
+ .option('--no-save-password', "don't keep the password (the session then lapses after 24h)")
133
+ .action(adminAuth_1.adminLoginCommand);
134
+ admin
135
+ .command('whoami')
136
+ .description('prove the stored admin console session still works')
137
+ .option('--json', 'output raw JSON')
138
+ .action(adminAuth_1.adminWhoamiCommand);
139
+ admin
140
+ .command('logout')
141
+ .description('forget the admin console session (the ck_ token is kept)')
142
+ .option('--yes', 'skip the confirmation')
143
+ .action(adminAuth_1.adminLogoutCommand);
144
+ admin
145
+ .command('clients', { isDefault: true })
146
+ .description('list the client accounts assigned to this admin user')
147
+ .option('--json', 'output raw JSON')
148
+ .action(admin_1.adminClientsCommand);
149
+ admin
150
+ .command('client <clientId>')
151
+ .description('show one client account: plan, credits, concurrency, phone numbers')
152
+ .option('--json', 'output raw JSON')
153
+ .action(admin_1.adminClientCommand);
154
+ admin
155
+ .command('coworkers <clientId>')
156
+ .description('list the coworkers assigned to a client account')
157
+ .option('--json', 'output raw JSON')
158
+ .action(admin_1.adminCoworkersCommand);
159
+ admin
160
+ .command('impersonate <clientId>')
161
+ .description("mint a real ck_ token for a client account and store it, scoped to that client")
162
+ .option('--yes', 'skip the confirmation when the account already has a login')
163
+ .addHelpText('after', '\n This is the console\'s "Login as this user", in the terminal. The token is stored\n' +
164
+ ' under the client\'s own email, so `cku accounts use <email>` picks it up, and it\n' +
165
+ ' renews itself by impersonating again.\n')
166
+ .action(admin_1.adminImpersonateCommand);
167
+ admin
168
+ .command('analytics')
169
+ .description('call, minute and cost totals per client')
170
+ .option('--client <id>', 'scope to one client account id')
171
+ .option('--start <YYYY-MM-DD>', 'window start (default: one month ago)')
172
+ .option('--end <YYYY-MM-DD>', 'window end (default: today)')
173
+ .option('--json', 'output raw JSON')
174
+ .action(admin_1.adminAnalyticsCommand);
175
+ admin
176
+ .command('costs')
177
+ .description('total and daily cost, broken down by subscription and category')
178
+ .option('--client <id>', 'scope to one client account id')
179
+ .option('--start <YYYY-MM-DD>', 'window start (default: one month ago)')
180
+ .option('--end <YYYY-MM-DD>', 'window end (default: today)')
181
+ .option('--json', 'output raw JSON')
182
+ .action(admin_1.adminCostsCommand);
183
+ admin
184
+ .command('recharges <clientId>')
185
+ .description('a client account\'s recharge history')
186
+ .option('--start <YYYY-MM-DD>', 'window start (default: one month ago)')
187
+ .option('--end <YYYY-MM-DD>', 'window end (default: today)')
188
+ .option('--json', 'output raw JSON')
189
+ .action(admin_1.adminRechargesCommand);
190
+ admin
191
+ .command('calls')
192
+ .description('look up calls by phone number, lead id, buylead, call sid or call id')
193
+ .option('--phone <number>', 'the number that was called')
194
+ .option('--lead-id <id>', 'lead id')
195
+ .option('--buylead <id>', 'buylead id')
196
+ .option('--call-sid <sid>', 'call sid')
197
+ .option('--call-id <id>', 'call id')
198
+ .option('--json', 'output raw JSON')
199
+ .addHelpText('after', '\n The search needs exactly one of those. Filling more than one narrows it.')
200
+ .action(admin_1.adminCallsCommand);
201
+ admin
202
+ .command('bots')
203
+ .description('the bots (agent + client pairs) seen over a window')
204
+ .option('--start <YYYY-MM-DD>', 'window start (default: one month ago)')
205
+ .option('--end <YYYY-MM-DD>', 'window end (default: today)')
206
+ .option('--json', 'output raw JSON')
207
+ .action(admin_1.adminBotsCommand);
208
+ admin
209
+ .command('sentry')
210
+ .description('Sentry transactions over the last 24h')
211
+ .option('--json', 'output raw JSON')
212
+ .action(admin_1.adminSentryCommand);
213
+ admin
214
+ .command('recharge <clientId>')
215
+ .description('add credits to a client account (real money — the console has no undo)')
216
+ .requiredOption('--amount <n>', 'credits to add')
217
+ .option('--yes', 'skip the confirmation')
218
+ .action(admin_1.adminRechargeCommand);
219
+ admin
220
+ .command('set <clientId>')
221
+ .description("change a client account's settings")
222
+ .option('--concurrency <n>', 'set the concurrency limit')
223
+ .option('--yes', 'skip the confirmation')
224
+ .action(admin_1.adminClientSetCommand);
225
+ admin
226
+ .command('pause <clientId>')
227
+ .description('pause a client account — no more calls dial out for it')
228
+ .option('--yes', 'skip the confirmation')
229
+ .action((clientId, opts) => (0, admin_1.adminClientActionCommand)('pause', clientId, opts));
230
+ admin
231
+ .command('resume <clientId>')
232
+ .description('resume a paused client account')
233
+ .option('--yes', 'skip the confirmation')
234
+ .action((clientId, opts) => (0, admin_1.adminClientActionCommand)('resume', clientId, opts));
235
+ admin
236
+ .command('clear <clientId>')
237
+ .description("clear a client account's whole call queue")
238
+ .option('--yes', 'skip the confirmation')
239
+ .action((clientId, opts) => (0, admin_1.adminClientActionCommand)('clear', clientId, opts));
240
+ admin
241
+ .command('agent <action> <clientId> <agentId>')
242
+ .description('pause, resume or clear one agent of a client account (action: pause, resume, clear)')
243
+ .option('--yes', 'skip the confirmation')
244
+ .action(admin_1.adminAgentActionCommand);
245
+ admin
246
+ .command('numbers <clientIds...>')
247
+ .description('add or replace the phone numbers on several client accounts at once')
248
+ .requiredOption('--phone <n,n>', 'phone numbers, comma separated')
249
+ .option('--mode <mode>', 'add (keep existing) or replace (default)', 'replace')
250
+ .option('--yes', 'skip the confirmation')
251
+ .action(admin_1.adminNumbersCommand);
252
+ admin
253
+ .command('voice-cache')
254
+ .description('clear the console-wide voice cache')
255
+ .option('--yes', 'skip the confirmation')
256
+ .action(admin_1.adminVoiceCacheCommand);
257
+ admin
258
+ .command('api <method> <path>')
259
+ .description('call any admin route with the stored session (the token never leaves the CLI)')
260
+ .option('--body <json|@file>', 'JSON request body')
261
+ .option('--query <k=v...>', 'query parameter, repeatable', (v, prev = []) => [...prev, v])
262
+ .addHelpText('after', '\n Example: cku admin api GET /monitor/bots --query startDate=2026-01-01\n Errors print the status and full body to stderr and exit 1.')
263
+ .action(admin_1.adminApiCommand);
117
264
  // Which models the server accepts. Same source the validator uses, so it cannot drift.
118
265
  program
119
266
  .command('models')
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AdminApi = exports.AdminError = void 0;
4
+ exports.isNotFound = isNotFound;
5
+ exports.adminApi = adminApi;
6
+ exports.normalisePhone = normalisePhone;
7
+ const config_1 = require("./config");
8
+ const adminAuth_1 = require("./adminAuth");
9
+ /**
10
+ * Client for the internal admin console's API — `https://intadmin.callkaro.ai`
11
+ * and the `/monitor/*` routes on the backend.
12
+ *
13
+ * ## A third credential, interchangeable with neither of the other two
14
+ *
15
+ * | credential | how it travels | reaches |
16
+ * |--------------|----------------|--------------------------------|
17
+ * | `ck_…` | bearer | `/cli/*` |
18
+ * | dashboard JWT| bearer + cookie| `/auth/*`, `/v1/*`, `/v2/agents/`|
19
+ * | `access_token_monitor` | cookie | `/monitor/*` |
20
+ *
21
+ * Each is rejected by the other two's routes. Sending the wrong one produces an
22
+ * unexplained 401 rather than anything diagnostic, which is why this client
23
+ * exists as a separate file rather than a flag on `DashboardApi`.
24
+ *
25
+ * ## The surface is undocumented
26
+ *
27
+ * Every route, query-parameter name and request body below was read out of the
28
+ * console's own JS bundle and confirmed by probing the live server. Two of them
29
+ * are misspelled upstream and must be sent exactly as they are:
30
+ *
31
+ * - the recharge body field is `rechargeAmmount` (not `rechargeAmount`)
32
+ * - the clients list is `GET /monitor/users/:adminId` — there is no
33
+ * `GET /monitor/clients`
34
+ *
35
+ * See docs/admin-api.md for the full map, including which routes 403 for which
36
+ * role.
37
+ */
38
+ /**
39
+ * A non-2xx response from the admin API.
40
+ *
41
+ * Shaped like `DashboardError` on the dashboard side, so a caller reads
42
+ * `err.status` the same way on every surface. Extends Error so existing
43
+ * `handleError` paths and message-based assertions keep working.
44
+ */
45
+ class AdminError extends Error {
46
+ status;
47
+ data;
48
+ /** `data` is the parsed error body, kept whole for callers (`admin api`) that print it. */
49
+ constructor(status, message, data) {
50
+ super(message);
51
+ this.status = status;
52
+ this.data = data;
53
+ this.name = 'AdminError';
54
+ }
55
+ }
56
+ exports.AdminError = AdminError;
57
+ /** True when the failure was "no such record", as opposed to a real error. */
58
+ function isNotFound(err) {
59
+ return err instanceof AdminError && err.status === 404;
60
+ }
61
+ class AdminApi {
62
+ baseUrl;
63
+ constructor(baseUrl) {
64
+ this.baseUrl = baseUrl;
65
+ }
66
+ /**
67
+ * One place that adds auth, so no caller can forget it. `requireAdmin()` runs
68
+ * per request rather than once at construction: it is what proves the stored
69
+ * 24h session is still valid, and a long-lived client must not outlive it.
70
+ */
71
+ async request(method, path, body) {
72
+ // baseUrl + path is the whole URL, so a path that does not start with "/"
73
+ // can rewrite the host ("https://backend.callkaro.ai" + "@evil.com/x") and
74
+ // carry the session cookie with it.
75
+ if (!path.startsWith('/'))
76
+ throw new Error(`Admin path must start with "/" (got "${path}").`);
77
+ const { token } = await (0, adminAuth_1.requireAdmin)();
78
+ const headers = {
79
+ 'Content-Type': 'application/json',
80
+ Cookie: `access_token_monitor=${token}`,
81
+ };
82
+ let res;
83
+ try {
84
+ res = await fetch(`${this.baseUrl}${path}`, {
85
+ method,
86
+ headers,
87
+ body: body === undefined ? undefined : JSON.stringify(body),
88
+ // A redirect would re-send the cookie to wherever it points.
89
+ redirect: 'error',
90
+ signal: AbortSignal.timeout(30_000),
91
+ });
92
+ }
93
+ catch (err) {
94
+ throw new Error(`Cannot reach ${this.baseUrl} (${err.message})`);
95
+ }
96
+ const text = await res.text();
97
+ let data;
98
+ try {
99
+ data = text ? JSON.parse(text) : undefined;
100
+ }
101
+ catch {
102
+ data = { msg: text.slice(0, 200) };
103
+ }
104
+ if (!res.ok) {
105
+ // Carry the status, not just the message: a caller that cannot tell 404
106
+ // from 500 has to treat every failure the same.
107
+ throw new AdminError(res.status, explain(data?.msg || data?.message || `Request failed (${res.status})`, res.status), data);
108
+ }
109
+ return data;
110
+ }
111
+ // --- identity -------------------------------------------------------------
112
+ /** The signed-in admin's own record: name, email, role, isSuperAdmin. */
113
+ getAdmin() { return this.request('GET', '/monitor/auth/get-admin'); }
114
+ /**
115
+ * The client accounts this admin is assigned to.
116
+ *
117
+ * There is no `GET /monitor/clients` — the console lists clients through the
118
+ * *admin's* own user record, whose `users[]` array holds them. The id in
119
+ * `admin.user._id` is what this route is keyed by.
120
+ *
121
+ * Note that `GET /monitor/users/:clientId` is a *different* route and 403s
122
+ * for a non-superadmin: a client id is not an admin you may read. The client
123
+ * records come from here, not from there.
124
+ */
125
+ myClients(adminId) { return this.request('GET', `/monitor/users/${encodeURIComponent(adminId)}`); }
126
+ /** The colleagues assigned to a client account. */
127
+ coworkers(clientId) {
128
+ return this.request('GET', `/monitor/clients/${encodeURIComponent(clientId)}/coworkers`);
129
+ }
130
+ /**
131
+ * "Login as this user" — the console's impersonation.
132
+ *
133
+ * Returns a dashboard JWT with an `actAs` claim. It is **not** a `ck_` token
134
+ * and reaches no `/cli/*` route; `impersonateClient` in adminAuth.ts turns it
135
+ * into one.
136
+ */
137
+ impersonate(clientId) {
138
+ return this.request('GET', `/monitor/clients/${encodeURIComponent(clientId)}/impersonate`);
139
+ }
140
+ // --- analytics ------------------------------------------------------------
141
+ //
142
+ // The user filter is `user_ids` (a csv), not `userIds`. `userIds` is
143
+ // accepted and answered with "At least one user must be selected", which
144
+ // reads like a permissions problem rather than a wrong parameter name.
145
+ /** Call, minute and cost totals per client. */
146
+ analytics(query = {}) {
147
+ return this.request('GET', `/monitor/analytics${qs(query)}`);
148
+ }
149
+ /** Just the call counts per client. */
150
+ analyticsCounts(query = {}) {
151
+ return this.request('GET', `/monitor/analytics/counts${qs(query)}`);
152
+ }
153
+ /** Daily / weekly / monthly active users. */
154
+ analyticsUsage(query = {}) {
155
+ return this.request('GET', `/monitor/analytics/usage${qs(query)}`);
156
+ }
157
+ /** The usage trend over the window. */
158
+ analyticsTrend(query = {}) {
159
+ return this.request('GET', `/monitor/analytics/trend${qs(query)}`);
160
+ }
161
+ // --- costs ----------------------------------------------------------------
162
+ // All four need startDate and endDate as YYYY-MM-DD.
163
+ /** Total and daily cost, broken down by subscription and category. */
164
+ costs(query = {}) { return this.request('GET', `/monitor/costs${qs(query)}`); }
165
+ /** Cost per meter. */
166
+ costsMeters(query = {}) {
167
+ return this.request('GET', `/monitor/costs/meters${qs(query)}`);
168
+ }
169
+ /** Cost per resource group. */
170
+ costsResourceGroups(query = {}) {
171
+ return this.request('GET', `/monitor/costs/resource-groups${qs(query)}`);
172
+ }
173
+ /** Subscription costs. */
174
+ costsSubscriptions(query = {}) {
175
+ return this.request('GET', `/monitor/costs/subscriptions${qs(query)}`);
176
+ }
177
+ // --- recharges ------------------------------------------------------------
178
+ /** Credits and amount added per client, over a window. */
179
+ rechargeSummary(query = {}) {
180
+ return this.request('GET', `/monitor/recharges/summary${qs(query)}`);
181
+ }
182
+ /** One client's recharge history. */
183
+ recharges(clientId, query = {}) {
184
+ return this.request('GET', `/monitor/recharges/${encodeURIComponent(clientId)}${qs(query)}`);
185
+ }
186
+ // --- calls ----------------------------------------------------------------
187
+ //
188
+ // The search needs exactly one of: to (phone), lead_id, buylead, call_sid,
189
+ // call_id. Filling more than one narrows the search.
190
+ /** Look up calls by phone number, lead id, buylead, call sid or call id. */
191
+ calls(query = {}) { return this.request('GET', `/monitor/calls${qs(query)}`); }
192
+ // --- bots -----------------------------------------------------------------
193
+ /** The bots (agent+client pairs) seen over a window. */
194
+ bots(query = {}) { return this.request('GET', `/monitor/bots${qs(query)}`); }
195
+ /** Function call statistics per agent. */
196
+ botFunctions(query = {}) {
197
+ return this.request('GET', `/monitor/bots/functions${qs(query)}`);
198
+ }
199
+ // --- observability --------------------------------------------------------
200
+ /** Sentry transactions over the last 24h. */
201
+ sentryTransactions(query = {}) {
202
+ return this.request('GET', `/monitor/sentry/transactions${qs(query)}`);
203
+ }
204
+ /** Sentry queries over the last 24h. */
205
+ sentryQueries(query = {}) {
206
+ return this.request('GET', `/monitor/sentry/queries${qs(query)}`);
207
+ }
208
+ /** Latency series for one transaction. */
209
+ sentryLatencySeries(query = {}) {
210
+ return this.request('GET', `/monitor/sentry/latency-series${qs(query)}`);
211
+ }
212
+ /** Clickhouse queries over a range. */
213
+ metricsQueries(query = {}) {
214
+ return this.request('GET', `/monitor/metrics/clickhouse-queries${qs(query)}`);
215
+ }
216
+ // --- writes ---------------------------------------------------------------
217
+ //
218
+ // These act on live client accounts. The console sends an empty object as the
219
+ // body on every one of them, and the server rejects a missing body, so they
220
+ // are all sent as `{}`.
221
+ /** Pause a client account: no more calls dial out for it. */
222
+ pauseClient(clientId) {
223
+ return this.request('POST', `/monitor/clients/${encodeURIComponent(clientId)}/pause`, {});
224
+ }
225
+ /** Resume a paused client account. */
226
+ resumeClient(clientId) {
227
+ return this.request('POST', `/monitor/clients/${encodeURIComponent(clientId)}/resume`, {});
228
+ }
229
+ /** Clear a client account's whole call queue. */
230
+ clearClientQueue(clientId) {
231
+ return this.request('POST', `/monitor/clients/${encodeURIComponent(clientId)}/clear`, {});
232
+ }
233
+ /** Pause one agent of a client account. */
234
+ pauseAgent(clientId, agentId) {
235
+ return this.request('POST', `/monitor/clients/${encodeURIComponent(clientId)}/agents/${encodeURIComponent(agentId)}/pause`, {});
236
+ }
237
+ /** Resume one agent of a client account. */
238
+ resumeAgent(clientId, agentId) {
239
+ return this.request('POST', `/monitor/clients/${encodeURIComponent(clientId)}/agents/${encodeURIComponent(agentId)}/resume`, {});
240
+ }
241
+ /** Clear one agent's call queue. */
242
+ clearAgentQueue(clientId, agentId) {
243
+ return this.request('POST', `/monitor/clients/${encodeURIComponent(clientId)}/agents/${encodeURIComponent(agentId)}/clear`, {});
244
+ }
245
+ /** Clear the console-wide voice cache. */
246
+ clearVoiceCache() { return this.request('POST', '/monitor/voice-cache/clear', {}); }
247
+ /**
248
+ * Add credits to a client account.
249
+ *
250
+ * `rechargeAmmount` is the server's own spelling — the typo is upstream, and
251
+ * sending `rechargeAmount` is a silent no-op at best. The response carries
252
+ * the updated client as `user`.
253
+ */
254
+ recharge(clientId, amount) {
255
+ return this.request('POST', `/monitor/clients/${encodeURIComponent(clientId)}/recharge`, {
256
+ rechargeAmmount: amount,
257
+ });
258
+ }
259
+ /**
260
+ * Update a client account's settings. The console sends only the fields the
261
+ * form changed, so this is a partial update rather than a replace.
262
+ */
263
+ updateClient(clientId, body) {
264
+ return this.request('PUT', `/monitor/clients/${encodeURIComponent(clientId)}`, body);
265
+ }
266
+ /**
267
+ * Replace or extend the phone numbers on several client accounts at once.
268
+ *
269
+ * `mode` is `add` (keep the existing ones) or `replace`. The console
270
+ * normalises every number to `+` followed by 12 digits and rejects anything
271
+ * else before it sends, so this method does the same.
272
+ */
273
+ bulkUpdatePhoneNumbers(userIds, phoneNumbers, mode = 'replace') {
274
+ return this.request('PUT', '/monitor/clients/bulk-update-phone-numbers', {
275
+ userIds,
276
+ phoneNumbers: phoneNumbers.map(normalisePhone),
277
+ mode,
278
+ });
279
+ }
280
+ }
281
+ exports.AdminApi = AdminApi;
282
+ /** Build a client bound to the configured backend. */
283
+ function adminApi() {
284
+ return new AdminApi((0, config_1.backendUrl)());
285
+ }
286
+ /**
287
+ * `+` then 12 digits, the format the console enforces before it sends.
288
+ * Anything else is a phone number the server will reject.
289
+ */
290
+ function normalisePhone(raw) {
291
+ const digits = raw.replace(/[^0-9]/g, '');
292
+ return `+${digits}`;
293
+ }
294
+ /** `?a=1&b=2`, skipping undefined and empty. Mirrors the one in api.ts. */
295
+ function qs(params) {
296
+ const parts = Object.entries(params)
297
+ .filter(([, v]) => v !== undefined && v !== '')
298
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
299
+ return parts.length ? `?${parts.join('&')}` : '';
300
+ }
301
+ /**
302
+ * Say what a bare "Not Authorized!" actually means.
303
+ *
304
+ * The console's 403 is two different things depending on the route. On a
305
+ * client you do not own it is "This client is not assigned to you" — a
306
+ * question of which account, and one the CLI can answer. On the write routes
307
+ * it is a role limit: an FDE may read a client but not change it. The server
308
+ * says the same two words for both, so the CLI has to name the difference.
309
+ */
310
+ function explain(message, status) {
311
+ if (status !== 403)
312
+ return message;
313
+ if (/not assigned to you/i.test(message)) {
314
+ return `${message}\n The client account must be assigned to your admin user. Run \`cku admin clients\`.`;
315
+ }
316
+ if (/not authorized/i.test(message)) {
317
+ return (`${message}\n` +
318
+ ' The console role on this account cannot make that change. Reading a client is\n' +
319
+ ' allowed; changing one (recharge, concurrency, pause, phone numbers) needs a\n' +
320
+ ' higher role. The account that did the write is a super admin.');
321
+ }
322
+ return message;
323
+ }