discoclaw 1.3.0 → 2.0.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/.env.example +4 -6
- package/.env.example.full +13 -32
- package/README.md +1 -1
- package/dist/cli/dashboard.test.js +0 -4
- package/dist/cli/init-wizard.js +4 -8
- package/dist/cli/init-wizard.test.js +4 -10
- package/dist/config.js +2 -42
- package/dist/config.test.js +8 -72
- package/dist/dashboard/server.js +1 -5
- package/dist/dashboard/server.test.js +3 -6
- package/dist/discord/actions.js +112 -6
- package/dist/discord/actions.test.js +117 -1
- package/dist/discord/help-command.js +1 -1
- package/dist/discord/message-coordinator.js +3 -8
- package/dist/discord/models-command.js +1 -1
- package/dist/discord/reaction-handler.js +2 -2
- package/dist/discord/reaction-handler.test.js +55 -0
- package/dist/discord/verify-push.js +31 -36
- package/dist/discord/verify-push.test.js +34 -6
- package/dist/discord/voice-command.js +1 -31
- package/dist/discord/voice-command.test.js +21 -259
- package/dist/discord/voice-status-command.js +3 -22
- package/dist/discord/voice-status-command.test.js +16 -124
- package/dist/discord-followup.test.js +133 -0
- package/dist/health/config-doctor.js +5 -27
- package/dist/health/config-doctor.test.js +1 -4
- package/dist/index.js +1 -28
- package/dist/runtime-overrides.js +2 -3
- package/dist/runtime-overrides.test.js +27 -193
- package/dist/tasks/store.js +10 -6
- package/dist/tasks/store.test.js +44 -0
- package/dist/tasks/task-action-executor.test.js +162 -50
- package/dist/tasks/task-action-mutations.js +22 -2
- package/dist/tasks/task-action-read-ops.js +7 -1
- package/dist/tasks/task-action-runner-types.js +19 -1
- package/dist/voice/audio-pipeline.js +145 -298
- package/docs/configuration.md +4 -9
- package/docs/official-docs.md +6 -9
- package/docs/runtime-switching.md +1 -1
- package/package.json +1 -1
- package/dist/voice/audio-pipeline.test.js +0 -1100
- package/dist/voice/stt-deepgram.js +0 -154
- package/dist/voice/stt-deepgram.test.js +0 -275
- package/dist/voice/stt-factory.js +0 -42
- package/dist/voice/stt-factory.test.js +0 -45
- package/dist/voice/stt-openai.js +0 -156
- package/dist/voice/stt-openai.test.js +0 -281
- package/dist/voice/tts-cartesia.js +0 -169
- package/dist/voice/tts-cartesia.test.js +0 -228
- package/dist/voice/tts-deepgram.js +0 -84
- package/dist/voice/tts-deepgram.test.js +0 -220
- package/dist/voice/tts-factory.js +0 -52
- package/dist/voice/tts-factory.test.js +0 -53
- package/dist/voice/tts-openai.js +0 -70
- package/dist/voice/tts-openai.test.js +0 -138
- package/dist/voice/types.test.js +0 -90
|
@@ -50,11 +50,11 @@ vi.mock('./task-sync-engine.js', () => {
|
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
51
|
function makeCtx() {
|
|
52
52
|
return {
|
|
53
|
-
guild: {},
|
|
53
|
+
guild: { id: 'guild-current' },
|
|
54
54
|
client: {
|
|
55
55
|
channels: {
|
|
56
56
|
cache: {
|
|
57
|
-
get: () => undefined,
|
|
57
|
+
get: vi.fn(() => undefined),
|
|
58
58
|
},
|
|
59
59
|
},
|
|
60
60
|
},
|
|
@@ -62,8 +62,8 @@ function makeCtx() {
|
|
|
62
62
|
messageId: 'msg-current',
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
65
|
+
function makeTask(id, overrides = {}) {
|
|
66
|
+
return {
|
|
67
67
|
id,
|
|
68
68
|
title: 'Test task',
|
|
69
69
|
description: 'A test',
|
|
@@ -76,34 +76,81 @@ function makeStore() {
|
|
|
76
76
|
comments: [],
|
|
77
77
|
created_at: '2026-01-01T00:00:00Z',
|
|
78
78
|
updated_at: '2026-01-01T00:00:00Z',
|
|
79
|
-
|
|
79
|
+
...overrides,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function makeStore(opts) {
|
|
83
|
+
const tasks = new Map((opts?.initialTasks ?? [
|
|
84
|
+
makeTask('ws-001', { title: 'First' }),
|
|
85
|
+
makeTask('ws-002', {
|
|
86
|
+
title: 'Second',
|
|
87
|
+
status: 'in_progress',
|
|
88
|
+
priority: 1,
|
|
89
|
+
external_ref: 'discord:222333444',
|
|
90
|
+
labels: [],
|
|
91
|
+
}),
|
|
92
|
+
]).map((task) => [task.id, task]));
|
|
80
93
|
return {
|
|
81
|
-
get: vi.fn((id) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
get: vi.fn((id) => tasks.get(id)),
|
|
95
|
+
list: vi.fn((params) => (Array.from(tasks.values()).slice(0, params?.limit ?? 50))),
|
|
96
|
+
create: vi.fn((params) => {
|
|
97
|
+
const createTaskOverrides = typeof opts?.createTaskOverrides === 'function'
|
|
98
|
+
? opts.createTaskOverrides(params)
|
|
99
|
+
: (opts?.createTaskOverrides ?? {});
|
|
100
|
+
const task = makeTask('ws-new', {
|
|
101
|
+
title: params.title,
|
|
102
|
+
description: params.description ?? '',
|
|
103
|
+
priority: params.priority ?? 2,
|
|
104
|
+
external_ref: '',
|
|
105
|
+
labels: params.labels ?? [],
|
|
106
|
+
...createTaskOverrides,
|
|
107
|
+
});
|
|
108
|
+
tasks.set(task.id, task);
|
|
109
|
+
return task;
|
|
110
|
+
}),
|
|
111
|
+
update: vi.fn((id, params) => {
|
|
112
|
+
const prev = tasks.get(id);
|
|
113
|
+
if (!prev)
|
|
114
|
+
throw new Error(`task not found: ${id}`);
|
|
115
|
+
const updated = makeTask(id, {
|
|
116
|
+
...prev,
|
|
117
|
+
...(params.title !== undefined ? { title: params.title } : {}),
|
|
118
|
+
...(params.description !== undefined ? { description: params.description } : {}),
|
|
119
|
+
...(params.priority !== undefined ? { priority: params.priority } : {}),
|
|
120
|
+
...(params.status !== undefined ? { status: params.status } : {}),
|
|
121
|
+
...(params.owner !== undefined ? { owner: params.owner } : {}),
|
|
122
|
+
...(params.externalRef !== undefined ? { external_ref: params.externalRef } : {}),
|
|
123
|
+
...(params.threadOriginGuild !== undefined ? { thread_origin_guild: params.threadOriginGuild } : {}),
|
|
124
|
+
updated_at: '2026-01-02T00:00:00Z',
|
|
125
|
+
});
|
|
126
|
+
tasks.set(id, updated);
|
|
127
|
+
return updated;
|
|
128
|
+
}),
|
|
129
|
+
close: vi.fn((id) => {
|
|
130
|
+
const prev = tasks.get(id);
|
|
131
|
+
if (!prev)
|
|
132
|
+
throw new Error(`task not found: ${id}`);
|
|
133
|
+
const updated = makeTask(id, {
|
|
134
|
+
...prev,
|
|
135
|
+
status: 'closed',
|
|
136
|
+
closed_at: '2026-01-02T00:00:00Z',
|
|
137
|
+
updated_at: '2026-01-02T00:00:00Z',
|
|
138
|
+
});
|
|
139
|
+
tasks.set(id, updated);
|
|
140
|
+
return updated;
|
|
141
|
+
}),
|
|
142
|
+
addLabel: vi.fn((id, label) => {
|
|
143
|
+
const prev = tasks.get(id);
|
|
144
|
+
if (!prev)
|
|
145
|
+
throw new Error(`task not found: ${id}`);
|
|
146
|
+
const updated = makeTask(id, {
|
|
147
|
+
...prev,
|
|
148
|
+
labels: [...new Set([...(prev.labels ?? []), label])],
|
|
149
|
+
updated_at: '2026-01-02T00:00:00Z',
|
|
150
|
+
});
|
|
151
|
+
tasks.set(id, updated);
|
|
152
|
+
return updated;
|
|
85
153
|
}),
|
|
86
|
-
list: vi.fn(() => [
|
|
87
|
-
{ id: 'ws-001', title: 'First', status: 'open', priority: 2 },
|
|
88
|
-
{ id: 'ws-002', title: 'Second', status: 'in_progress', priority: 1 },
|
|
89
|
-
]),
|
|
90
|
-
create: vi.fn((params) => ({
|
|
91
|
-
id: 'ws-new',
|
|
92
|
-
title: params.title,
|
|
93
|
-
description: params.description ?? '',
|
|
94
|
-
status: 'open',
|
|
95
|
-
priority: params.priority ?? 2,
|
|
96
|
-
issue_type: 'task',
|
|
97
|
-
owner: '',
|
|
98
|
-
external_ref: '',
|
|
99
|
-
labels: params.labels ?? [],
|
|
100
|
-
comments: [],
|
|
101
|
-
created_at: '2026-01-01T00:00:00Z',
|
|
102
|
-
updated_at: '2026-01-01T00:00:00Z',
|
|
103
|
-
})),
|
|
104
|
-
update: vi.fn((id) => defaultTask(id)),
|
|
105
|
-
close: vi.fn((id) => ({ ...defaultTask(id), status: 'closed' })),
|
|
106
|
-
addLabel: vi.fn((id) => defaultTask(id)),
|
|
107
154
|
reload: vi.fn(async () => ({ added: [], updated: [], removed: [] })),
|
|
108
155
|
};
|
|
109
156
|
}
|
|
@@ -137,11 +184,19 @@ describe('TASK_ACTION_TYPES', () => {
|
|
|
137
184
|
});
|
|
138
185
|
});
|
|
139
186
|
describe('executeTaskAction', () => {
|
|
140
|
-
it('taskCreate returns
|
|
187
|
+
it('taskCreate returns a real Discord thread URL and structured thread metadata', async () => {
|
|
141
188
|
const result = await executeTaskAction({ type: 'taskCreate', title: 'New task', priority: 1 }, makeCtx(), makeTaskCtx());
|
|
189
|
+
const success = result;
|
|
142
190
|
expect(result.ok).toBe(true);
|
|
143
|
-
expect(
|
|
144
|
-
expect(
|
|
191
|
+
expect(success.summary).toContain('ws-new');
|
|
192
|
+
expect(success.summary).toContain('New task');
|
|
193
|
+
expect(success.summary).toContain('Thread: https://discord.com/channels/guild-current/thread-new');
|
|
194
|
+
expect(success.thread).toEqual({
|
|
195
|
+
externalRef: 'discord:thread-new',
|
|
196
|
+
threadId: 'thread-new',
|
|
197
|
+
threadGuildId: 'guild-current',
|
|
198
|
+
threadUrl: 'https://discord.com/channels/guild-current/thread-new',
|
|
199
|
+
});
|
|
145
200
|
});
|
|
146
201
|
it('taskCreate calls forumCountSync.requestUpdate', async () => {
|
|
147
202
|
const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
|
|
@@ -157,28 +212,29 @@ describe('executeTaskAction', () => {
|
|
|
157
212
|
createTaskThread.mockClear?.();
|
|
158
213
|
const result = await executeTaskAction({ type: 'taskCreate', title: 'No thread please', tags: 'no-thread,feature' }, makeCtx(), makeTaskCtx());
|
|
159
214
|
expect(result.ok).toBe(true);
|
|
215
|
+
expect(result.summary).not.toContain('Thread:');
|
|
216
|
+
expect(result.thread).toBeUndefined();
|
|
160
217
|
expect(createTaskThread).not.toHaveBeenCalled();
|
|
161
218
|
});
|
|
162
|
-
it('taskCreate
|
|
219
|
+
it('taskCreate backfills missing origin guild data for already-linked tasks', async () => {
|
|
163
220
|
const { createTaskThread } = await import('./thread-ops.js');
|
|
164
221
|
createTaskThread.mockClear?.();
|
|
165
|
-
const store = makeStore(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
issue_type: 'task',
|
|
172
|
-
owner: '',
|
|
173
|
-
external_ref: 'discord:thread-existing',
|
|
174
|
-
labels: ['feature'],
|
|
175
|
-
comments: [],
|
|
176
|
-
created_at: '2026-01-01T00:00:00Z',
|
|
177
|
-
updated_at: '2026-01-01T00:00:00Z',
|
|
178
|
-
}));
|
|
222
|
+
const store = makeStore({
|
|
223
|
+
createTaskOverrides: {
|
|
224
|
+
title: 'Already linked',
|
|
225
|
+
external_ref: 'discord:thread-existing',
|
|
226
|
+
},
|
|
227
|
+
});
|
|
179
228
|
const result = await executeTaskAction({ type: 'taskCreate', title: 'Task already linked' }, makeCtx(), makeTaskCtx({ store: store }));
|
|
180
229
|
expect(result.ok).toBe(true);
|
|
181
|
-
expect(result.summary).toContain('thread
|
|
230
|
+
expect(result.summary).toContain('Thread: https://discord.com/channels/guild-current/thread-existing');
|
|
231
|
+
expect(result.thread).toEqual({
|
|
232
|
+
externalRef: 'discord:thread-existing',
|
|
233
|
+
threadId: 'thread-existing',
|
|
234
|
+
threadGuildId: 'guild-current',
|
|
235
|
+
threadUrl: 'https://discord.com/channels/guild-current/thread-existing',
|
|
236
|
+
});
|
|
237
|
+
expect(store.update).toHaveBeenCalledWith('ws-new', { threadOriginGuild: 'guild-current' });
|
|
182
238
|
expect(createTaskThread).not.toHaveBeenCalled();
|
|
183
239
|
});
|
|
184
240
|
it('taskUpdate returns updated summary', async () => {
|
|
@@ -301,9 +357,65 @@ describe('executeTaskAction', () => {
|
|
|
301
357
|
it('taskShow returns task details', async () => {
|
|
302
358
|
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx());
|
|
303
359
|
expect(result.ok).toBe(true);
|
|
304
|
-
expect(result.summary).toContain('
|
|
360
|
+
expect(result.summary).toContain('First');
|
|
305
361
|
expect(result.summary).toContain('ws-001');
|
|
306
362
|
});
|
|
363
|
+
it('taskShow shows External ref for linked tasks even without canonical thread URL data', async () => {
|
|
364
|
+
const store = makeStore({
|
|
365
|
+
initialTasks: [
|
|
366
|
+
makeTask('ws-001', {
|
|
367
|
+
title: 'Linked elsewhere',
|
|
368
|
+
external_ref: 'gh:123',
|
|
369
|
+
thread_origin_guild: undefined,
|
|
370
|
+
}),
|
|
371
|
+
],
|
|
372
|
+
});
|
|
373
|
+
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ store: store }));
|
|
374
|
+
expect(result.ok).toBe(true);
|
|
375
|
+
expect(result.summary).toContain('External ref: gh:123');
|
|
376
|
+
expect(result.summary).not.toContain('Thread:');
|
|
377
|
+
expect(result.thread).toEqual({ externalRef: 'gh:123' });
|
|
378
|
+
});
|
|
379
|
+
it('taskShow only emits Thread when stored origin guild data exists', async () => {
|
|
380
|
+
const store = makeStore({
|
|
381
|
+
initialTasks: [
|
|
382
|
+
makeTask('ws-001', {
|
|
383
|
+
title: 'Thread-linked',
|
|
384
|
+
external_ref: 'discord:111222333',
|
|
385
|
+
thread_origin_guild: 'guild-stored',
|
|
386
|
+
}),
|
|
387
|
+
],
|
|
388
|
+
});
|
|
389
|
+
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ store: store }));
|
|
390
|
+
expect(result.ok).toBe(true);
|
|
391
|
+
expect(result.summary).toContain('External ref: discord:111222333');
|
|
392
|
+
expect(result.summary).toContain('Thread: https://discord.com/channels/guild-stored/111222333');
|
|
393
|
+
expect(result.thread).toEqual({
|
|
394
|
+
externalRef: 'discord:111222333',
|
|
395
|
+
threadId: '111222333',
|
|
396
|
+
threadGuildId: 'guild-stored',
|
|
397
|
+
threadUrl: 'https://discord.com/channels/guild-stored/111222333',
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
it('taskShow does not perform live Discord lookups', async () => {
|
|
401
|
+
const { resolveTasksForum } = await import('./thread-ops.js');
|
|
402
|
+
resolveTasksForum.mockClear?.();
|
|
403
|
+
const ctx = makeCtx();
|
|
404
|
+
const cacheGet = ctx.client.channels.cache.get;
|
|
405
|
+
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, ctx, makeTaskCtx({
|
|
406
|
+
store: makeStore({
|
|
407
|
+
initialTasks: [
|
|
408
|
+
makeTask('ws-001', {
|
|
409
|
+
external_ref: 'discord:111222333',
|
|
410
|
+
thread_origin_guild: 'guild-stored',
|
|
411
|
+
}),
|
|
412
|
+
],
|
|
413
|
+
}),
|
|
414
|
+
}));
|
|
415
|
+
expect(result.ok).toBe(true);
|
|
416
|
+
expect(resolveTasksForum).not.toHaveBeenCalled();
|
|
417
|
+
expect(cacheGet).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
307
419
|
it('taskShow fails for unknown task', async () => {
|
|
308
420
|
const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-notfound' }, makeCtx(), makeTaskCtx());
|
|
309
421
|
expect(result.ok).toBe(false);
|
|
@@ -4,6 +4,7 @@ import { autoTagTask } from './auto-tag.js';
|
|
|
4
4
|
import { taskThreadCache } from './thread-cache.js';
|
|
5
5
|
import { resolveTaskId, resolveTaskService, scheduleRepairSync, } from './task-action-mutation-helpers.js';
|
|
6
6
|
import { ensureCreatedTaskThreadLink, syncClosedTaskThread, syncUpdatedTaskThread, } from './task-action-thread-sync.js';
|
|
7
|
+
import { getTaskActionThreadMetadata } from './task-action-runner-types.js';
|
|
7
8
|
/** Pre-computed set for filtering status names from tag candidates. */
|
|
8
9
|
const STATUS_NAME_SET = new Set(TASK_STATUSES);
|
|
9
10
|
/**
|
|
@@ -73,10 +74,29 @@ export async function handleTaskCreate(action, ctx, taskCtx) {
|
|
|
73
74
|
if (needsRepairSync) {
|
|
74
75
|
scheduleRepairSync(taskCtx, task.id, ctx);
|
|
75
76
|
}
|
|
77
|
+
let linkedTask = taskCtx.store.get(task.id) ?? task;
|
|
78
|
+
const storedThread = getTaskActionThreadMetadata(linkedTask);
|
|
79
|
+
const linkedThreadId = storedThread?.threadId ?? (threadId || undefined);
|
|
80
|
+
const guildId = ctx.guild.id?.trim() || undefined;
|
|
81
|
+
if (linkedThreadId && guildId && !linkedTask.thread_origin_guild) {
|
|
82
|
+
try {
|
|
83
|
+
linkedTask = taskService.update(task.id, {
|
|
84
|
+
...(storedThread ? {} : { externalRef: `discord:${linkedThreadId}` }),
|
|
85
|
+
threadOriginGuild: guildId,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
taskCtx.log?.warn({ err, taskId: task.id, threadId: linkedThreadId, guildId }, 'tasks:thread origin guild update failed');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const thread = getTaskActionThreadMetadata(linkedTask);
|
|
76
93
|
taskThreadCache.invalidate();
|
|
77
94
|
taskCtx.forumCountSync?.requestUpdate();
|
|
78
|
-
const
|
|
79
|
-
|
|
95
|
+
const summary = [
|
|
96
|
+
`Task ${task.id} created: "${task.title}"`,
|
|
97
|
+
...(thread?.threadUrl ? [`Thread: ${thread.threadUrl}`] : []),
|
|
98
|
+
].join('\n');
|
|
99
|
+
return { ok: true, summary, ...(thread ? { thread } : {}) };
|
|
80
100
|
}
|
|
81
101
|
export async function handleTaskUpdate(action, ctx, taskCtx) {
|
|
82
102
|
const taskId = resolveTaskId(action);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runTaskSync } from './task-sync.js';
|
|
2
2
|
import { reloadTagMapInPlace } from './tag-map.js';
|
|
3
|
+
import { getTaskActionThreadMetadata } from './task-action-runner-types.js';
|
|
3
4
|
function resolveTaskId(action) {
|
|
4
5
|
return (action.taskId ?? '').trim();
|
|
5
6
|
}
|
|
@@ -18,11 +19,16 @@ export async function handleTaskShow(action, _ctx, taskCtx) {
|
|
|
18
19
|
];
|
|
19
20
|
if (task.owner)
|
|
20
21
|
lines.push(`Owner: ${task.owner}`);
|
|
22
|
+
const thread = getTaskActionThreadMetadata(task);
|
|
23
|
+
if (thread)
|
|
24
|
+
lines.push(`External ref: ${thread.externalRef}`);
|
|
25
|
+
if (thread?.threadUrl)
|
|
26
|
+
lines.push(`Thread: ${thread.threadUrl}`);
|
|
21
27
|
if (task.labels?.length)
|
|
22
28
|
lines.push(`Labels: ${task.labels.join(', ')}`);
|
|
23
29
|
if (task.description)
|
|
24
30
|
lines.push(`\n${task.description.slice(0, 500)}`);
|
|
25
|
-
return { ok: true, summary: lines.join('\n') };
|
|
31
|
+
return { ok: true, summary: lines.join('\n'), ...(thread ? { thread } : {}) };
|
|
26
32
|
}
|
|
27
33
|
export async function handleTaskList(action, _ctx, taskCtx) {
|
|
28
34
|
const tasks = taskCtx.store.list({
|
|
@@ -1 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
import { getThreadIdFromTask } from './thread-helpers.js';
|
|
2
|
+
export function buildDiscordThreadUrl(guildId, threadId) {
|
|
3
|
+
return `https://discord.com/channels/${guildId}/${threadId}`;
|
|
4
|
+
}
|
|
5
|
+
export function getTaskActionThreadMetadata(task) {
|
|
6
|
+
const externalRef = task.external_ref?.trim() ?? '';
|
|
7
|
+
if (!externalRef)
|
|
8
|
+
return undefined;
|
|
9
|
+
const threadId = getThreadIdFromTask(task) ?? undefined;
|
|
10
|
+
const threadGuildId = task.thread_origin_guild?.trim() || undefined;
|
|
11
|
+
return {
|
|
12
|
+
externalRef,
|
|
13
|
+
...(threadId ? { threadId } : {}),
|
|
14
|
+
...(threadGuildId ? { threadGuildId } : {}),
|
|
15
|
+
...(threadId && threadGuildId
|
|
16
|
+
? { threadUrl: buildDiscordThreadUrl(threadGuildId, threadId) }
|
|
17
|
+
: {}),
|
|
18
|
+
};
|
|
19
|
+
}
|